Skip to main content

callgraph_verify/
skel.rs

1//! Byte-derived **reference skeleton** emitter: turns a function's machine code into a
2//! Rust-flavored structural outline — labeled basic blocks, ordered calls with their recovered
3//! arguments, struct-field accesses (`rd`/`wr`), globals (`g`/`s`), and the branch / loop / switch
4//! structure. It is NOT compilable Rust; it is a faithful *hint* for porting, generated from the
5//! same byte-truth the gates verify against. Use it next to the naked asm and the Ghidra C: the
6//! asm is ground truth, Ghidra C gives high-level naming, and this gives the byte-accurate call
7//! order + arguments + offsets + control flow in the porter's target idiom.
8
9use crate::disasm::{follow_jump_table, recover_call_site, ArgKind};
10use crate::pe::Pe;
11use iced_x86::{
12    Decoder, DecoderOptions, FlowControl, Formatter, Instruction, Mnemonic, NasmFormatter, OpKind,
13    Register,
14};
15use std::collections::{BTreeMap, BTreeSet};
16use std::fmt::Write;
17use std::sync::{Mutex, OnceLock};
18
19const MAX_SPAN: u64 = 0x10000;
20
21/// Decode the whole function, returning every instruction by address plus the set of block
22/// leaders (entry, every in-range branch/switch target, and the fallthrough after a conditional).
23fn decode_all(pe: &Pe, entry: u64) -> (BTreeMap<u64, Instruction>, BTreeSet<u64>) {
24    let (lo, hi) = (entry, entry.saturating_add(MAX_SPAN));
25    let mut instrs: BTreeMap<u64, Instruction> = BTreeMap::new();
26    let mut leaders: BTreeSet<u64> = BTreeSet::new();
27    leaders.insert(entry);
28    let mut work = vec![entry];
29    let mut seen: BTreeSet<u64> = BTreeSet::new();
30
31    while let Some(start) = work.pop() {
32        if !seen.insert(start) {
33            continue;
34        }
35        let Some(bytes) = pe.read(start, 32 * 64) else {
36            continue;
37        };
38        let mut dec = Decoder::with_ip(32, bytes, start, DecoderOptions::NONE);
39        let mut instr = Instruction::default();
40        while dec.can_decode() {
41            let ip = dec.ip();
42            if ip != start && instrs.contains_key(&ip) {
43                break;
44            }
45            dec.decode_out(&mut instr);
46            if instr.is_invalid() {
47                break;
48            }
49            instrs.insert(ip, instr);
50            match instr.flow_control() {
51                FlowControl::ConditionalBranch => {
52                    let t = instr.near_branch_target();
53                    let n = instr.next_ip();
54                    if (lo..hi).contains(&t) {
55                        leaders.insert(t);
56                        work.push(t);
57                    }
58                    leaders.insert(n);
59                    work.push(n);
60                    break;
61                }
62                FlowControl::UnconditionalBranch => {
63                    let t = instr.near_branch_target();
64                    // Only follow JMPs whose target is BEFORE the JMP
65                    // itself. A backward JMP within a structured function
66                    // is a real back-edge (loop tail jumping to loop head);
67                    // its target must be a leader so the dominance-based
68                    // back-edge check at cfg_fingerprint:303-307 has a
69                    // chance to count it (`dom[source].contains(target)`
70                    // requires `target` to be in `starts`). A FORWARD JMP
71                    // is either a fall-through to the next leader (already
72                    // handled by `build_cfg`'s `leaders.contains(&addrs[idx])`
73                    // check at line 203) or — the bug case — a tail-call
74                    // into another function. Real-world instance: thunk
75                    // `0x579210: jmp 0x5821f0` in a real PE, where
76                    // 0x5821f0 is 36KB past the entry and the OLD
77                    // `(lo..hi).contains(&t)` check followed it and
78                    // counted the callee's loops, switches, and
79                    // conditionals against the caller's CFG fingerprint
80                    // (`fp.loops = 11` for a 1-instruction thunk). A
81                    // forward JMP that the inner decoder hasn't already
82                    // advanced past via fall-through is a tail-call, not
83                    // intra-function control flow, and must NOT be
84                    // decoded into this function's CFG.
85                    //
86                    // `cfg_fingerprint` itself counts the JMP as a
87                    // back-edge iff `t <= b.start && dom[b].contains(t)`
88                    // and as a return-equivalent otherwise, so a thunk
89                    // (forward JMP, target not in dom) is correctly
90                    // classified as a return-equivalent terminator.
91                    if t < ip {
92                        leaders.insert(t);
93                        work.push(t);
94                    }
95                    break;
96                }
97                FlowControl::IndirectBranch => {
98                    let mut cases = Vec::new();
99                    // Keep the SIB-only table read here: this is the CFG
100                    // leader walk, and the disasm.rs fallthrough recovery
101                    // (for index-less Watcom dispatches) is deliberately
102                    // scoped to call harvesting so CFG fingerprints stay
103                    // byte-stable (see is_switch_dispatch there).
104                    follow_jump_table(&instr, pe, lo, hi, false, true, &mut cases);
105                    for c in cases {
106                        leaders.insert(c);
107                        work.push(c);
108                    }
109                    break;
110                }
111                FlowControl::Return | FlowControl::Exception => break,
112                // `int n` falls through (DOS/DPMI API call that returns;
113                // see disasm.rs) — it ends neither a leader walk nor a block.
114                _ => {}
115            }
116        }
117    }
118    (instrs, leaders)
119}
120
121/// A coarse, optimization-tolerant control-flow signature of a function, from its CFG.
122#[derive(Debug, Default, Clone, Copy)]
123pub struct CfgFingerprint {
124    /// Natural-loop back-edges: a branch from block B to block T where T
125    /// dominates B (every path from entry to B passes through T). The
126    /// dominance check is what stops a function that has several
127    /// `if (cond) goto epilog;` exits to a common tail from being reported
128    /// as having multiple "loops" — those are forward jumps in source layout
129    /// that look like back-edges once the compiler lays the tail at a lower
130    /// address, but the tail doesn't dominate the `goto` sites, so they
131    /// aren't iteration.
132    pub loops: usize,
133    /// Total case count across jump-table switches.
134    pub switch_cases: usize,
135    /// Conditional-branch terminators.
136    pub conditionals: usize,
137    /// Return / interrupt / tail-call terminators.
138    pub returns: usize,
139}
140
141/// One basic block in the local CFG of a function: a leader plus all
142/// instructions until the next leader or terminator, plus successors that
143/// stay in-range. Used by both [`cfg_fingerprint`] (for the natural-loop
144/// count) and [`emit_skeleton`] (for the labeled-block outline).
145struct Block {
146    start: u64,
147    body: Vec<Instruction>,
148    term: Option<Instruction>,
149    /// In-range successor block starts. Out-of-range JMPs (tail calls) and
150    /// the fall-through into the next leader are included so dominance can
151    /// see the whole local flow graph.
152    succ: Vec<u64>,
153}
154
155/// Build the local CFG of the function at `entry`: ordered blocks with
156/// successors, restricted to VAs in `[entry, entry + MAX_SPAN)`.
157fn build_cfg(pe: &Pe, entry: u64) -> (BTreeMap<u64, Instruction>, Vec<Block>) {
158    let (_, _) = (entry, entry.saturating_add(MAX_SPAN));
159    let (instrs, leaders) = decode_all(pe, entry);
160    let addrs: Vec<u64> = instrs.keys().copied().collect();
161    let lo = entry;
162    let hi = entry.saturating_add(MAX_SPAN);
163    let mut blocks: Vec<Block> = Vec::new();
164    for &ld in &leaders {
165        let mut idx = match addrs.binary_search(&ld) {
166            Ok(i) => i,
167            Err(_) => continue,
168        };
169        let mut body = Vec::new();
170        let mut term = None;
171        let mut succ = Vec::new();
172        loop {
173            let a = addrs[idx];
174            let instr = instrs[&a];
175            // IndirectCall is NOT a terminator: the callee returns and
176            // execution continues at the next IP, so the call is
177            // "synchronous" from a CFG perspective.
178            let is_term = !matches!(
179                instr.flow_control(),
180                FlowControl::Next
181                    | FlowControl::Call
182                    | FlowControl::IndirectCall
183                    | FlowControl::XbeginXabortXend
184            );
185            if is_term {
186                match instr.flow_control() {
187                    FlowControl::ConditionalBranch => {
188                        let t = instr.near_branch_target();
189                        if (lo..hi).contains(&t) {
190                            succ.push(t);
191                        }
192                        let n = instr.next_ip();
193                        if (lo..hi).contains(&n) {
194                            succ.push(n);
195                        }
196                    }
197                    FlowControl::UnconditionalBranch => {
198                        let t = instr.near_branch_target();
199                        if (lo..hi).contains(&t) {
200                            succ.push(t);
201                        }
202                    }
203                    FlowControl::IndirectBranch => {
204                        // Switch / jump-table dispatch: every in-range
205                        // case is a successor of this block. The
206                        // dominator set can then see the indirect
207                        // dispatch as a forward fan-out (no back-edge
208                        // through a switch). SIB-only read (see above).
209                        let mut cases = Vec::new();
210                        follow_jump_table(&instr, pe, lo, hi, false, true, &mut cases);
211                        for c in cases {
212                            if (lo..hi).contains(&c) {
213                                succ.push(c);
214                            }
215                        }
216                    }
217                    _ => {}
218                }
219                term = Some(instr);
220                break;
221            }
222            body.push(instr);
223            idx += 1;
224            if idx >= addrs.len() {
225                break;
226            }
227            if leaders.contains(&addrs[idx]) {
228                succ.push(addrs[idx]);
229                break;
230            }
231        }
232        blocks.push(Block {
233            start: ld,
234            body,
235            term,
236            succ,
237        });
238    }
239    blocks.sort_by_key(|b| b.start);
240    (instrs, blocks)
241}
242
243/// Iterative dominator set computation on the local CFG: for each block `n`,
244/// `dom[n]` is the set of blocks that appear on EVERY path from `entry` to
245/// `n` (including `n` itself). Standard textbook algorithm — small enough
246/// for the size of CFG we deal with here (max ~64KB). Blocks unreachable
247/// from `entry` are kept in the map with an empty set so callers can detect
248/// them, but in practice `decode_all` only follows reachable leaders so
249/// unreachable blocks shouldn't appear.
250fn dominators(entry: u64, blocks: &[Block]) -> BTreeMap<u64, BTreeSet<u64>> {
251    let starts: BTreeSet<u64> = blocks.iter().map(|b| b.start).collect();
252    let mut preds: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
253    for b in blocks {
254        for &s in &b.succ {
255            if starts.contains(&s) {
256                preds.entry(s).or_default().insert(b.start);
257            }
258        }
259    }
260    let mut dom: BTreeMap<u64, BTreeSet<u64>> = BTreeMap::new();
261    if !starts.contains(&entry) {
262        return dom;
263    }
264    let mut entry_set: BTreeSet<u64> = BTreeSet::new();
265    entry_set.insert(entry);
266    dom.insert(entry, entry_set.clone());
267    for &s in &starts {
268        if s != entry {
269            dom.insert(s, starts.clone());
270        }
271    }
272    let order: Vec<u64> = blocks.iter().map(|b| b.start).collect();
273    loop {
274        let mut changed = false;
275        for &n in &order {
276            if n == entry {
277                continue;
278            }
279            let empty: BTreeSet<u64> = BTreeSet::new();
280            let ps = preds.get(&n).unwrap_or(&empty);
281            if ps.is_empty() {
282                continue;
283            }
284            let first = ps.iter().next().unwrap();
285            let mut new_dom = dom.get(first).cloned().unwrap_or_else(|| starts.clone());
286            for &p in ps {
287                let pd = dom.get(&p).cloned().unwrap_or_else(|| starts.clone());
288                new_dom = new_dom.intersection(&pd).copied().collect();
289            }
290            new_dom.insert(n);
291            if dom.get(&n) != Some(&new_dom) {
292                dom.insert(n, new_dom);
293                changed = true;
294            }
295        }
296        if !changed {
297            break;
298        }
299    }
300    dom
301}
302
303/// Compute the coarse CFG fingerprint of the function at `entry`. Counts what is reasonably stable
304/// across optimization (loops, jump-table switches) plus raw conditional/return counts; the caller
305/// should compare these tolerantly (optimized CFGs do not map 1:1 to source).
306pub fn cfg_fingerprint(pe: &Pe, entry: u64) -> Result<CfgFingerprint, String> {
307    type Key = (u64, u64);
308    static CACHE: OnceLock<Mutex<BTreeMap<Key, Result<CfgFingerprint, String>>>> = OnceLock::new();
309    let cache = CACHE.get_or_init(|| Mutex::new(BTreeMap::new()));
310    let key = (pe.id(), entry);
311    if let Some(result) = cache
312        .lock()
313        .unwrap_or_else(|e| e.into_inner())
314        .get(&key)
315        .cloned()
316    {
317        return result;
318    }
319
320    let result = cfg_fingerprint_uncached(pe, entry);
321    let mut entries = cache.lock().unwrap_or_else(|e| e.into_inner());
322    if entries.len() >= 8192 {
323        entries.clear();
324    }
325    entries.insert(key, result.clone());
326    result
327}
328
329fn cfg_fingerprint_uncached(pe: &Pe, entry: u64) -> Result<CfgFingerprint, String> {
330    if !pe.is_executable_va(entry) {
331        return Err(format!(
332            "{entry:#010x} is not inside any PE section\n{}",
333            pe.describe_sections(entry)
334        ));
335    }
336    let (lo, hi) = (entry, entry.saturating_add(MAX_SPAN));
337    let (instrs, blocks) = build_cfg(pe, entry);
338    let dom = dominators(entry, &blocks);
339    let mut fp = CfgFingerprint::default();
340    for b in &blocks {
341        let Some(instr) = b.term else {
342            continue;
343        };
344        match instr.flow_control() {
345            FlowControl::ConditionalBranch => {
346                fp.conditionals += 1;
347                let t = instr.near_branch_target();
348                // Natural-loop back-edge: `t -> b.start` is a back-edge iff
349                // `t` dominates `b.start` — every path from `entry` to
350                // `b.start` passes through `t`. The textbook definition
351                // gives "target dominates source", which is exactly
352                // `dom[source].contains(target)`.
353                if t <= b.start && dom.get(&b.start).is_some_and(|d| d.contains(&t)) {
354                    fp.loops += 1;
355                }
356            }
357            FlowControl::UnconditionalBranch => {
358                let t = instr.near_branch_target();
359                if t <= b.start && dom.get(&b.start).is_some_and(|d| d.contains(&t)) {
360                    fp.loops += 1;
361                } else {
362                    // Out-of-function JMP — a tail call (the callee will RET
363                    // for us). Count it as a return-equivalent so functions
364                    // that consist entirely of tail calls (e.g. `return
365                    // foo();` after inlining) aren't reported as
366                    // terminator-less. The macro's CFG check keys off "any
367                    // terminator at all"; missing tail-call JMPs would let
368                    // it fire false positives for legitimate delegation.
369                    fp.returns += 1;
370                }
371            }
372            FlowControl::Return | FlowControl::Interrupt => fp.returns += 1,
373            FlowControl::IndirectBranch => {
374                let mut cases = Vec::new();
375                // SIB-only read (see above). On LE the table base and its
376                // entries are pre-fixup; follow_jump_table translates both
377                // through Pe::fixup_post_image, so LE tables resolve here
378                // too (0.1.8). A resolved switch then requires the body to
379                // model it (CFG gate: switch_cases>=3 needs a match/if-chain).
380                follow_jump_table(&instr, pe, lo, hi, false, true, &mut cases);
381                fp.switch_cases += cases.len();
382            }
383            _ => {}
384        }
385        let _ = &instrs;
386    }
387    Ok(fp)
388}
389
390/// Emit the reference skeleton for the function at `entry`.
391pub fn emit_skeleton(pe: &Pe, entry: u64) -> Result<String, String> {
392    if !pe.is_executable_va(entry) {
393        return Err(format!(
394            "{entry:#010x} is not inside any PE section\n{}",
395            pe.describe_sections(entry)
396        ));
397    }
398    let (instrs, blocks) = build_cfg(pe, entry);
399    let _ = &instrs;
400
401    // Loop heads = targets of a natural-loop back-edge (a branch from B to T
402    // where T strictly precedes B AND T dominates B). The dominance test is
403    // what excludes the false positives that the older `succ <= start` test
404    // used to flag: an `if (cond) goto epilog;` that the compiler laid out
405    // below its `if`s looks like a back-edge by address, but the epilog
406    // doesn't dominate the `if` site, so it isn't a loop.
407    let dom = dominators(entry, &blocks);
408    let mut loop_heads: BTreeSet<u64> = BTreeSet::new();
409    for b in &blocks {
410        for &s in &b.succ {
411            if s <= b.start && dom.get(&b.start).is_some_and(|d| d.contains(&s)) {
412                loop_heads.insert(s);
413            }
414        }
415    }
416    // A label is worth printing if more than one block targets it, it's a loop head, or the entry.
417    let mut target_count: BTreeMap<u64, u32> = BTreeMap::new();
418    for b in &blocks {
419        for &s in &b.succ {
420            *target_count.entry(s).or_insert(0) += 1;
421        }
422    }
423
424    let mut fmt = NasmFormatter::new();
425    let mut out = String::new();
426    let _ = writeln!(
427        out,
428        "// AUTO-GENERATED reference skeleton from bytes @ {entry:#010x} — NOT compilable; a faithful\n\
429         // structural hint (ordered calls + recovered args, field accesses, globals, control flow).\n\
430         unsafe fn sub_{entry:08x}(this: u32) {{"
431    );
432
433    let _ = &target_count; // (kept for potential label-pruning; every block is labeled for now)
434    for b in &blocks {
435        let tag = if loop_heads.contains(&b.start) {
436            "  // loop head"
437        } else {
438            ""
439        };
440        let _ = writeln!(out, "  'L{:08x}: {{{tag}", b.start);
441        let mut last_cmp: Option<Instruction> = None;
442        let mut arg_base = 0usize; // recovered call args scan only since the previous call
443        for (i, instr) in b.body.iter().enumerate() {
444            if matches!(instr.mnemonic(), Mnemonic::Cmp | Mnemonic::Test) {
445                last_cmp = Some(*instr);
446            }
447            if let Some(line) = render_instr(pe, instr, &b.body[arg_base..i]) {
448                let _ = writeln!(out, "    {line}");
449            }
450            if matches!(
451                instr.flow_control(),
452                FlowControl::Call | FlowControl::IndirectCall
453            ) {
454                arg_base = i + 1;
455            }
456        }
457        if let Some(t) = &b.term {
458            let _ = writeln!(
459                out,
460                "    {}",
461                render_terminator(t, last_cmp.as_ref(), &mut fmt)
462            );
463        }
464        let _ = writeln!(out, "  }}");
465    }
466    let _ = writeln!(out, "}}");
467    Ok(out)
468}
469
470/// Render the asm text of an instruction (for fallback comments / conditions).
471fn asm(fmt: &mut NasmFormatter, instr: &Instruction) -> String {
472    let mut s = String::new();
473    fmt.format(instr, &mut s);
474    s
475}
476
477fn reg(r: Register) -> String {
478    format!("{r:?}").to_lowercase()
479}
480
481/// Render an `ArgKind` as a pseudo-Rust expression.
482fn arg(a: &ArgKind) -> String {
483    match a {
484        ArgKind::Imm(v) if *v >= 0x1000 => format!("{v:#x}"),
485        ArgKind::Imm(v) => format!("{v}"),
486        ArgKind::Field(o) => format!("rd(_, {o:#x})"),
487        ArgKind::Global(a) => format!("g({a:#010x})"),
488        ArgKind::LocalAddr => "&local".to_string(),
489        ArgKind::Other => "_".to_string(),
490    }
491}
492
493/// Render a non-terminator instruction as a pseudo-Rust line, or `None` to omit it (a pure
494/// register shuffle that carries no portable structural fact).
495fn render_instr(pe: &Pe, instr: &Instruction, prior: &[Instruction]) -> Option<String> {
496    match instr.flow_control() {
497        FlowControl::Call => {
498            let t = instr.near_branch_target();
499            if t == 0 {
500                return Some("(*indirect)();              // indirect call".to_string());
501            }
502            let cs = recover_call_site(pe, prior, t);
503            let mut a: Vec<String> = Vec::new();
504            if let Some(x) = cs.ecx {
505                a.push(arg(&x));
506            }
507            if let Some(x) = cs.edx {
508                a.push(arg(&x));
509            }
510            a.extend(cs.stack.iter().map(arg));
511            return Some(format!("sub_{t:08x}({});", a.join(", ")));
512        }
513        FlowControl::IndirectCall => {
514            return Some("(*vtbl[?])();              // indirect/virtual call".to_string())
515        }
516        _ => {}
517    }
518    // Memory store / load and globals — the portable field/global facts. Stack-local and bare
519    // absolute accesses (prologue / locals) are omitted to keep the skeleton a skeleton.
520    if instr.mnemonic() == Mnemonic::Mov {
521        if instr.op0_kind() == OpKind::Memory {
522            let src = match instr.op1_kind() {
523                OpKind::Immediate8
524                | OpKind::Immediate8to16
525                | OpKind::Immediate8to32
526                | OpKind::Immediate16
527                | OpKind::Immediate32 => format!("{:#x}", instr.immediate(1)),
528                OpKind::Register => reg(instr.op1_register()),
529                _ => "_".to_string(),
530            };
531            return match mem_of(pe, instr) {
532                Mem::Field(b, d) => Some(format!("wr({b}, {d:#x}, {src});")),
533                Mem::Global(va) => Some(format!("s({va:#010x}, {src});")),
534                Mem::Other => None,
535            };
536        }
537        if instr.op1_kind() == OpKind::Memory && instr.op0_kind() == OpKind::Register {
538            let dst = reg(instr.op0_register());
539            return match mem_of(pe, instr) {
540                Mem::Field(b, d) => Some(format!("let {dst} = rd({b}, {d:#x});")),
541                Mem::Global(va) => Some(format!("let {dst} = g({va:#010x});")),
542                Mem::Other => None,
543            };
544        }
545    }
546    None
547}
548
549/// Classified memory operand: a struct field `[reg+disp]`, an in-image global `[abs]`, or other
550/// (stack local / unmapped absolute) which the skeleton omits.
551enum Mem {
552    Field(String, u64),
553    Global(u64),
554    Other,
555}
556fn mem_of(pe: &Pe, instr: &Instruction) -> Mem {
557    let base = instr.memory_base();
558    let disp = instr.memory_displacement64();
559    if base == Register::None && instr.memory_index() == Register::None {
560        if pe.section_of(disp).is_some() {
561            return Mem::Global(disp);
562        }
563        return Mem::Other;
564    }
565    if base == Register::ESP || base == Register::EBP || base == Register::None {
566        return Mem::Other;
567    }
568    Mem::Field(reg(base), disp)
569}
570
571/// Render a block terminator as pseudo-Rust control flow.
572fn render_terminator(
573    instr: &Instruction,
574    last_cmp: Option<&Instruction>,
575    fmt: &mut NasmFormatter,
576) -> String {
577    match instr.flow_control() {
578        FlowControl::Return | FlowControl::Interrupt => "return;".to_string(),
579        FlowControl::UnconditionalBranch => {
580            format!("break 'L{:08x};   // jmp", instr.near_branch_target())
581        }
582        FlowControl::IndirectBranch => {
583            format!("match _ {{ /* switch */ }}   // {}", asm(fmt, instr))
584        }
585        FlowControl::ConditionalBranch => {
586            let cond = render_cond(instr, last_cmp, fmt);
587            format!(
588                "if {cond} {{ break 'L{:08x}; }}   // {}",
589                instr.near_branch_target(),
590                asm(fmt, instr)
591            )
592        }
593        _ => format!("// {}", asm(fmt, instr)),
594    }
595}
596
597/// Best-effort condition for a `Jcc`, using the preceding `CMP`/`TEST` operands.
598fn render_cond(
599    jcc: &Instruction,
600    last_cmp: Option<&Instruction>,
601    fmt: &mut NasmFormatter,
602) -> String {
603    let relop = match jcc.mnemonic() {
604        Mnemonic::Je => "==",
605        Mnemonic::Jne => "!=",
606        Mnemonic::Jl | Mnemonic::Jb => "<",
607        Mnemonic::Jle | Mnemonic::Jbe => "<=",
608        Mnemonic::Jg | Mnemonic::Ja => ">",
609        Mnemonic::Jge | Mnemonic::Jae => ">=",
610        Mnemonic::Js => "< 0 //(sign)",
611        Mnemonic::Jns => ">= 0 //(sign)",
612        _ => "?",
613    };
614    match last_cmp {
615        Some(c) if c.mnemonic() == Mnemonic::Test => {
616            // TEST a,a ; Jz  → a == 0
617            format!("(cond {} {relop} 0)", asm_operands(c, fmt))
618        }
619        Some(c) => {
620            let ops = asm_operands(c, fmt);
621            // "a, b" → "a <relop> b"
622            if let Some((a, b)) = ops.split_once(", ") {
623                format!("{a} {relop} {b}")
624            } else {
625                format!("({ops}) {relop} 0")
626            }
627        }
628        None => format!("(flags) {relop} 0"),
629    }
630}
631
632/// Operand text of an instruction (drops the mnemonic).
633fn asm_operands(instr: &Instruction, fmt: &mut NasmFormatter) -> String {
634    let s = asm(fmt, instr);
635    s.split_once(' ').map(|x| x.1).unwrap_or(&s).to_string()
636}