Skip to content

[AUTOMATED] feat(ghidra): ghidra-sim differential test harness — pin the GUI-path quality gap - #317

Merged
mahaloz merged 5 commits into
mainfrom
feat/ghidra-sim-harness
Aug 17, 2026
Merged

[AUTOMATED] feat(ghidra): ghidra-sim differential test harness — pin the GUI-path quality gap#317
mahaloz merged 5 commits into
mainfrom
feat/ghidra-sim-harness

Conversation

@mahaloz

@mahaloz mahaloz commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Test harness for the Ghidra integration: make GUI-path regressions and the Phase-2 quality gap measurable numbers in CI, without a live Ghidra. This is PR-A of the ghidra-mode series; the Phase-3 (lazy symbol/type providers) and Phase-4 PRs land against this harness by flipping its pins.

What the harness is

  • Shared MockJava loopback (decompiler/crates/kuna-ghidra/tests/ghidra_sim/mod.rs): the interactive single-threaded pipe double proven by decompile_at_e2e.rs, extracted behind a pluggable AnswerSource — wire builders, query pump, session tracer, a dual-<function> doc parser, a markup→C flattener that replicates Java's getC() token cleaning (IllegalCharCppTransformer), badness scanners, and a normalized line-diff metric. decompile_at_e2e.rs is refactored onto it (still green).
  • ghidra-sim v1 (tests/ghidra_sim/oracle.rs): a mock-Java answer source backed by kuna's own analysis of real vendored ELFsbootstrap_from_object for bytes/labels/facts, the real Sleigh re-encoded as wire <inst> docs for getPcode, register/userop tables from the same Sleigh, and a tspec generated from the loaded Sleigh's AddrSpaceManager so packed space indices agree by construction. getMappedSymbols/getExternalRef answer empty — the Phase-2 reality — at a clearly marked PHASE-3 SEAM where the <mapsym> encoder plugs in.
  • The differential harness (tests/ghidra_sim_e2e.rs): drives registerProgram → setAction → decompileAt×3 → flushNative → repeat decompileAt → deregister over tests/bug-repro/faillog (plus a sort/grep breadth test), asserts the response-document schema (name/entry echo, markup opref/varref ⊆ ast, 19-query legality + query-legal-command placement, clean warnings), and decompiles the SAME functions through the in-process CLI drive to pin the gap the GUI user experiences.

Today's pinned numbers (faillog, x86-64, stripped)

target register leaks Unique/Stack tokens placeholders loader-resolvable getC()-mangled tokens C lines diff ratio vs CLI (band)
sub_2620 (main) 108 34 49 24 21 243 0.646 [0.55, 0.70]
sub_3320 58 4 25 18 6 128 0.867 [0.80, 0.95]
sub_3ad0 60 8 17 14 7 89 0.811 [0.70, 0.90]

