diff --git a/harbor/cli.py b/harbor/cli.py index 5bfaa05..0eacfe6 100644 --- a/harbor/cli.py +++ b/harbor/cli.py @@ -255,9 +255,10 @@ def build_parser(): p = sub.add_parser("who", help="who has worked on a scope (notes, reviews, claims, messages)") p.add_argument("scope") p = sub.add_parser("re", help="reverse-engineering lookups from the project's image and annotations") - p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup", "ghidra", "ghidra-tools", "debugger-tools"]) + p.add_argument("action", choices=["disasm", "xrefs", "bytes", "lookup", "ghidra", "ghidra-tools", "debugger-tools", "match"]) p.add_argument("target", nargs="?", default="", help="an address (0x4AD850) or a name; for ghidra, the tool") p.add_argument("arguments", nargs="?", default="{}", help="ghidra: the tool's arguments as JSON") + p.add_argument("--decomp", help="match: the checkout or worktree to compile from") p.add_argument("--max-bytes", type=int, dest="max_bytes") p.add_argument("--count", type=int, default=16) p.add_argument("--as", dest="as_type", default="hex", choices=["hex", "int32", "uint32", "float", "double", "string"]) @@ -675,8 +676,14 @@ def main(argv=None): out(r, f"summary #{r['id']} pinned to {r['thread']}") elif cmd == "re": from .reverse import Ghidra, Reverse - r = None if args.action.startswith(("ghidra", "debugger")) else Reverse(harbor.project.reverse) - if args.action == "debugger-tools": + r = None if args.action.startswith(("ghidra", "debugger", "match")) else Reverse(harbor.project.reverse) + if args.action == "match": + from .reverse import match_functions + res = match_functions(harbor.project.reverse, args.target, args.decomp) + out(res, res["summary"] + "\n" + "\n".join( + f"{x['address']} {x['status']:<9} {x.get('similarity', '')!s:<6} {x['file']} {x.get('note', '')}" + for x in res["results"])) + elif args.action == "debugger-tools": from .reverse import Debugger res = Debugger(harbor.project.reverse).tools() out(res, "\n".join(f"{t['name']:<22} {(t['description'] or '').splitlines()[0][:90] if t['description'] else ''}" diff --git a/harbor/mcp_server.py b/harbor/mcp_server.py index 3744667..0f23b38 100644 --- a/harbor/mcp_server.py +++ b/harbor/mcp_server.py @@ -503,6 +503,21 @@ def _gh(fn): raise ToolError(str(error)) from None +@mcp.tool() +def re_match(targets: str, decomp: str | None = None) -> dict: + """Compile functions with the game's own compiler (Visual C++ 2003, cl 13.10) and compare them with the + original bytes. targets: up to 50 addresses, e.g. '0x412F70, 0x4ADC70'; decomp: your worktree (default main). + status 'match' means byte-identical apart from linker-filled addresses; 'arguments in registers' means the + game passed them in registers (link-time code generation) and the rest is identical. similarity (0-1) + compares instructions, so you can see whether a rewrite moved closer. Matching is a bonus, not a gate: + readable, correct C comes first.""" + try: + from .reverse import match_functions + return match_functions(harbor().project.reverse, targets, decomp) + except (HarborError, projects.ProjectError) as error: + raise ToolError(str(error)) from None + + @mcp.tool() def ghidra_tools() -> list: """The tools of the team's shared Ghidra session (GhidrAssistMCP, read-only, the curated project with every diff --git a/harbor/reverse.py b/harbor/reverse.py index f10e000..4809682 100644 --- a/harbor/reverse.py +++ b/harbor/reverse.py @@ -452,3 +452,32 @@ class Debugger: pass self.proc.terminate() self.proc = None + + +def match_functions(config, targets, decomp=None, flags=None): + """Compile functions with the game's compiler (Visual C++ 2003) and compare them with the original bytes. + targets: addresses ("0x4ADC70, 0x412F70"); decomp: the checkout or worktree whose src/ holds them.""" + import subprocess + import sys + import tempfile + config = config or {} + matcher = config.get("matcher") + if not matcher: + raise HarborError("this project has no matcher in its [reverse] table") + wanted = [t.strip() for t in re.split(r"[,\s]+", str(targets)) if t.strip()] + if not wanted or len(wanted) > 50: + raise HarborError("give 1 to 50 addresses") + with tempfile.TemporaryDirectory(dir=config.get("debugger_temp")) as tmp: + out = Path(tmp) / "match.json" + command = [sys.executable, "-B", str(matcher), "--all", "--only", ",".join(wanted), "--json", str(out), + "--flags", flags or config.get("match_flags", "-TC -O2")] + if decomp: + command += ["--decomp", str(decomp)] + result = subprocess.run(command, capture_output=True, text=True, encoding="utf-8", errors="replace", + timeout=600, creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + if not out.exists(): + raise HarborError("the matcher failed: " + (result.stdout + result.stderr).strip()[-800:]) + rows = json.loads(out.read_text(encoding="utf-8")) + errors = [line for line in result.stdout.splitlines() if "compile errors" in line] + return {"results": rows, "summary": result.stdout.strip().splitlines()[0] if result.stdout.strip() else "", + **({"compile": errors[0]} if errors else {})} diff --git a/tests/fake_matcher.py b/tests/fake_matcher.py new file mode 100644 index 0000000..df15d4c --- /dev/null +++ b/tests/fake_matcher.py @@ -0,0 +1,10 @@ +"""Stands in for match71.py: writes one result per --only address.""" +import json +import sys + +args = sys.argv[1:] +only = args[args.index("--only") + 1].split(",") +out = args[args.index("--json") + 1] +rows = [{"address": a, "file": a + "_F.c", "status": "match", "similarity": 1.0} for a in only] +open(out, "w", encoding="utf-8").write(json.dumps(rows)) +print(f"cl 13.10 {args[args.index('--flags') + 1]}: {len(rows)} functions: {len(rows)} match") diff --git a/tests/test_reverse.py b/tests/test_reverse.py index ea5a5ae..caa32b8 100644 --- a/tests/test_reverse.py +++ b/tests/test_reverse.py @@ -136,3 +136,17 @@ class DebuggerForwarder(unittest.TestCase): d.close() with self.assertRaises(HarborError): Debugger({}) + + +class MatchFunctions(unittest.TestCase): + def test_runs_the_matcher_and_reads_its_results(self): + from pathlib import Path + from harbor.reverse import match_functions + config = {"matcher": str(Path(__file__).with_name("fake_matcher.py")), "match_flags": "-TC -O2"} + res = match_functions(config, "0x412F70, 0x404810") + self.assertEqual([r["address"] for r in res["results"]], ["0x412F70", "0x404810"]) + self.assertIn("-TC -O2", res["summary"]) + with self.assertRaises(HarborError): + match_functions(config, "") + with self.assertRaises(HarborError): + match_functions({}, "0x412F70")