Skip to content
Open
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
545 changes: 512 additions & 33 deletions Cargo.lock

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ members = [
"objdiff-cli",
"objdiff-core",
"objdiff-gui",
"objdiff-mcp",
"objdiff-wasm",
]
default-members = [
Expand Down
135 changes: 126 additions & 9 deletions objdiff-core/src/diff/code.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,53 @@ fn ins_data_literals_eq(
left_literals == right_literals
}

/// Strip the leading `?<ident>@?<scope>` of an MSVC function-local static's
/// decorated name, returning the stable `??<func>@…@4<type>@<access>` tail
/// (or `None` if `name` isn't such a local static).
///
/// MSVC decorates a function-local `static` as
/// `?<localname>@?<scope>@??<func>@<sig>@4<type>@<access>`. Neither the
/// `<localname>` (the source's variable name — e.g. a macro's `info` vs
/// `__info`) nor the `<scope>` disambiguator (a compiler-assigned index that
/// shifts with the surrounding function body) is stable across recompiles, but
/// the enclosing function, type and storage class uniquely identify the static.
/// The `<scope>` is an MSVC mangled number: a single digit `0`..`9`, or one or
/// more base-16 nibbles `A`(0)..`P`(15) terminated by `@`.
fn canonical_local_static(name: &str) -> Option<&str> {
// `?<ident>@`
let rest = name.strip_prefix('?')?;
let at = rest.find('@')?;
let ident = &rest[..at];
if ident.is_empty() || !ident.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'_') {
return None;
}
// `?<scope>`
let rest = rest[at + 1..].strip_prefix('?')?;
let tail = if rest.starts_with(|c: char| c.is_ascii_digit()) {
&rest[1..]
} else {
let end = rest.find('@')?;
let nibbles = &rest[..end];
if nibbles.is_empty() || !nibbles.bytes().all(|b| (b'A'..=b'P').contains(&b)) {
return None;
}
&rest[end + 1..]
};
// The remainder must open the enclosing nested name (`??<func>@…`).
tail.starts_with("??").then_some(tail)
}

/// True if both names are MSVC function-local statics of the same enclosing
/// function, type and storage class (ignoring their unstable local-name/scope
/// prefixes). Used to match e.g. `?info@?M@??f@…@4Uinfo@@B` against
/// `?__info@?6??f@…@4Uinfo@@B` — the same static under a different macro/compile.
fn local_static_eq(left: &str, right: &str) -> bool {
match (canonical_local_static(left), canonical_local_static(right)) {
(Some(a), Some(b)) => a == b,
_ => false,
}
}

