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