Skip to main content

neo_decompiler/decompiler/analysis/
call_graph.rs

1//! Call graph construction for Neo N3 scripts.
2
3// Bytecode offset arithmetic requires isize↔usize casts for signed jump deltas.
4// NEF scripts are bounded (~1 MB), so these conversions are structurally safe.
5#![allow(
6    clippy::cast_possible_truncation,
7    clippy::cast_possible_wrap,
8    clippy::cast_sign_loss
9)]
10
11use std::collections::{BTreeMap, BTreeSet};
12
13use serde::Serialize;
14
15use crate::instruction::{Instruction, OpCode, Operand};
16use crate::manifest::ContractManifest;
17use crate::nef::NefFile;
18use crate::{syscalls, util};
19
20use super::{MethodRef, MethodTable};
21
22/// A resolved call target extracted from the instruction stream.
23#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
24#[non_exhaustive]
25pub enum CallTarget {
26    /// Direct call into the same script (CALL/CALL_L).
27    Internal {
28        /// Callee method resolved from the target offset.
29        method: MethodRef,
30    },
31    /// Call to an entry in the NEF method-token table (CALLT).
32    MethodToken {
33        /// Index into the NEF `method_tokens` table.
34        index: u16,
35        /// Script hash (little-endian) for the called contract.
36        hash_le: String,
37        /// Script hash (big-endian) for the called contract.
38        hash_be: String,
39        /// Target method name.
40        method: String,
41        /// Declared parameter count.
42        parameters_count: u16,
43        /// Whether the target method has a return value.
44        has_return_value: bool,
45        /// Call flags bitfield.
46        call_flags: u8,
47    },
48    /// System call (SYSCALL).
49    Syscall {
50        /// Syscall identifier (little-endian u32).
51        hash: u32,
52        /// Resolved syscall name when known.
53        name: Option<String>,
54        /// Whether the syscall is known to push a value.
55        returns_value: bool,
56    },
57    /// Indirect call (e.g., CALLA) where the destination cannot be resolved statically.
58    Indirect {
59        /// Opcode mnemonic (`CALLA` or similar).
60        opcode: String,
61        /// Optional operand value (when present).
62        operand: Option<u16>,
63    },
64    /// A CALL/CALL_L target that could not be resolved to a valid offset.
65    UnresolvedInternal {
66        /// Computed target offset (may be negative when malformed).
67        target: isize,
68    },
69}
70
71/// One call edge in the call graph.
72#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
73pub struct CallEdge {
74    /// Caller method containing the call instruction.
75    pub caller: MethodRef,
76    /// Bytecode offset of the call instruction.
77    pub call_offset: usize,
78    /// Opcode mnemonic of the call instruction (e.g., `CALL_L`, `SYSCALL`).
79    pub opcode: String,
80    /// Resolved target.
81    pub target: CallTarget,
82}
83
84/// Call graph for a decompiled script.
85#[derive(Debug, Clone, Default, Serialize)]
86pub struct CallGraph {
87    /// Known methods (manifest-defined plus synthetic internal targets).
88    pub methods: Vec<MethodRef>,
89    /// Call edges extracted from the instruction stream.
90    pub edges: Vec<CallEdge>,
91}
92
93/// Build a call graph for the provided instruction stream.
94#[must_use]
95pub fn build_call_graph(
96    nef: &NefFile,
97    instructions: &[Instruction],
98    manifest: Option<&ContractManifest>,
99) -> CallGraph {
100    let table = MethodTable::new(instructions, manifest);
101    let mut methods: BTreeMap<usize, MethodRef> = table
102        .spans()
103        .iter()
104        .map(|span| (span.method.offset, span.method.clone()))
105        .collect();
106
107    let mut edges = Vec::new();
108    for (index, instr) in instructions.iter().enumerate() {
109        match instr.opcode {
110            OpCode::Syscall => {
111                let Some(Operand::Syscall(hash)) = instr.operand else {
112                    continue;
113                };
114                let info = syscalls::lookup(hash);
115                edges.push(CallEdge {
116                    caller: table.method_for_offset(instr.offset),
117                    call_offset: instr.offset,
118                    opcode: instr.opcode.to_string(),
119                    target: CallTarget::Syscall {
120                        hash,
121                        name: info.map(|i| i.name.to_string()),
122                        returns_value: info.map(|i| i.returns_value).unwrap_or(true),
123                    },
124                });
125            }
126            OpCode::Call | OpCode::Call_L => {
127                let caller = table.method_for_offset(instr.offset);
128                match relative_target_isize(instr) {
129                    Some(target) if target >= 0 => {
130                        let target = target as usize;
131                        let callee = table.resolve_internal_target(target);
132                        methods.insert(callee.offset, callee.clone());
133                        edges.push(CallEdge {
134                            caller,
135                            call_offset: instr.offset,
136                            opcode: instr.opcode.to_string(),
137                            target: CallTarget::Internal { method: callee },
138                        });
139                    }
140                    Some(target) => edges.push(CallEdge {
141                        caller,
142                        call_offset: instr.offset,
143                        opcode: instr.opcode.to_string(),
144                        target: CallTarget::UnresolvedInternal { target },
145                    }),
146                    None => edges.push(CallEdge {
147                        caller,
148                        call_offset: instr.offset,
149                        opcode: instr.opcode.to_string(),
150                        target: CallTarget::UnresolvedInternal { target: -1 },
151                    }),
152                }
153            }
154            OpCode::CallT => {
155                let Some(Operand::U16(index)) = instr.operand else {
156                    continue;
157                };
158                let token = nef.method_tokens.get(index as usize);
159                if let Some(token) = token {
160                    edges.push(CallEdge {
161                        caller: table.method_for_offset(instr.offset),
162                        call_offset: instr.offset,
163                        opcode: instr.opcode.to_string(),
164                        target: CallTarget::MethodToken {
165                            index,
166                            hash_le: util::format_hash(&token.hash),
167                            hash_be: util::format_hash_be(&token.hash),
168                            method: token.method.clone(),
169                            parameters_count: token.parameters_count,
170                            has_return_value: token.has_return_value,
171                            call_flags: token.call_flags,
172                        },
173                    });
174                } else {
175                    edges.push(CallEdge {
176                        caller: table.method_for_offset(instr.offset),
177                        call_offset: instr.offset,
178                        opcode: instr.opcode.to_string(),
179                        target: CallTarget::Indirect {
180                            opcode: instr.opcode.to_string(),
181                            operand: Some(index),
182                        },
183                    });
184                }
185            }
186            OpCode::CallA => {
187                // CALLA takes no operand — it pops a Pointer from the stack.
188                // Resolve direct PUSHA + CALLA sequences to internal call edges.
189                let caller = table.method_for_offset(instr.offset);
190                if let Some(target) = calla_target_from_pusha(instructions, index) {
191                    let callee = table.resolve_internal_target(target);
192                    methods.insert(callee.offset, callee.clone());
193                    edges.push(CallEdge {
194                        caller,
195                        call_offset: instr.offset,
196                        opcode: instr.opcode.to_string(),
197                        target: CallTarget::Internal { method: callee },
198                    });
199                } else {
200                    edges.push(CallEdge {
201                        caller,
202                        call_offset: instr.offset,
203                        opcode: instr.opcode.to_string(),
204                        target: CallTarget::Indirect {
205                            opcode: instr.opcode.to_string(),
206                            operand: None,
207                        },
208                    });
209                }
210            }
211            _ => {}
212        }
213    }
214
215    // Second pass: resolve CALLA targets that load function pointers from
216    // argument slots (LDARG) by tracing back through callers.
217    resolve_ldarg_calla_targets(instructions, &mut edges, &table, &mut methods);
218
219    CallGraph {
220        methods: methods.into_values().collect(),
221        edges,
222    }
223}
224
225fn relative_target_isize(instr: &Instruction) -> Option<isize> {
226    let delta = match &instr.operand {
227        Some(Operand::Jump(v)) => *v as isize,
228        Some(Operand::Jump32(v)) => *v as isize,
229        _ => return None,
230    };
231    Some(instr.offset as isize + delta)
232}
233
234pub(super) fn calla_target_from_pusha(instructions: &[Instruction], index: usize) -> Option<usize> {
235    let mut cursor = index.checked_sub(1)?;
236    loop {
237        let prev = instructions.get(cursor)?;
238        if prev.opcode == OpCode::Nop {
239            cursor = cursor.checked_sub(1)?;
240            continue;
241        }
242        return trace_pointer_target_from_value_source(instructions, cursor);
243    }
244}
245
246fn pusha_absolute_target(instruction: &Instruction) -> Option<usize> {
247    let delta = match instruction.operand {
248        Some(Operand::U32(value)) => i32::from_le_bytes(value.to_le_bytes()) as isize,
249        Some(Operand::I32(value)) => value as isize,
250        _ => return None,
251    };
252    instruction.offset.checked_add_signed(delta)
253}
254
255#[derive(Clone, Copy, Debug, PartialEq, Eq)]
256enum SlotDomain {
257    Local(u8),
258    Static(u8),
259}
260
261fn local_load_index(instruction: &Instruction) -> Option<u8> {
262    match instruction.opcode {
263        OpCode::Ldloc0 => Some(0),
264        OpCode::Ldloc1 => Some(1),
265        OpCode::Ldloc2 => Some(2),
266        OpCode::Ldloc3 => Some(3),
267        OpCode::Ldloc4 => Some(4),
268        OpCode::Ldloc5 => Some(5),
269        OpCode::Ldloc6 => Some(6),
270        OpCode::Ldloc => match instruction.operand {
271            Some(Operand::U8(index)) => Some(index),
272            _ => None,
273        },
274        _ => None,
275    }
276}
277
278fn static_load_index(instruction: &Instruction) -> Option<u8> {
279    match instruction.opcode {
280        OpCode::Ldsfld0 => Some(0),
281        OpCode::Ldsfld1 => Some(1),
282        OpCode::Ldsfld2 => Some(2),
283        OpCode::Ldsfld3 => Some(3),
284        OpCode::Ldsfld4 => Some(4),
285        OpCode::Ldsfld5 => Some(5),
286        OpCode::Ldsfld6 => Some(6),
287        OpCode::Ldsfld => match instruction.operand {
288            Some(Operand::U8(index)) => Some(index),
289            _ => None,
290        },
291        _ => None,
292    }
293}
294
295fn arg_load_index(instruction: &Instruction) -> Option<u8> {
296    match instruction.opcode {
297        OpCode::Ldarg0 => Some(0),
298        OpCode::Ldarg1 => Some(1),
299        OpCode::Ldarg2 => Some(2),
300        OpCode::Ldarg3 => Some(3),
301        OpCode::Ldarg4 => Some(4),
302        OpCode::Ldarg5 => Some(5),
303        OpCode::Ldarg6 => Some(6),
304        OpCode::Ldarg => match instruction.operand {
305            Some(Operand::U8(index)) => Some(index),
306            _ => None,
307        },
308        _ => None,
309    }
310}
311
312fn slot_store_domain(instruction: &Instruction) -> Option<SlotDomain> {
313    match instruction.opcode {
314        OpCode::Stloc0 => Some(SlotDomain::Local(0)),
315        OpCode::Stloc1 => Some(SlotDomain::Local(1)),
316        OpCode::Stloc2 => Some(SlotDomain::Local(2)),
317        OpCode::Stloc3 => Some(SlotDomain::Local(3)),
318        OpCode::Stloc4 => Some(SlotDomain::Local(4)),
319        OpCode::Stloc5 => Some(SlotDomain::Local(5)),
320        OpCode::Stloc6 => Some(SlotDomain::Local(6)),
321        OpCode::Stloc => match instruction.operand {
322            Some(Operand::U8(index)) => Some(SlotDomain::Local(index)),
323            _ => None,
324        },
325        OpCode::Stsfld0 => Some(SlotDomain::Static(0)),
326        OpCode::Stsfld1 => Some(SlotDomain::Static(1)),
327        OpCode::Stsfld2 => Some(SlotDomain::Static(2)),
328        OpCode::Stsfld3 => Some(SlotDomain::Static(3)),
329        OpCode::Stsfld4 => Some(SlotDomain::Static(4)),
330        OpCode::Stsfld5 => Some(SlotDomain::Static(5)),
331        OpCode::Stsfld6 => Some(SlotDomain::Static(6)),
332        OpCode::Stsfld => match instruction.operand {
333            Some(Operand::U8(index)) => Some(SlotDomain::Static(index)),
334            _ => None,
335        },
336        _ => None,
337    }
338}
339
340fn resolve_slot_pointer_target(
341    instructions: &[Instruction],
342    before_index: usize,
343    domain: SlotDomain,
344) -> Option<usize> {
345    let store_index = find_slot_store_before(instructions, before_index, domain)?;
346    let source_index = previous_non_nop_index(instructions, store_index.checked_sub(1)?)?;
347    trace_pointer_target_from_value_source(instructions, source_index)
348}
349
350fn trace_pointer_target_from_value_source(
351    instructions: &[Instruction],
352    mut source_index: usize,
353) -> Option<usize> {
354    loop {
355        let instruction = instructions.get(source_index)?;
356        if instruction.opcode == OpCode::Dup {
357            source_index = previous_non_nop_index(instructions, source_index.checked_sub(1)?)?;
358            continue;
359        }
360        if instruction.opcode == OpCode::PushA {
361            return pusha_absolute_target(instruction);
362        }
363        if instruction.opcode == OpCode::Pickitem {
364            return resolve_pickitem_pointer_target(instructions, source_index);
365        }
366
367        let domain = if let Some(slot) = local_load_index(instruction) {
368            SlotDomain::Local(slot)
369        } else if let Some(slot) = static_load_index(instruction) {
370            SlotDomain::Static(slot)
371        } else {
372            return None;
373        };
374
375        let store_index = find_slot_store_before(instructions, source_index, domain)?;
376        source_index = previous_non_nop_index(instructions, store_index.checked_sub(1)?)?;
377    }
378}
379
380// ---------------------------------------------------------------------------
381// Second-pass inter-procedural CALLA resolution for LDARG patterns
382// ---------------------------------------------------------------------------
383
384/// Check if a CALLA instruction ultimately loads its pointer from an argument
385/// slot and return that argument index.
386pub(super) fn calla_ldarg_index(instructions: &[Instruction], calla_index: usize) -> Option<u8> {
387    let producer_index = previous_non_nop_index(instructions, calla_index.checked_sub(1)?)?;
388    trace_argument_index_from_value_source(instructions, producer_index)
389}
390
391fn trace_argument_index_from_value_source(
392    instructions: &[Instruction],
393    mut source_index: usize,
394) -> Option<u8> {
395    loop {
396        let instruction = instructions.get(source_index)?;
397        if instruction.opcode == OpCode::Dup {
398            source_index = previous_non_nop_index(instructions, source_index.checked_sub(1)?)?;
399            continue;
400        }
401        if let Some(arg_index) = arg_load_index(instruction) {
402            return Some(arg_index);
403        }
404
405        let domain = if let Some(slot) = local_load_index(instruction) {
406            SlotDomain::Local(slot)
407        } else if let Some(slot) = static_load_index(instruction) {
408            SlotDomain::Static(slot)
409        } else {
410            return None;
411        };
412
413        let store_index = find_slot_store_before(instructions, source_index, domain)?;
414        source_index = previous_non_nop_index(instructions, store_index.checked_sub(1)?)?;
415    }
416}
417
418fn resolve_pickitem_pointer_target(
419    instructions: &[Instruction],
420    pickitem_index: usize,
421) -> Option<usize> {
422    let index_source = previous_non_nop_index(instructions, pickitem_index.checked_sub(1)?)?;
423    let array_source_index = previous_non_nop_index(instructions, index_source.checked_sub(1)?)?;
424    let domain = trace_container_domain_from_value_source(instructions, array_source_index)?;
425
426    let scan_start = match domain {
427        SlotDomain::Local(_) => find_resolution_start_index(instructions, pickitem_index),
428        SlotDomain::Static(_) => 0,
429    };
430
431    let mut resolved_target = None;
432    for (index, instruction) in instructions
433        .iter()
434        .enumerate()
435        .take(pickitem_index)
436        .skip(scan_start)
437    {
438        if instruction.opcode != OpCode::Append {
439            continue;
440        }
441        let item_index = trace_stack_value_producer_before(instructions, index, 0)?;
442        let array_index = trace_stack_value_producer_before(instructions, index, 1)?;
443        let Some(array_domain) =
444            trace_container_domain_from_value_source(instructions, array_index)
445        else {
446            continue;
447        };
448        if array_domain != domain {
449            continue;
450        }
451        let target = trace_pointer_target_from_value_source(instructions, item_index)?;
452        if let Some(existing) = resolved_target {
453            if existing != target {
454                return None;
455            }
456        } else {
457            resolved_target = Some(target);
458        }
459    }
460    resolved_target
461}
462
463fn trace_container_domain_from_value_source(
464    instructions: &[Instruction],
465    mut source_index: usize,
466) -> Option<SlotDomain> {
467    loop {
468        let instruction = instructions.get(source_index)?;
469        if instruction.opcode == OpCode::Dup {
470            source_index = previous_non_nop_index(instructions, source_index.checked_sub(1)?)?;
471            continue;
472        }
473        if let Some(slot) = local_load_index(instruction) {
474            let domain = SlotDomain::Local(slot);
475            let Some(store_index) = find_slot_store_before(instructions, source_index, domain)
476            else {
477                return Some(domain);
478            };
479            let source = previous_non_nop_index(instructions, store_index.checked_sub(1)?)?;
480            let source_instruction = instructions.get(source)?;
481            if source_instruction.opcode == OpCode::Dup {
482                source_index = previous_non_nop_index(instructions, source.checked_sub(1)?)?;
483                continue;
484            }
485            if local_load_index(source_instruction).is_some()
486                || static_load_index(source_instruction).is_some()
487            {
488                source_index = source;
489                continue;
490            }
491            return Some(domain);
492        }
493        if let Some(slot) = static_load_index(instruction) {
494            let domain = SlotDomain::Static(slot);
495            let Some(store_index) = find_slot_store_before(instructions, source_index, domain)
496            else {
497                return Some(domain);
498            };
499            let source = previous_non_nop_index(instructions, store_index.checked_sub(1)?)?;
500            let source_instruction = instructions.get(source)?;
501            if source_instruction.opcode == OpCode::Dup {
502                source_index = previous_non_nop_index(instructions, source.checked_sub(1)?)?;
503                continue;
504            }
505            if local_load_index(source_instruction).is_some()
506                || static_load_index(source_instruction).is_some()
507            {
508                source_index = source;
509                continue;
510            }
511            return Some(domain);
512        }
513        return None;
514    }
515}
516
517fn trace_stack_value_producer_before(
518    instructions: &[Instruction],
519    before_index: usize,
520    mut depth: usize,
521) -> Option<usize> {
522    for index in (0..before_index).rev() {
523        let instruction = instructions.get(index)?;
524        let (pops, pushes) = stack_effect(instruction)?;
525        if depth < pushes {
526            return Some(index);
527        }
528        depth = depth.checked_add(pops)?.checked_sub(pushes)?;
529    }
530    None
531}
532
533fn stack_effect(instruction: &Instruction) -> Option<(usize, usize)> {
534    use OpCode::*;
535    let opcode = instruction.opcode;
536    match opcode {
537        Nop => Some((0, 0)),
538        PushA | PushNull | PushT | PushF | PushM1 | Push0 | Push1 | Push2 | Push3 | Push4
539        | Push5 | Push6 | Push7 | Push8 | Push9 | Push10 | Push11 | Push12 | Push13 | Push14
540        | Push15 | Push16 | Pushint8 | Pushint16 | Pushint32 | Pushint64 | Pushint128
541        | Pushint256 | Pushdata1 | Pushdata2 | Pushdata4 | Newarray0 | Newmap | Newstruct0
542        | Ldloc0 | Ldloc1 | Ldloc2 | Ldloc3 | Ldloc4 | Ldloc5 | Ldloc6 | Ldloc | Ldarg0
543        | Ldarg1 | Ldarg2 | Ldarg3 | Ldarg4 | Ldarg5 | Ldarg6 | Ldarg | Ldsfld0 | Ldsfld1
544        | Ldsfld2 | Ldsfld3 | Ldsfld4 | Ldsfld5 | Ldsfld6 | Ldsfld => Some((0, 1)),
545        Stloc0 | Stloc1 | Stloc2 | Stloc3 | Stloc4 | Stloc5 | Stloc6 | Stloc | Starg0 | Starg1
546        | Starg2 | Starg3 | Starg4 | Starg5 | Starg6 | Starg | Stsfld0 | Stsfld1 | Stsfld2
547        | Stsfld3 | Stsfld4 | Stsfld5 | Stsfld6 | Stsfld => Some((1, 0)),
548        Append => Some((2, 0)),
549        Pickitem => Some((2, 1)),
550        Dup => Some((1, 2)),
551        _ => None,
552    }
553}
554
555fn find_resolution_start_index(instructions: &[Instruction], before_index: usize) -> usize {
556    for index in (0..before_index).rev() {
557        if let Some(instruction) = instructions.get(index) {
558            if is_pointer_resolution_boundary(instruction.opcode) {
559                return index + 1;
560            }
561        }
562    }
563    0
564}
565
566fn find_slot_store_before(
567    instructions: &[Instruction],
568    before_index: usize,
569    domain: SlotDomain,
570) -> Option<usize> {
571    for index in (0..before_index).rev() {
572        let instruction = instructions.get(index)?;
573        if matches!(domain, SlotDomain::Local(_))
574            && is_pointer_resolution_boundary(instruction.opcode)
575        {
576            return None;
577        }
578        if slot_store_domain(instruction) == Some(domain) {
579            return Some(index);
580        }
581    }
582    None
583}
584
585fn is_pointer_resolution_boundary(opcode: OpCode) -> bool {
586    matches!(
587        opcode,
588        OpCode::Ret
589            | OpCode::Throw
590            | OpCode::Abort
591            | OpCode::Abortmsg
592            | OpCode::Initslot
593            | OpCode::Initsslot
594    )
595}
596
597fn previous_non_nop_index(instructions: &[Instruction], mut index: usize) -> Option<usize> {
598    loop {
599        let instruction = instructions.get(index)?;
600        if instruction.opcode != OpCode::Nop {
601            return Some(index);
602        }
603        index = index.checked_sub(1)?;
604    }
605}
606
607/// Extract the argument count from an INITSLOT instruction at the given method offset.
608pub(super) fn initslot_arg_count_at(
609    instructions: &[Instruction],
610    method_offset: usize,
611) -> Option<usize> {
612    instructions
613        .iter()
614        .find(|i| i.offset == method_offset && i.opcode == OpCode::Initslot)
615        .and_then(|i| match &i.operand {
616            Some(Operand::Bytes(bytes)) if bytes.len() >= 2 => Some(bytes[1] as usize),
617            _ => None,
618        })
619}
620
621#[derive(Clone, Copy, Debug, PartialEq, Eq)]
622pub(super) enum CallArgSource {
623    Target(usize),
624    PassThrough(u8),
625}
626
627/// Trace backwards from a CALL instruction to find the source of the
628/// `arg_index`-th argument (0-indexed).
629///
630/// Neo VM pops arguments top-first: the top of stack becomes arg0, the next
631/// item becomes arg1, etc. So `arg0` is the last item pushed (0 items to skip)
632/// and `arg N` requires skipping N single-push instructions.
633pub(super) fn trace_call_arg_source(
634    instructions: &[Instruction],
635    call_index: usize,
636    arg_index: u8,
637    callee_arg_count: usize,
638) -> Option<CallArgSource> {
639    if (arg_index as usize) >= callee_arg_count {
640        return None;
641    }
642    let call_instruction = instructions.get(call_index)?;
643    let skip_count = if call_instruction.opcode == OpCode::CallA {
644        arg_index as usize + 1
645    } else {
646        arg_index as usize
647    };
648
649    let mut cursor = call_index.checked_sub(1)?;
650    let mut remaining = skip_count;
651
652    loop {
653        let instr = instructions.get(cursor)?;
654        if instr.opcode == OpCode::Nop {
655            cursor = cursor.checked_sub(1)?;
656            continue;
657        }
658
659        if remaining == 0 {
660            if instr.opcode == OpCode::PushA {
661                return pusha_absolute_target(instr).map(CallArgSource::Target);
662            }
663            if let Some(slot) = local_load_index(instr) {
664                return resolve_slot_pointer_target(instructions, cursor, SlotDomain::Local(slot))
665                    .map(CallArgSource::Target)
666                    .or_else(|| {
667                        trace_argument_index_from_value_source(instructions, cursor)
668                            .map(CallArgSource::PassThrough)
669                    });
670            }
671            if let Some(slot) = static_load_index(instr) {
672                return resolve_slot_pointer_target(instructions, cursor, SlotDomain::Static(slot))
673                    .map(CallArgSource::Target)
674                    .or_else(|| {
675                        trace_argument_index_from_value_source(instructions, cursor)
676                            .map(CallArgSource::PassThrough)
677                    });
678            }
679            return arg_load_index(instr).map(CallArgSource::PassThrough);
680        }
681
682        remaining -= 1;
683        cursor = cursor.checked_sub(1)?;
684    }
685}
686
687/// Second pass over call edges: resolve CALLA targets that load their function
688/// pointer from an argument slot (LDARG N) by tracing back through callers.
689fn resolve_ldarg_calla_targets(
690    instructions: &[Instruction],
691    edges: &mut [CallEdge],
692    table: &MethodTable,
693    methods: &mut BTreeMap<usize, MethodRef>,
694) {
695    // Build offset → instruction-index map.
696    let offset_to_index: BTreeMap<usize, usize> = instructions
697        .iter()
698        .enumerate()
699        .map(|(i, instr)| (instr.offset, i))
700        .collect();
701
702    // Collect unresolved CALLA sites preceded by LDARG.
703    // NOTE: edge.caller.offset may be inaccurate for internal helpers discovered
704    // during the first pass (the MethodTable was built before those methods were
705    // found).  Use the `methods` map — which now contains all first-pass
706    // discoveries — to find the true containing method for each CALLA.
707    let mut sites: Vec<(usize, u8, usize)> = Vec::new(); // (edge_index, arg_index, method_offset)
708    for (edge_idx, edge) in edges.iter().enumerate() {
709        if edge.opcode != "CALLA" || !matches!(edge.target, CallTarget::Indirect { .. }) {
710            continue;
711        }
712        let Some(&calla_idx) = offset_to_index.get(&edge.call_offset) else {
713            continue;
714        };
715        if let Some(arg_idx) = calla_ldarg_index(instructions, calla_idx) {
716            // Find the actual method containing this CALLA by looking up the
717            // largest method offset <= the CALLA offset in the methods map.
718            let actual_method_offset = methods
719                .range(..=edge.call_offset)
720                .next_back()
721                .map(|(&offset, _)| offset)
722                .unwrap_or(edge.caller.offset);
723            sites.push((edge_idx, arg_idx, actual_method_offset));
724        }
725    }
726
727    if sites.is_empty() {
728        return;
729    }
730
731    loop {
732        let mut callers_by_target: BTreeMap<usize, Vec<usize>> = BTreeMap::new();
733        for edge in edges.iter() {
734            if let CallTarget::Internal { method } = &edge.target {
735                if edge.opcode == "CALL" || edge.opcode == "CALL_L" || edge.opcode == "CALLA" {
736                    callers_by_target
737                        .entry(method.offset)
738                        .or_default()
739                        .push(edge.call_offset);
740                }
741            }
742        }
743
744        let mut progress = false;
745        for (edge_idx, arg_idx, method_offset) in &sites {
746            if !matches!(edges[*edge_idx].target, CallTarget::Indirect { .. }) {
747                continue;
748            }
749
750            let mut visited = BTreeSet::new();
751            let resolved = resolve_argument_target_recursive(
752                instructions,
753                &offset_to_index,
754                &callers_by_target,
755                methods,
756                *method_offset,
757                *arg_idx,
758                &mut visited,
759            );
760
761            if let Some(target) = resolved {
762                let callee = table.resolve_internal_target(target);
763                methods.insert(callee.offset, callee.clone());
764                edges[*edge_idx].target = CallTarget::Internal { method: callee };
765                progress = true;
766            }
767        }
768
769        if !progress {
770            break;
771        }
772    }
773}
774
775fn resolve_argument_target_recursive(
776    instructions: &[Instruction],
777    offset_to_index: &BTreeMap<usize, usize>,
778    callers_by_target: &BTreeMap<usize, Vec<usize>>,
779    methods: &BTreeMap<usize, MethodRef>,
780    method_offset: usize,
781    arg_index: u8,
782    visited: &mut BTreeSet<(usize, u8)>,
783) -> Option<usize> {
784    if !visited.insert((method_offset, arg_index)) {
785        return None;
786    }
787
788    let call_sites = callers_by_target.get(&method_offset)?;
789    let callee_arg_count =
790        initslot_arg_count_at(instructions, method_offset).unwrap_or(arg_index as usize + 1);
791
792    for &call_offset in call_sites {
793        let &call_idx = offset_to_index.get(&call_offset)?;
794        match trace_call_arg_source(instructions, call_idx, arg_index, callee_arg_count) {
795            Some(CallArgSource::Target(target)) => return Some(target),
796            Some(CallArgSource::PassThrough(next_arg)) => {
797                let caller_method_offset = methods
798                    .range(..=call_offset)
799                    .next_back()
800                    .map(|(&offset, _)| offset)
801                    .unwrap_or(call_offset);
802                if let Some(target) = resolve_argument_target_recursive(
803                    instructions,
804                    offset_to_index,
805                    callers_by_target,
806                    methods,
807                    caller_method_offset,
808                    next_arg,
809                    visited,
810                ) {
811                    return Some(target);
812                }
813            }
814            None => {}
815        }
816    }
817
818    None
819}