Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,323 changes: 852 additions & 471 deletions Cargo.lock

Large diffs are not rendered by default.

5 changes: 5 additions & 0 deletions objdiff-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,22 @@ publish = false
[dependencies]
anyhow = "1.0"
argp = "0.4"
axum = "0.8.9"
crossterm = "0.29"
enable-ansi-support = "0.3"
futures-util = "0.3"
memmap2 = "0.9"
objdiff-core = { path = "../objdiff-core", features = ["all"] }
prost = "0.14"
ratatui = "0.30"
rayon = "1.11"
rmcp = { version = "2.1.0", features = ["server", "transport-io", "transport-streamable-http-server"] }
schemars = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
supports-color = "3.0"
time = { version = "0.3", features = ["formatting", "local-offset"] }
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-std", "signal", "sync"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
typed-path = "0.12"
Expand Down
100 changes: 100 additions & 0 deletions objdiff-cli/src/cmd/mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# objdiff-cli mcp

A [Model Context Protocol](https://modelcontextprotocol.io) server that exposes
[objdiff](https://github.com/encounter/objdiff)'s diffing engine so a model can
drive decompilation matching without any UI.

It's designed to close the loop with an IDA bridge: read the reference in IDA →
write/adjust C/C++ → compile to an object → **`diff_function`** against the
baseline → read the per-instruction diff → repeat until 100%.

```
IDA (reference) decomp project objdiff-cli mcp (this)
ida-bridge MCP → edit C++ → build → diff_function → match% + instr diff
▲ │
└────────────── agent iterates ◀────────────┘
```

## Build

```bash
cargo build --release -p objdiff-cli
# binary: target/release/objdiff-cli
```

Built on `objdiff-core` (all architectures: ARM, ARM64, MIPS, PPC, SuperH,
x86/x86_64) with COFF + ELF support.

## Run

The server is persistent — it runs until closed and holds project/config state
across calls.

**Persistent HTTP instance** (recommended; e.g. on the Windows build VM next to
the compiled objects, reached from the agent over the network):

```bash
objdiff-cli mcp --transport http --bind 0.0.0.0:3001 [--project C:\path\to\project]
# MCP endpoint: http://<host>:3001/mcp
```

**stdio** (spawned by the client via `.mcp.json`):

```bash
objdiff-cli mcp # --transport stdio is the default
```

Logs go to stderr; stdout is reserved for the protocol on stdio.

## Tools

| Tool | Purpose |
|---|---|
| `open_project` | Load an `objdiff.json` so later calls refer to **units** by name instead of file paths. |
| `list_units` | List the project's units with their resolved target/base object paths (optional name filter). |
| `build` | Run the project's build command for a unit's base (or target) object; returns command line, exit status, and compiler output. |
| `diff_function` | Diff one function between the target (expected/baseline) and base (current/your build). Returns the match percent and a **side-by-side, per-instruction diff** with mismatch markers. The primary matching tool. |
| `diff_overview` | List every function in the object pair with its match percent, worst first. Use to pick what to work on. |
| `set_config` | Set a persistent objdiff config option (e.g. `x86.formatter`, `spaceBetweenArgs`, `demangler`) applied to subsequent diffs. |
| `version` | Report the server version. |

`diff_function` / `diff_overview` take **either** a project `unit` **or** explicit
`target`+`base` object-file paths, plus an optional per-call `config` map of
objdiff config overrides. Mismatch marker legend:
`~` replace · `o` opcode-mismatch · `a` arg-mismatch · `+` insert · `-` delete.

## The matching loop

1. `open_project` once (or `--project` at startup).
2. Understand the target function in IDA (via the IDA bridge).
3. Edit the C/C++ for the unit.
4. `build` the unit.
5. `diff_function(unit, symbol)` — read the match % and the side-by-side diff.
6. Adjust based on the mismatching instructions; cross-check offsets/targets in
IDA. Go to 3. Repeat until 100%.

Use `diff_overview(unit, only_mismatches=true)` to triage which functions to
attack first.

## Connecting the agent

**HTTP (shared instance):** point your MCP client at `http://<host>:3001/mcp`.

**stdio (`.mcp.json`):**

```json
{
"mcpServers": {
"objdiff": { "command": "/path/to/objdiff-cli", "args": ["mcp"] }
}
}
```

## Note on the baseline

objdiff compares two objects: the **target** (the original/expected function's
machine code, the "baseline") and the **base** (your current build). Producing
the baseline object — extracting the original function's bytes into a COFF/ELF
object with name/xref-derived relocations — is a build/extraction step outside
objdiff (best done IDA-side, where the names and xrefs live). Point `target_path`
in `objdiff.json` at that file.
160 changes: 160 additions & 0 deletions objdiff-cli/src/cmd/mcp/diff.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
//! Core diffing helpers built directly on `objdiff-core`.
//!
//! These are UI-free and synchronous; the MCP server layer wraps them in
//! `spawn_blocking` and turns the results into tool responses.

use std::{collections::BTreeMap, fmt::Write as _, path::Path};

use anyhow::{Context, Result};
use objdiff_core::{
bindings::diff::{DiffKind, DiffObject, DiffResult, DiffSymbol, DiffSymbolKind},
diff::{DiffObjConfig, DiffSide, MappingConfig, diff_objs},
obj::read,
};

/// Load target + base objects from disk and produce a full serializable diff.
///
/// `target` is the expected/baseline object (left); `base` is your current
/// build (right).
pub fn run_diff(
target: &Path,
base: &Path,
config: &DiffObjConfig,
mappings: &BTreeMap<String, String>,
) -> Result<DiffResult> {
let target_obj = read::read(target, config, DiffSide::Target)
.with_context(|| format!("Failed to read target object {}", target.display()))?;
let base_obj = read::read(base, config, DiffSide::Base)
.with_context(|| format!("Failed to read base object {}", base.display()))?;
let mapping_config =
MappingConfig { mappings: mappings.clone(), selecting_left: None, selecting_right: None };
let result = diff_objs(Some(&target_obj), Some(&base_obj), None, config, &mapping_config)
.context("Failed to diff objects")?;
DiffResult::new(
result.left.as_ref().map(|d| (&target_obj, d)),
result.right.as_ref().map(|d| (&base_obj, d)),
config,
)
.context("Failed to build diff result")
}

fn kind_marker(kind: DiffKind) -> &'static str {
match kind {
DiffKind::DiffNone => " ",
DiffKind::DiffReplace => "~",
DiffKind::DiffDelete => "-",
DiffKind::DiffInsert => "+",
DiffKind::DiffOpMismatch => "o",
DiffKind::DiffArgMismatch => "a",
}
}

fn is_function(sym: &DiffSymbol) -> bool {
DiffSymbolKind::try_from(sym.kind).unwrap_or(DiffSymbolKind::SymbolUnknown)
== DiffSymbolKind::SymbolFunction
}

/// A compact, token-efficient overview of every code symbol and its match %.
pub fn overview(diff: &DiffResult, min_only_mismatches: bool, limit: usize) -> String {
let Some(right) = diff.right.as_ref() else {
return "No base object in diff result.".to_string();
};
let mut rows: Vec<(&str, f32, u64)> = right
.symbols
.iter()
.filter(|s| is_function(s))
.map(|s| (s.name.as_str(), s.match_percent.unwrap_or(0.0), s.size))
.collect();
// Worst matches first — that's what you want to work on.
rows.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
if min_only_mismatches {
rows.retain(|r| r.1 < 100.0);
}

let mut out = String::new();
let total = rows.len();
let _ = writeln!(out, "{total} function(s) (worst match first):");
for (name, pct, size) in rows.into_iter().take(limit) {
let _ = writeln!(out, " {pct:6.2}% {size:>6} {name}");
}
out
}

fn instr_text(sym: Option<&DiffSymbol>, i: usize) -> (&str, Option<u64>) {
match sym.and_then(|s| s.instructions.get(i)) {
Some(row) => match row.instruction.as_ref() {
Some(ins) => (ins.formatted.as_str(), Some(ins.address)),
None => ("", None),
},
None => ("", None),
}
}

/// Render a single function's diff as a side-by-side (target vs current) table
/// with per-row mismatch markers — the primary signal for iterative matching.
pub fn function_diff(diff: &DiffResult, symbol: &str) -> Result<String> {
let right = diff.right.as_ref().context("No base object in diff result")?;
let left = diff.left.as_ref();

// Look the symbol up by its base (current) name first; fall back to the
// target-side name and follow its pairing back to the base symbol, so
// mapped symbols (e.g. statics renamed between objects) resolve either way.
let base_sym = right
.symbols
.iter()
.find(|s| s.name == symbol)
.or_else(|| {
left.and_then(|l| l.symbols.iter().find(|s| s.name == symbol))
.and_then(|ls| ls.target_symbol)
.and_then(|bi| right.symbols.get(bi as usize))
})
.with_context(|| {
format!("Symbol `{symbol}` not found in base (current) or target object")
})?;

let target_sym: Option<&DiffSymbol> = base_sym
.target_symbol
.and_then(|ti| left.and_then(|l: &DiffObject| l.symbols.get(ti as usize)));

let pct = base_sym.match_percent.unwrap_or(0.0);
let mut out = String::new();
let _ = writeln!(out, "symbol : {symbol}");
if let Some(dm) = &base_sym.demangled_name {
let _ = writeln!(out, "demangled: {dm}");
}
let _ = writeln!(out, "match : {pct:.2}%");
match target_sym {
Some(_) => {}
None => {
let _ = writeln!(
out,
"note : no matched symbol in target object (unmatched — nothing to compare against)"
);
}
}
let _ = writeln!(
out,
"legend : ' '=equal ~=replace o=opcode-mismatch a=arg-mismatch +=insert -=delete"
);
let _ = writeln!(out);
let _ = writeln!(
out,
" {:<8} {:1} {:<38} {:<38}",
"addr", "", "target (expected)", "current (yours)"
);

let n = base_sym.instructions.len().max(target_sym.map(|s| s.instructions.len()).unwrap_or(0));
for i in 0..n {
let (rtext, raddr) = instr_text(Some(base_sym), i);
let (ltext, laddr) = instr_text(target_sym, i);
let kind = base_sym
.instructions
.get(i)
.map(|r| DiffKind::try_from(r.diff_kind).unwrap_or(DiffKind::DiffNone))
.unwrap_or(DiffKind::DiffInsert);
let addr = raddr.or(laddr).unwrap_or(0);
let _ =
writeln!(out, " {:08x} {:1} {:<38} {:<38}", addr, kind_marker(kind), ltext, rtext);
}
Ok(out)
}
Loading
Loading