N64LLMDecompile / app.py
MatthewReingold's picture
removed non-functioning ghidra pipeline due to time constraints
bc3e4b1
Raw
History Blame Contribute Delete
15.2 kB
"""N64LLMDecompile - HuggingFace ZeroGPU Space
Model: fine-tuned LLM4Decompile-v2 9B (ghidra-refine, merged). Input is Ghidra
pseudo-C; output is bare C. Completion-style prompt.
Flow:
Paste Ghidra pseudo-C -> LLM -> C (+ optional match rating if you also upload
the target object to score against)
The ONLY thing you must edit is MODEL_ID below (your HF username). Everything else
runs as-is; scoring degrades gracefully if its pieces aren't present yet.
"""
from __future__ import annotations
import os
import re
import shlex
import subprocess
import tempfile
from pathlib import Path
from typing import Any
import gradio as gr
try:
import diff as asm_differ # type: ignore
ASM_DIFFER_OK = True
except Exception as exc: # noqa: BLE001
asm_differ = None # type: ignore
ASM_DIFFER_OK = False
ASM_DIFFER_IMPORT_ERROR = str(exc)
try:
import spaces # type: ignore
GPU = spaces.GPU
except Exception:
def GPU(fn=None, **_kwargs):
if fn is None:
return lambda f: f
return fn
# --------------------------------------------------------------------------- #
# Config -- EDIT THIS LINE: put your HF username before the slash
# --------------------------------------------------------------------------- #
MODEL_ID = "MatthewReingold/llm4decompile-9b-v2-n64-finetune"
MAX_NEW_TOKENS = 2048
COMPILERS_DIR = (Path(__file__).parent / "compilers").resolve()
COMPILER_PATHS = {
"ido5.3": str(COMPILERS_DIR / "ido5.3" / "cc"),
"ido7.1": str(COMPILERS_DIR / "ido7.1" / "cc"),
"gcc2.7.2kmc": str(COMPILERS_DIR / "gcc2.7.2kmc" / "gcc"),
"gcc2.8.1pm": str(COMPILERS_DIR / "gcc2.8.1pm" / "gcc"),
}
COMPILERS = list(COMPILER_PATHS)
FLAG_HINTS = {
"ido5.3": "-O2 -mips2 -Xfullwarn -signed -32 ...",
"ido7.1": "-O2 -mips2 -Xfullwarn -signed -32 ...",
"gcc2.7.2kmc": "-O2 -mips3 -G0 ...",
"gcc2.8.1pm": "-O2 -mips3 -G0 ...",
}
OBJDUMP = "mips-linux-gnu-objdump"
COMPILE_TIMEOUT_SECONDS = 30
OBJDUMP_TIMEOUT_SECONDS = 30
MAX_ASM_LINES = 10000
LANGUAGE_ID = "MIPS:BE:32:default"
def score_label(score: int) -> str:
if score == 0:
return "Perfect match"
if score <= 10:
return "Near-perfect"
if score <= 50:
return "Close"
if score <= 100:
return "Partial"
if score <= 500:
return "Rough"
return "Far off"
# --------------------------------------------------------------------------- #
# Compile + score (from eval.py, adapted for one upload at a time)
# --------------------------------------------------------------------------- #
def strip_line_comments(source_code: str) -> str:
return re.sub(r"//[^\n]*", "", source_code)
def write_translation_unit(path: Path, context: str, source_code: str) -> None:
pieces = []
if context.strip():
pieces.append(strip_line_comments(context.rstrip()))
pieces.append(source_code.rstrip())
path.write_text("\n\n".join(pieces) + "\n", encoding="utf-8")
def make_empty_stub_source(name: str) -> str:
if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", name or ""):
name = "stub"
return f"void {name}(void) {{\n}}\n"
def run_command(command: list[str], timeout_seconds: int,
env: dict[str, str] | None = None) -> subprocess.CompletedProcess[str]:
return subprocess.run(
command, check=False, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, timeout=timeout_seconds, env=env,
)
def compiler_command(compiler_name: str, flags: str,
source_path: Path, output_path: Path) -> list[str] | None:
compiler_path = COMPILER_PATHS.get(compiler_name)
if compiler_path is None:
return None
compiler = os.path.expanduser(compiler_path)
return [compiler, *shlex.split(flags or ""), "-c", str(source_path), "-o", str(output_path)]
def compile_source(compiler_name: str, flags: str, context: str, work_dir: Path,
source_code: str, output_name: str) -> tuple[Path | None, dict | None]:
source_path = work_dir / f"{output_name}.c"
output_path = work_dir / f"{output_name}.o"
write_translation_unit(source_path, context, source_code)
command = compiler_command(compiler_name, flags, source_path, output_path)
if command is None:
return None, {"type": "unsupported_compiler", "compiler": compiler_name}
env = os.environ.copy()
compiler_dir = os.path.dirname(os.path.expanduser(command[0]))
env["PATH"] = compiler_dir + ":" + env.get("PATH", "")
try:
completed = run_command(command, COMPILE_TIMEOUT_SECONDS, env=env)
except FileNotFoundError as exc:
return None, {"type": "compiler_not_found", "command": command, "error": str(exc)}
except subprocess.TimeoutExpired:
return None, {"type": "compile_timeout", "timeout_seconds": COMPILE_TIMEOUT_SECONDS}
if completed.returncode != 0 or not output_path.exists():
return None, {
"type": "compile_failed",
"returncode": completed.returncode,
"stdout": completed.stdout,
"stderr": completed.stderr,
}
return output_path, None
def make_diff_config() -> Any:
return asm_differ.Config(
arch=asm_differ.get_arch("mips"),
diff_obj=True, file=None, make=False, source_old_binutils=False,
diff_section=".text", inlines=False,
max_function_size_lines=MAX_ASM_LINES,
max_function_size_bytes=MAX_ASM_LINES * 4,
formatter=asm_differ.PythonFormatter(arch_str="mips"),
diff_mode=asm_differ.DiffMode.NORMAL,
base_shift=0, skip_lines=0, compress=None,
show_rodata_refs=True, show_branches=True, show_line_numbers=False,
show_source=False, stop_at_ret=False, ignore_large_imms=False,
ignore_addr_diffs=False, algorithm="levenshtein",
reg_categories={}, diff_function_symbols=False,
)
def strip_objdump_headers(objdump_output: str) -> str:
lines, in_disassembly = [], False
for line in objdump_output.splitlines():
if line.startswith("Disassembly of section "):
in_disassembly = True
continue
if in_disassembly:
lines.append(line)
return "\n".join(lines)
def disassemble_elf(path: Path, config: Any) -> str:
command = [OBJDUMP, *config.arch.arch_flags, "-dr", str(path)]
completed = run_command(command, OBJDUMP_TIMEOUT_SECONDS)
if completed.returncode != 0:
raise RuntimeError(completed.stderr or completed.stdout)
if hasattr(asm_differ, "preprocess_objdump_out"):
return asm_differ.preprocess_objdump_out(None, path.read_bytes(), completed.stdout, config)
return strip_objdump_headers(completed.stdout)
def score_disassembly(expected_disasm: str, actual_disasm: str, config: Any) -> int:
expected_lines = asm_differ.process(expected_disasm, config)
actual_lines = asm_differ.process(actual_disasm, config)
return int(asm_differ.do_diff(expected_lines, actual_lines, config).score)
def compile_and_score(generated_c: str, object_bytes: bytes, compiler: str,
flags: str, context: str, function_name: str) -> dict:
if not ASM_DIFFER_OK:
return {"status": "scoring_unavailable"}
if compiler not in COMPILER_PATHS:
return {"status": "unsupported_compiler", "compiler": compiler}
config = make_diff_config()
predicted = strip_line_comments(generated_c)
with tempfile.TemporaryDirectory(prefix="n64_space_") as tmp_name:
tmp = Path(tmp_name)
expected_path = tmp / "expected.o"
expected_path.write_bytes(object_bytes)
actual_path, error = compile_source(compiler, flags, context, tmp, predicted, "actual")
if error is not None:
return {"status": error["type"], "error": error, "compiler": compiler}
baseline_path, baseline_error = compile_source(
compiler, flags, context, tmp, make_empty_stub_source(function_name), "baseline"
)
try:
expected_disasm = disassemble_elf(expected_path, config)
actual_disasm = disassemble_elf(actual_path, config)
score = score_disassembly(expected_disasm, actual_disasm, config)
except Exception as exc: # noqa: BLE001
return {"status": "diff_failed", "error": str(exc), "compiler": compiler}
result = {"status": "evaluated", "score": score, "compiler": compiler}
if baseline_error is None:
try:
baseline_disasm = disassemble_elf(baseline_path, config)
result["baseline_score"] = score_disassembly(expected_disasm, baseline_disasm, config)
except Exception: # noqa: BLE001
pass
return result
# --------------------------------------------------------------------------- #
# Model -- loads at startup; if MODEL_ID is wrong the Space still boots and
# generate_c reports the error instead of crashing.
# --------------------------------------------------------------------------- #
_MODEL = None
_TOKENIZER = None
_MODEL_LOAD_ERROR = None
try:
from transformers import AutoModelForCausalLM, AutoTokenizer
_TOKENIZER = AutoTokenizer.from_pretrained(MODEL_ID)
_MODEL = AutoModelForCausalLM.from_pretrained(MODEL_ID, torch_dtype="auto")
except Exception as exc: # noqa: BLE001
_MODEL_LOAD_ERROR = str(exc)
def l4d_prompt(pseudo_c: str) -> str:
return f"# This is the assembly code:\n{pseudo_c}\n# What is the source code?\n"
@GPU
def generate_c(pseudo_c: str) -> str:
prompt = l4d_prompt(pseudo_c)
if _MODEL is None or _TOKENIZER is None:
msg = _MODEL_LOAD_ERROR or "model not loaded — check MODEL_ID"
return f"/* model unavailable: {msg} */\n/* prompt that would be sent:\n\n{prompt}\n*/\n"
_MODEL.to("cuda")
inputs = _TOKENIZER(prompt, return_tensors="pt").to("cuda")
out = _MODEL.generate(
**inputs, max_new_tokens=MAX_NEW_TOKENS, do_sample=False,
repetition_penalty=1.1, pad_token_id=_TOKENIZER.eos_token_id,
)
completion = out[0][inputs["input_ids"].shape[1]:]
return _TOKENIZER.decode(completion, skip_special_tokens=True)
# --------------------------------------------------------------------------- #
# Rating render
# --------------------------------------------------------------------------- #
def render_rating(result: dict | None, scored: bool) -> str:
if not scored:
return ("**No match rating.** Upload the target object alongside your paste "
"(or use object mode) to score the compiled output against it.")
if result is None:
return ""
status = result.get("status")
if status == "scoring_unavailable":
return ("**Scoring unavailable** — `diff.py` (asm-differ) isn't in the repo yet. "
"The C is below; add diff.py to enable match ratings.")
if status == "unsupported_compiler":
return f"**Unsupported compiler** `{result.get('compiler')}`. The C is below."
if status == "compiler_not_found":
return ("**Compiler binary not found** — check that `compilers/` is in the Space "
"and executable. The C is below.")
if status == "compile_timeout":
return "**Compile timed out.** The C is below — you may be able to simplify it."
if status == "compile_failed":
err = (result.get("error") or {}).get("stderr") or "compile error"
return (f"**Did not compile** with `{result.get('compiler')}`. "
f"The C is below anyway — you may be able to fix it.\n\n```\n{err[:600]}\n```")
if status == "diff_failed":
return f"**Diff failed:** {str(result.get('error'))[:300]}. The C is below."
score = result["score"]
line = f"**{score_label(score)}** — asm-differ score `{score}` (0 = byte-perfect, lower is better)."
base = result.get("baseline_score")
if isinstance(base, int):
verb = "beats" if score < base else "does not beat"
line += f"\n\nThis {verb} the empty-stub baseline (`{base}`)."
return line
# --------------------------------------------------------------------------- #
# Orchestrator
# --------------------------------------------------------------------------- #
def _read_bytes(file_path) -> bytes | None:
if file_path is None:
return None
with open(file_path, "rb") as handle:
return handle.read()
def run(pasted_pseudo_c, paste_object_file, compiler, flags, context):
context = context or ""
pseudo_c = (pasted_pseudo_c or "").strip()
if not pseudo_c:
return "", "**Paste some Ghidra pseudo-C to start.**"
generated_c = generate_c(pseudo_c)
object_bytes = _read_bytes(paste_object_file)
if object_bytes is not None:
result = compile_and_score(generated_c, object_bytes, compiler, flags, context, "")
return generated_c, render_rating(result, scored=True)
return generated_c, render_rating(None, scored=False)
# --------------------------------------------------------------------------- #
# UI
# --------------------------------------------------------------------------- #
CSS = """
.gradio-container { max-width: 1100px !important; }
#out-c textarea, #pseudo textarea { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
"""
with gr.Blocks(title="N64LLMDecompile", theme=gr.themes.Base(), css=CSS) as demo:
gr.Markdown(
"# N64LLMDecompile\n"
"Turn N64 MIPS into C with LLM4Decompile-v2 fine-tuned on decomp.me. "
"Paste Ghidra pseudo-C to get C back — optionally upload the target object "
"to score the result with a match rating."
)
with gr.Row():
with gr.Column(scale=1):
pasted_pseudo_c = gr.Textbox(
label="Ghidra pseudo-C", placeholder="Paste the decompiler output here…",
lines=12, elem_id="pseudo",
)
paste_object_file = gr.File(
label="Target object for scoring (optional) — upload to rate the output",
file_types=[".o", ".elf", ".bin"], type="filepath",
)
compiler = gr.Dropdown(choices=COMPILERS, value="ido7.1",
label="Compiler (for scoring)")
flags = gr.Textbox(label="Compiler flags (for scoring)",
placeholder=FLAG_HINTS["ido7.1"])
context = gr.Textbox(
label="Context / headers (optional, for scoring)",
placeholder="typedefs, structs, externs the function needs to compile", lines=4,
)
run_btn = gr.Button("Decompile to C", variant="primary")
with gr.Column(scale=1):
out_c = gr.Code(label="Generated C", language="c", elem_id="out-c")
rating = gr.Markdown()
compiler.change(lambda c: gr.update(placeholder=FLAG_HINTS.get(c, "")),
inputs=compiler, outputs=flags)
run_btn.click(
run,
inputs=[pasted_pseudo_c, paste_object_file, compiler, flags, context],
outputs=[out_c, rating],
)
if __name__ == "__main__":
demo.launch()