fn reloc_eq(
left_obj: &Object,
right_obj: &Object,
Expand All @@ -342,17 +389,32 @@ fn reloc_eq(
&& left_reloc.relocation.addend == right_reloc.relocation.addend;
match (&left_reloc.symbol.section, &right_reloc.symbol.section) {
(Some(sl), Some(sr)) => {
// Match if section and name or address match
// Match if section and name or address match. MSVC function-local
// statics (assert-info records, etc.) also match on their stable
// function+type tail — their local-name/scope decoration and their
// debug-metadata contents (message text, line number) legitimately
// vary across recompiles and are diffed as data separately.
section_name_eq(left_obj, right_obj, *sl, *sr)
&& (diff_config.function_reloc_diffs == FunctionRelocDiffs::DataValue
|| symbol_name_addend_matches
|| address_eq(left_reloc, right_reloc))
&& (diff_config.function_reloc_diffs == FunctionRelocDiffs::NameAddress
|| left_reloc.symbol.kind != SymbolKind::Object
|| right_reloc.symbol.size == 0 // Likely a pool symbol like ...data, don't treat this as a diff
|| ins_data_literals_eq(left_obj, right_obj, left_ins, right_ins, diff_config))
&& (local_static_eq(&left_reloc.symbol.name, &right_reloc.symbol.name)
|| ((diff_config.function_reloc_diffs == FunctionRelocDiffs::DataValue
|| symbol_name_addend_matches
|| address_eq(left_reloc, right_reloc))
&& (diff_config.function_reloc_diffs == FunctionRelocDiffs::NameAddress
|| left_reloc.symbol.kind != SymbolKind::Object
|| right_reloc.symbol.size == 0 // Likely a pool symbol like ...data, don't treat this as a diff
|| ins_data_literals_eq(
left_obj, right_obj, left_ins, right_ins, diff_config,
))))
}
// Section-less (external/COMDAT) references — e.g. an inline function's
// assert-info static referenced from another object. Match by exact name
// or, for MSVC function-local statics, by the stable function+type tail
// at the same addend (their local-name/scope decoration is unstable).
(Some(_), None) | (None, Some(_)) | (None, None) => {
symbol_name_addend_matches
|| (left_reloc.relocation.addend == right_reloc.relocation.addend
&& local_static_eq(&left_reloc.symbol.name, &right_reloc.symbol.name))
}
(Some(_), None) | (None, Some(_)) | (None, None) => symbol_name_addend_matches,
}
}

Expand Down Expand Up @@ -532,3 +594,58 @@ fn diff_instruction(

Ok(InstructionDiffResult::new(InstructionDiffKind::None))
}

#[cfg(test)]
mod tests {
use super::{canonical_local_static, local_static_eq};

#[test]
fn canonical_local_static_strips_name_and_scope() {
// Digit scope (`?6`) and macro name `__info`.
assert_eq!(
canonical_local_static("?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B"),
Some("??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B")
);
// Nibble scope (`?M@`) and source name `info` — the reference side.
assert_eq!(
canonical_local_static("?info@?M@??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B"),
Some("??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B")
);
}

#[test]
fn local_static_eq_matches_across_name_and_scope() {
// Same function/type/storage, different local name + scope index → equal.
assert!(local_static_eq(
"?info@?M@??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B",
"?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B"
));
}

#[test]
fn local_static_eq_rejects_different_functions() {
// Different enclosing function → not equal, even with matching name/type.
assert!(!local_static_eq(
"?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B",
"?__info@?6??release@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B"
));
}

#[test]
fn local_static_eq_rejects_different_types() {
// Same function, different static type → not equal.
assert!(!local_static_eq(
"?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Us_slim_assert_info@@B",
"?__info@?6??acquire@?$c_reference_count@F@@QEAAXXZ@4Uother_type@@B"
));
}

#[test]
fn canonical_local_static_rejects_non_locals() {
// Ordinary global/function symbols must not be treated as local statics.
assert_eq!(canonical_local_static("?acquire@?$c_reference_count@F@@QEAAXXZ"), None);
assert_eq!(canonical_local_static("?g_some_global@@3HA"), None);
// Missing the `??` nested-name introducer after the scope.
assert_eq!(canonical_local_static("?info@?6@not_a_nested_name"), None);
}
}
28 changes: 28 additions & 0 deletions objdiff-mcp/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
[package]
name = "objdiff-mcp"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
description = """
Model Context Protocol (MCP) server exposing objdiff's diffing for AI-driven decompilation matching.
"""

[[bin]]
name = "objdiff-mcp"
path = "src/main.rs"

[dependencies]
objdiff-core = { path = "../objdiff-core", features = ["all"] }
anyhow = "1.0"
rmcp = { version = "2.1.0", features = ["server", "transport-io", "transport-streamable-http-server"] }
tokio = { version = "1.52.3", features = ["rt-multi-thread", "macros", "io-std", "signal", "sync"] }
schemars = "1"
serde = { version = "1.0.228", features = ["derive"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
clap = { version = "4.6.1", features = ["derive"] }
axum = "0.8.9"
typed-path = "0.12"
100 changes: 100 additions & 0 deletions objdiff-mcp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# objdiff-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-mcp (this)
ida-bridge MCP → edit C++ → build → diff_function → match% + instr diff
▲ │
└────────────── agent iterates ◀────────────┘
```

## Build

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

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-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-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-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.
Loading
Loading