(Re-measured on main @ 813ee131 after rebasing — the engine PRs #303#316 shift the ghidra-path output slightly; determinism verified over three identical runs.)

Session query traffic (unchanged by the rebase): getPcode total 1477 vs 1003 distinct decoded instructions (no p-code cache + no-return flow overrun into neighbours); getMappedSymbols = 0 (pinned; Phase 3 flips this to ≥ 1). flushNative + repeat decompile is pinned byte-identical (deterministic engine; Phase 3 turns this into the cache-clearing semantics test via the oracle's label_overrides seam).

How the next PRs flip them:

  • Phase 3 (lazy ScopeGhidra/TypeFactoryGhidra): resolvable → 0 (the 24/18/14 are PLT imports the loader already names — localtime, getopt_long, dcgettext, … rendering as sub_ADDR only because getMappedSymbols is unanswered), getMappedSymbols ≥ 1, diff-ratio floors flip to ceilings, register/Unique leak counts collapse (r3 §8 defects a–e, g).
  • PR-C (markup emitter): mangled tokens → 0 (see root cause below).

Root cause found (reported, NOT fixed here — PR-C's job)

The live-repro type mangling (unsigned_long__a1, (char__)) is two-sided:

  1. kuna's markup path emits the whole rendered declarator as ONE <type> token — tag_type(ty/decl_type/ret_type, …) at decompiler/crates/kuna-decomp/src/p9_emit/printc.rs:1428,1451,2190,2679 receives strings like "unsigned long *" (the code at :2683 even tests decl_type.ends_with('*')). Upstream PrintC::pushTypeStart pushes only the base type NAME as the type token; stars/spaces travel as separate syntax/op tokens.
  2. Java's DecompileResults.getDecompiledFunction() (DecompileResults.java:211) runs every funcname/variable/type/field/label token through IllegalCharCppTransformer.simplify, rewriting every non-identifier char to _ — so "unsigned long *" + "a1" reads back as unsigned_long__a1. The GUI panel renders token text verbatim; scripts/exports/getC() consumers see the mangling.

The harness flattener replicates (2) exactly, so (1) is pinned as the mangled tokens column and PR-C's fix is measurable. Fixing (1) is a multi-site token-granularity change (> 10 lines), out of scope here per the brief.

CI + tooling

  • .github/workflows/tests.yml gates job (runs on every PR): one new step — cargo test -p kuna-ghidra --release -- --include-ignored with the standard skip-canary grep. Cost: ~1–2 min compile over the already-built release deps + ~2 s of test runtime.
  • make test-ghidra: the same run locally.
  • make rust-test (dev profile) runs the faillog tests too (~12 s; the sort/grep breadth test is #[ignore]d there and picked up by the release contexts).
  • Live-smoke rig (integrations/ghidra/live-smoke/, manual/dev, not CI): a parameterized pyghidra script that swaps DecompileProcessFactory.exepath to kuna_ghidra inside a real Ghidra, decompiles the same functions with both cores, and writes a side-by-side report with the same scanner counts (README covers the offline-pyghidra setup).
  • docs/ghidra-integration.md §11 rewritten around the harness.

Gates

make test 675/675 PARITY OK · make test-stages PARITY OK · make rust-test green (the new tests run inside it) · make check-spec OK.

🤖 Generated with Claude Code

https://claude.ai/code/session_01AfGYKwUWvPYYj1Aw7tcLhC


Review hardening (aa67ad57): diff-ratio pins are now a band (floors + ceilings [0.70, 0.95, 0.90]) and the flattened-C normalized line count is pinned per target (241 / 174 / 89) — the assert that catches a <break>-token collapse the ratio floors alone could not; the vacuous <= 1.0 assert is gone. decompile_cli now routes through the shared decompile_step::decompile_one with the error-noreturn CALL_RETURN flow overrides exactly as kuna_console::project builds them (latent fix — every faillog pin re-measured identical). Assert strength: warnings frames pinned trim-empty, markup oprefs AND varrefs non-empty per class, ast varnode refs collected from <varnodes> only (all Java's buildVarnodeRefs keys), name echo vs the sim's served code_label. The CI step now tees its log so a failing pin prints diagnostics under bash -e. Live-smoke docs point at cargo build --release -p kuna-ghidra (not make binaries). No pinned value moved.

mahaloz and others added 5 commits August 17, 2026 22:44
… quality gap

Extract the decompile_at_e2e MockJava loopback into a shared module
(tests/ghidra_sim/mod.rs — AnswerSource-pluggable pump, wire builders,
session tracer, dual-<function> doc parser, a markup->C flattener that
replicates Java's getC() token cleaning (IllegalCharCppTransformer),
badness scanners, line-diff metric) and build ghidra-sim v1 on top
(tests/ghidra_sim/oracle.rs): a mock-Java answer source backed by kuna's
own analysis of real vendored ELFs — bootstrap_from_object for
bytes/labels, the real Sleigh re-encoded as wire <inst> docs for
getPcode, and a tspec GENERATED from the loaded Sleigh's
AddrSpaceManager so packed space indices agree by construction.
getMappedSymbols/getExternalRef answer EMPTY at a marked PHASE-3 SEAM.

tests/ghidra_sim_e2e.rs drives the full wire lifecycle (registerProgram
-> setAction -> decompileAt x3 -> flushNative -> repeat -> deregister)
over tests/bug-repro/faillog (sort/grep as an ignored breadth test),
asserts the response-document schema (name/entry echo, markup
opref/varref subset-of ast, 19-query legality + query-legal placement),
and PINS today's Phase-2 reality: per-function raw-register leaks
(106/64/60), Unique tokens (32/2/8), placeholders (49/25/17) of which
the loader already knows names for 24/18/14 (Phase 3 drives to 0),
getC()-mangled tokens (21/13/7, PR-C drives to 0), ghidra-vs-CLI line
diff ratios (0.64/0.90/0.81 floors), getPcode traffic 1477/1003, and
getMappedSymbols == 0 (Phase 3 flips to >=1). Pins move only with the
provider/emitter change that earns them.

[AUTOMATED]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfGYKwUWvPYYj1Aw7tcLhC
…hidra

Add a `gates`-job step running `cargo test -p kuna-ghidra --release --
--include-ignored` (with the standard specs-skip canary grep) after the
catalog checks: the workspace suite is skipped on internal PRs, and a
GUI-path regression is exactly what the ghidra-sim pins exist to catch
pre-merge. Cost: ~1-2 min of compile over the release deps `make
binaries` already built, ~2 s of test runtime. `make test-ghidra` is the
same run locally.

[AUTOMATED]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfGYKwUWvPYYj1Aw7tcLhC
integrations/ghidra/live-smoke/: a manual/dev pyghidra rig that swaps
DecompileProcessFactory.exepath to kuna_ghidra inside a real Ghidra,
decompiles the same functions with both cores, and writes a side-by-side
report with the same badness-scanner counts the in-tree harness pins
(README covers the offline-pyghidra setup and the getC()-vs-GUI-panel
rendering distinction). docs/ghidra-integration.md §11 rewritten around
the shipped harness: what it covers, how to run it, where the pins live.

[AUTOMATED]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfGYKwUWvPYYj1Aw7tcLhC
- Diff-ratio pins are now a BAND: ceilings [0.70,0.95,0.90] join the
  floors (a markup regression that makes the GUI text worse now fails
  instead of saturating), the vacuous `<= 1.0` assert is gone, and the
  flattened-C normalized line count is pinned per target (241/174/89) --
  the assert that actually catches a <break>-token collapse. The pin
  comment now decomposes the ratio (Phase-3 symbol gap + option-preset
  skew until setOptions is wired) so nobody chases 0 with symbol work.
- decompile_cli now routes through the SHARED per-function step
  (kuna_console::decompile_step::decompile_one, DIV-66) with the
  error-noreturn CALL_RETURN flow overrides built exactly as
  kuna_console::project does -- the previous direct drive call silently
  dropped them (latent: faillog has no error() sites, and every faillog
  pin re-measured IDENTICAL; sort/grep-class fixtures would have
  diverged from the real CLI).
- Assert strength: warnings frames pinned trim-empty on registerProgram
  and decompileAt (no substring blocklists); markup oprefs AND varrefs
  pinned non-empty per class; ast varnode refs collected from the
  <varnodes> child only (all Java's buildVarnodeRefs keys -- an op
  operand ref undeclared there must not launder the subset assert);
  the name echo compares against the sim's code_label (which consults
  label_overrides) rather than the raw program lookup.
- CI: the gates-job harness step tees its output to a log before the
  canary grep, so a FAILING pin still prints its diagnostics (the old
  command substitution aborted under bash -e before any echo).
- live-smoke docs/script: kuna_ghidra is not built by `make binaries`;
  point at `cargo build --release -p kuna-ghidra`.

Tests/CI/docs-only diff: the parity gates are untouched by construction.
`cargo test -p kuna-ghidra --release -- --include-ignored` fully green;
`make check-spec` green. No pinned value moved.

[AUTOMATED]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfGYKwUWvPYYj1Aw7tcLhC
The branch was cut from #302 while main advanced to #316; the engine
PRs in between (spillargtrial #315, stackguard #306, snip-reads #307,
loadguardrange #308, ...) legitimately shift the ghidra-path output, so
CI's merge-ref run measured register leaks 108 vs the pinned 106. Rebase
onto origin/main and re-pin every value to the rebased tree:

  registers 106/64/60 -> 108/58/60, unique 32/2/8 -> 34/4/8,
  mangled 21/13/7 -> 21/6/7, c_lines 241/174/89 -> 243/128/89,
  diff ratios 0.643/0.898/0.811 -> 0.646/0.867/0.811 (band unchanged);
  placeholders/resolvable/traffic unchanged (49/25/17, 24/18/14,
  1477/1003).

The sub_3320 shrink (c_lines -26%, FS_OFFSET gone from its register
set, mangled 13 -> 6) is the stackguard/snip-reads work stripping the
canary sequence -- explainable, not suspicious. Determinism verified:
three consecutive runs of the pins test are measurement-identical.

Gates on the rebased tree: harness release + --include-ignored green,
make test 675/675 PARITY OK, make test-stages PARITY OK, check-spec OK,
kuna-ghidra dev-profile spot check green. The full workspace suite ran
green on this branch pre-rebase and every added commit is already-CI-
green main history, so it is covered by the branch + main CI runs.

[AUTOMATED]

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AfGYKwUWvPYYj1Aw7tcLhC
@mahaloz
mahaloz force-pushed the feat/ghidra-sim-harness branch from aa67ad5 to 27bbe6a Compare August 17, 2026 22:48
@mahaloz
mahaloz merged commit 7bf77b9 into main Aug 17, 2026
9 checks passed
@mahaloz
mahaloz deleted the feat/ghidra-sim-harness branch August 17, 2026 22:53
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant