[GH-ISSUE #1947] [Design] Address-normalized text dimensions for compare_functions #647

Open
opened 2026-10-15 18:26:54 +00:00 by bsakaguchi · 7 comments
Owner

Originally created by @pablodiazjorge on GitHub (Oct 11, 2026).
Original GitHub issue: https://github.com/morluto/rea/issues/1947

Before submitting

  • I searched the open and closed issues and pull requests for a related discussion.
  • I removed credentials, tokens, private paths, and sensitive target data.

Decision needed

Should compare_functions add a separately labelled, address-normalized view of the pseudocode and assembly text, keeping the exact dimensions as they are, so that two builds which only shift addresses stop reporting every function as changed? If so, which masking rules should it declare, and should provider-specific name forms be defined at the provider boundary or in the domain normalization?

Context and evidence

Observed (rea 6.4.0, Ghidra 12.1.4, native Windows 11 x64): on Ghidra's own version-tracking exercise, WallaceSrc.exe against WallaceVersion2.exe, compare_functions with the analyze_function Evidence of both sides reports every textual dimension as changed for all three compared functions, including addPeople, whose source did not change:

Dimension addPeople (unchanged in source) initializePeople (changed) gadget function (changed)
Pseudocode text changed, 639 → 639 characters changed, 583 → 493 changed, 1,174 → 1,216
Calls changed, 2 → 2 changed, 2 → 0 changed, 5 → 5
Control flow graph unchanged changed changed, 9 → 10 blocks

Derived: only the control flow graph, which normalizeCfg maps to local block indices, separates the real changes from the noise. The textual dimensions differ because address-derived names (FUN_…, DAT_…, LAB_…) and absolute addresses shift between the two builds; identical lengths and counts on both sides of the control function point to that.

From the source: compareText compares dossier.pseudocode and the joined dossier.assembly as exact strings. The assembly dimension declares "Assembly is opaque provider text; relocation normalization is unavailable."; the pseudocode dimension declares no limitation, although the same shift affects it. isAutoName already recognizes address-derived procedure names, and normalizeCfg already relativizes addresses for the graph.

Inference: an agent that reads changed with identical counts can easily report a behaviour change that is only a relocation, which is the opposite of what a diff is for.

Boundary and invariants

  • The owning layer is the domain comparison (functionComparisonDimensions.ts, functionComparisonNormalization.ts); it works on provider text that is already in each dossier, so no provider or bridge change is needed.
  • The exact pseudocode and assembly dimensions keep their current meaning and statuses; any normalized view is additive and labelled as such.
  • The masking rules are deterministic and declared in the result, so an agent can tell an exact difference from a normalized one and see what was masked.
  • Small numeric constants are not masked: a change such as % 3 must stay visible.
  • Comparisons across different providers stay unresolved, as today.

Options and tradeoffs

  1. Keep the current behaviour and document it: no contract change, but the textual dimensions keep reporting changed for relocation-only differences, and agents must know to rely on the graph.
  2. Add pseudocode_normalized and assembly_normalized dimensions: rename address-derived identifiers to per-function ordinal placeholders (consistently on both sides) and replace absolute addresses inside the image with offsets relative to the function start, then diff. Additive and explicit, at the cost of two more dimensions and new goldens.
  3. Keep the dimensions but add a status or annotation such as "changed, address shift only" when the normalized texts match: fewer fields, but it changes the meaning of an existing status, which is a contract change.

Option 2 looks like the best fit for REA's additive, evidence-preserving style, but the choice is yours.

Success criteria and open questions

Success: on a pair of builds that differ only in layout (for example, the same source with one extra function linked first), the normalized dimensions report unchanged for every function while the exact ones keep reporting the shift; on the Wallace pair, addPeople is unchanged in the normalized view and the two edited functions stay changed.

Open questions:

  • Which identifier forms to mask per provider (Ghidra FUN_, DAT_, LAB_, PTR_, string labels; IDA sub_, loc_, off_; Hopper), and whether that list belongs to the provider boundary.
  • Whether absolute constants outside the image range should ever be masked.

References

I can provide reproducible x86 and x64 PE pairs built with llvm-mingw (relocation-only and real-change cases) and run them against real Ghidra 12.1.4 on Windows for whichever option you prefer.

Originally created by @pablodiazjorge on GitHub (Oct 11, 2026). Original GitHub issue: https://github.com/morluto/rea/issues/1947 ### Before submitting - [x] I searched the open and closed issues and pull requests for a related discussion. - [x] I removed credentials, tokens, private paths, and sensitive target data. ### Decision needed Should `compare_functions` add a separately labelled, address-normalized view of the pseudocode and assembly text, keeping the exact dimensions as they are, so that two builds which only shift addresses stop reporting every function as `changed`? If so, which masking rules should it declare, and should provider-specific name forms be defined at the provider boundary or in the domain normalization? ### Context and evidence Observed (rea 6.4.0, Ghidra 12.1.4, native Windows 11 x64): on Ghidra's own version-tracking exercise, `WallaceSrc.exe` against `WallaceVersion2.exe`, `compare_functions` with the `analyze_function` Evidence of both sides reports every textual dimension as `changed` for all three compared functions, including `addPeople`, whose source did not change: | Dimension | `addPeople` (unchanged in source) | `initializePeople` (changed) | gadget function (changed) | |---|---|---|---| | Pseudocode text | `changed`, 639 → 639 characters | `changed`, 583 → 493 | `changed`, 1,174 → 1,216 | | Calls | `changed`, 2 → 2 | `changed`, 2 → 0 | `changed`, 5 → 5 | | Control flow graph | `unchanged` | `changed` | `changed`, 9 → 10 blocks | Derived: only the control flow graph, which `normalizeCfg` maps to local block indices, separates the real changes from the noise. The textual dimensions differ because address-derived names (`FUN_…`, `DAT_…`, `LAB_…`) and absolute addresses shift between the two builds; identical lengths and counts on both sides of the control function point to that. From the source: `compareText` compares `dossier.pseudocode` and the joined `dossier.assembly` as exact strings. The assembly dimension declares "Assembly is opaque provider text; relocation normalization is unavailable."; the pseudocode dimension declares no limitation, although the same shift affects it. `isAutoName` already recognizes address-derived procedure names, and `normalizeCfg` already relativizes addresses for the graph. Inference: an agent that reads `changed` with identical counts can easily report a behaviour change that is only a relocation, which is the opposite of what a diff is for. ### Boundary and invariants - The owning layer is the domain comparison (`functionComparisonDimensions.ts`, `functionComparisonNormalization.ts`); it works on provider text that is already in each dossier, so no provider or bridge change is needed. - The exact pseudocode and assembly dimensions keep their current meaning and statuses; any normalized view is additive and labelled as such. - The masking rules are deterministic and declared in the result, so an agent can tell an exact difference from a normalized one and see what was masked. - Small numeric constants are not masked: a change such as `% 3` must stay visible. - Comparisons across different providers stay unresolved, as today. ### Options and tradeoffs 1. Keep the current behaviour and document it: no contract change, but the textual dimensions keep reporting `changed` for relocation-only differences, and agents must know to rely on the graph. 2. Add `pseudocode_normalized` and `assembly_normalized` dimensions: rename address-derived identifiers to per-function ordinal placeholders (consistently on both sides) and replace absolute addresses inside the image with offsets relative to the function start, then diff. Additive and explicit, at the cost of two more dimensions and new goldens. 3. Keep the dimensions but add a status or annotation such as "changed, address shift only" when the normalized texts match: fewer fields, but it changes the meaning of an existing status, which is a contract change. Option 2 looks like the best fit for REA's additive, evidence-preserving style, but the choice is yours. ### Success criteria and open questions Success: on a pair of builds that differ only in layout (for example, the same source with one extra function linked first), the normalized dimensions report `unchanged` for every function while the exact ones keep reporting the shift; on the Wallace pair, `addPeople` is `unchanged` in the normalized view and the two edited functions stay `changed`. Open questions: - Which identifier forms to mask per provider (Ghidra `FUN_`, `DAT_`, `LAB_`, `PTR_`, string labels; IDA `sub_`, `loc_`, `off_`; Hopper), and whether that list belongs to the provider boundary. - Whether absolute constants outside the image range should ever be masked. ### References - `src/domain/functionComparisonDimensions.ts` (`compareText` and the assembly limitation) - `src/domain/functionComparisonNormalization.ts` (`normalizeCfg`, `isAutoName`) - Writeup with the sanitized, re-authenticated Evidence of both sessions: https://github.com/pablodiazjorge/rea-lab/tree/main/writeups/2026-10-wallace-versions I can provide reproducible x86 and x64 PE pairs built with llvm-mingw (relocation-only and real-change cases) and run them against real Ghidra 12.1.4 on Windows for whichever option you prefer.
Author
Owner

@Zburgers commented on GitHub (Oct 11, 2026):

I'd like to take this on and prepare a focused PR for #1947.

I reviewed the current comparison pipeline (functionComparisonDimensions.ts, functionComparisonNormalization.ts, functionComparisonSchemas.ts, functionComparisonResults.ts, and the comparison/MCP tests). I agree with option 2: add explicitly labelled normalized pseudocode/assembly dimensions, while preserving the existing exact dimensions and their meanings.

Proposed approach:

  1. Start with regression fixtures showing an unchanged function relocated between builds, plus controls where a genuine constant, call target, or instruction changes.
  2. Add a conservative, deterministic projection at the domain comparison boundary for known address-derived identifiers and evidence-supported image-relative addresses. Preserve original text/digests; do not blanket-mask hexadecimal literals, small numeric constants, or unsupported provider forms.
  3. Keep provider mismatch / ambiguous normalization as unknown, with explicit limitations; make masking rules discoverable in the Evidence.
  4. Update the typed dimension schema, summary/overall status implications, MCP Evidence contract and downstream reconstruction-verification consumers; keep legacy dimension statuses intact.
  5. Run focused domain and CLI/MCP boundary tests, schema/catalog checks, and check:fast/check, and report any real-Ghidra verification that is unavailable.

One contract question before I implement that surface: would you prefer adding pseudocode_normalized and assembly_normalized as two dimensions (expanding the current fixed-length eight-dimension array), or exposing them as separate, optional normalized views to avoid affecting existing summary/overall statuses? My inclination is additive and opt-in/clearly labelled, never redefining changed for exact text.

If this boundary and direction look good, I'll proceed with the PR. Happy to adjust the representation or provider-specific masking policy first.

<!-- gh-comment-id:6113940005 --> @Zburgers commented on GitHub (Oct 11, 2026): I'd like to take this on and prepare a focused PR for #1947. I reviewed the current comparison pipeline (`functionComparisonDimensions.ts`, `functionComparisonNormalization.ts`, `functionComparisonSchemas.ts`, `functionComparisonResults.ts`, and the comparison/MCP tests). I agree with **option 2**: add explicitly labelled normalized pseudocode/assembly dimensions, while preserving the existing exact dimensions and their meanings. Proposed approach: 1. Start with regression fixtures showing an unchanged function relocated between builds, plus controls where a genuine constant, call target, or instruction changes. 2. Add a conservative, deterministic projection at the domain comparison boundary for known address-derived identifiers and *evidence-supported* image-relative addresses. Preserve original text/digests; do not blanket-mask hexadecimal literals, small numeric constants, or unsupported provider forms. 3. Keep provider mismatch / ambiguous normalization as `unknown`, with explicit limitations; make masking rules discoverable in the Evidence. 4. Update the typed dimension schema, summary/overall status implications, MCP Evidence contract and downstream reconstruction-verification consumers; keep legacy dimension statuses intact. 5. Run focused domain and CLI/MCP boundary tests, schema/catalog checks, and `check:fast`/`check`, and report any real-Ghidra verification that is unavailable. One contract question before I implement that surface: would you prefer adding `pseudocode_normalized` and `assembly_normalized` as two dimensions (expanding the current fixed-length eight-dimension array), or exposing them as separate, optional normalized views to avoid affecting existing summary/overall statuses? My inclination is **additive and opt-in/clearly labelled**, never redefining `changed` for exact text. If this boundary and direction look good, I'll proceed with the PR. Happy to adjust the representation or provider-specific masking policy first.
Author
Owner

@pablodiazjorge commented on GitHub (Oct 12, 2026):

Thanks for picking this up. On the contract question, my preference would be separate, optional normalized views: they keep the existing eight dimensions and the summary statuses exactly as they are, so nothing that reads changed today changes meaning, and an agent opts into the normalized view explicitly. Once your PR is up, I can build relocation-only and real-change PE pairs (x86 and x64, llvm-mingw, reproducible) and run them against real Ghidra 12.1.4 on Windows, including the Wallace pair from the issue, and post the results there.

<!-- gh-comment-id:6118365008 --> @pablodiazjorge commented on GitHub (Oct 12, 2026): Thanks for picking this up. On the contract question, my preference would be separate, optional normalized views: they keep the existing eight dimensions and the summary statuses exactly as they are, so nothing that reads `changed` today changes meaning, and an agent opts into the normalized view explicitly. Once your PR is up, I can build relocation-only and real-change PE pairs (x86 and x64, llvm-mingw, reproducible) and run them against real Ghidra 12.1.4 on Windows, including the Wallace pair from the issue, and post the results there.
Author
Owner

@pablodiazjorge commented on GitHub (Oct 12, 2026):

@Zburgers, thanks for taking this on. I ran your branch against real Ghidra on Windows, as offered in #1947. @Jax367, part of this bears on #2088 too (see the end).

Setup. Head ad7327ac1, built on Windows 11 26H2 x64 (non-elevated), Node 22.22.3, Ghidra 12.1.4, Temurin 21. Each binary gets its own MCP session for analyze_function; the second session runs compare_functions with include_normalized_views: true.

Wallace, real run of your fixture. It matches your table: addPeople is unchanged in both normalized views (7 address-derived identifiers masked, 2 visible addresses on each side), and initializePeople and the gadget function stay changed in every view.

Reproducible pairs. fixtures/compare-pairs builds one C file in three variants with llvm-mingw 20261006, stripped, for x64 and x86 at -O0 and -O2. reloc only shifts the layout. change adds two real edits: changed_constant tests % 4 instead of % 3, and changed_call calls other_helper (value * 9 + 1) instead of helper (value * 7 + 3). The function addresses come from an unstripped rebuild whose code for the three functions is byte-identical to the stripped binaries.

Relocation only (base against reloc): exact pseudocode and assembly are changed, and both normalized views are unchanged, for all three functions in all four builds (12 of 12).

Real changes (base against change), normalized pseudocode / normalized assembly:

Build unchanged changed_constant changed_call
x64 -O0 unchanged / unchanged changed / changed unchanged / unchanged
x64 -O2 unchanged / unchanged changed / changed changed / unchanged
x86 -O0 unchanged / unchanged changed / changed unchanged / unchanged
x86 -O2 unchanged / unchanged changed / changed changed / changed

In every pair comparison calls is unknown ("Address-derived callee or caller names cannot be matched safely."), and cfg is unchanged for changed_call except at x86 -O2.

The gap: a changed call target reads as no change. At -O0, FUN_140001490(param_1) and FUN_140001510(param_1) both become FUN_#1(param_1), and CALL 0x140001490 and CALL 0x140001510 become the same placeholder. The normalized views say unchanged, calls is unknown and cfg is unchanged, so no dimension reports that the function now calls different code. It only shows when the decompiler happens to print the callee's body: at x64 -O2 the function is a tail-call thunk and Ghidra decompiles the target (* 7 + 3 against * 9 + 1), while the assembly view (JMP 0x…) still reads unchanged; at x86 -O2 Ghidra folded the callee into the body.

This is the limitation the views already declare (ordinal placeholders do not keep which address-derived symbol a site referenced). On stripped binaries, though, calling a different function is one of the most common behavior changes between builds, and here it is the case no dimension catches.

@Zburgers, how to handle it is your call. Two additive directions, in case either fits:

  1. Give a placeholder that stands for a corroborated callee an identity that survives relocation, such as a digest of the callee's own normalized body when the dossier or a cheap provider read has it. helper and other_helper would then mask to different tokens, while a relocated helper keeps its token.
  2. If that is out of scope, report a view as unknown instead of unchanged when it masked a call target and calls could not resolve it, so an agent does not read equality where the tool cannot know.

@Jax367, for #2088: the same data backs your note that equal CFGs establish only equal block sizes and successors. changed_call keeps an unchanged CFG in three of the four builds while it calls different code, so that wording holds up on real Ghidra.

Happy to rerun either branch on a revised head; the pairs rebuild with the same hashes on any machine.

<!-- gh-comment-id:6122664833 --> @pablodiazjorge commented on GitHub (Oct 12, 2026): @Zburgers, thanks for taking this on. I ran your branch against real Ghidra on Windows, as offered in #1947. @Jax367, part of this bears on #2088 too (see the end). **Setup.** Head `ad7327ac1`, built on Windows 11 26H2 x64 (non-elevated), Node 22.22.3, Ghidra 12.1.4, Temurin 21. Each binary gets its own MCP session for `analyze_function`; the second session runs `compare_functions` with `include_normalized_views: true`. **Wallace, real run of your fixture.** It matches your table: `addPeople` is `unchanged` in both normalized views (7 address-derived identifiers masked, 2 visible addresses on each side), and `initializePeople` and the gadget function stay `changed` in every view. **Reproducible pairs.** [`fixtures/compare-pairs`](https://github.com/pablodiazjorge/rea-lab/tree/main/fixtures/compare-pairs) builds one C file in three variants with llvm-mingw 20261006, stripped, for x64 and x86 at -O0 and -O2. `reloc` only shifts the layout. `change` adds two real edits: `changed_constant` tests `% 4` instead of `% 3`, and `changed_call` calls `other_helper` (`value * 9 + 1`) instead of `helper` (`value * 7 + 3`). The function addresses come from an unstripped rebuild whose code for the three functions is byte-identical to the stripped binaries. Relocation only (`base` against `reloc`): exact pseudocode and assembly are `changed`, and both normalized views are `unchanged`, for all three functions in all four builds (12 of 12). Real changes (`base` against `change`), normalized pseudocode / normalized assembly: | Build | `unchanged` | `changed_constant` | `changed_call` | |---|---|---|---| | x64 -O0 | unchanged / unchanged | changed / changed | **unchanged / unchanged** | | x64 -O2 | unchanged / unchanged | changed / changed | changed / **unchanged** | | x86 -O0 | unchanged / unchanged | changed / changed | **unchanged / unchanged** | | x86 -O2 | unchanged / unchanged | changed / changed | changed / changed | In every pair comparison `calls` is `unknown` ("Address-derived callee or caller names cannot be matched safely."), and `cfg` is `unchanged` for `changed_call` except at x86 -O2. **The gap: a changed call target reads as no change.** At -O0, `FUN_140001490(param_1)` and `FUN_140001510(param_1)` both become `FUN_#1(param_1)`, and `CALL 0x140001490` and `CALL 0x140001510` become the same placeholder. The normalized views say `unchanged`, `calls` is `unknown` and `cfg` is `unchanged`, so no dimension reports that the function now calls different code. It only shows when the decompiler happens to print the callee's body: at x64 -O2 the function is a tail-call thunk and Ghidra decompiles the target (`* 7 + 3` against `* 9 + 1`), while the assembly view (`JMP 0x…`) still reads `unchanged`; at x86 -O2 Ghidra folded the callee into the body. This is the limitation the views already declare (ordinal placeholders do not keep which address-derived symbol a site referenced). On stripped binaries, though, calling a different function is one of the most common behavior changes between builds, and here it is the case no dimension catches. @Zburgers, how to handle it is your call. Two additive directions, in case either fits: 1. Give a placeholder that stands for a corroborated callee an identity that survives relocation, such as a digest of the callee's own normalized body when the dossier or a cheap provider read has it. `helper` and `other_helper` would then mask to different tokens, while a relocated `helper` keeps its token. 2. If that is out of scope, report a view as `unknown` instead of `unchanged` when it masked a call target and `calls` could not resolve it, so an agent does not read equality where the tool cannot know. @Jax367, for #2088: the same data backs your note that equal CFGs establish only equal block sizes and successors. `changed_call` keeps an `unchanged` CFG in three of the four builds while it calls different code, so that wording holds up on real Ghidra. Happy to rerun either branch on a revised head; the pairs rebuild with the same hashes on any machine.
Author
Owner

@batteryshark commented on GitHub (Oct 13, 2026):

@Zburgers @pablodiazjorge , my rekit Diaphora adapter uses an approach that may help with the changed-callee case reported above(https://github.com/morluto/rea/issues/1947#issuecomment-6122664833).

The distinction is that a replacement token identifies a cross-build function match, rather than a call’s position within the caller.

  • build_token_map consumes Diaphora’s matched function pairs and builds separate address→token maps for each binary. Both addresses in a pair receive the same token, using the primary function’s name or a synthetic FN_<match-index> when it is anonymous.

  • normalize_pseudocode replaces an address-derived identifier only when that address appears in the map. Otherwise, it preserves the original identifier.

That gives two useful behaviors:

  • A corroborated helper→helper match normalizes identically despite relocation.
  • A caller switching from helper to other_helper retains distinct tokens when those targets have distinct matches. Unmatched addresses remain visible instead of becoming the same “first callee” placeholder.

The relevant regression tests are:

This depends on the quality and ambiguity of the supplied matches; normalization does not establish callee identity itself. Preserving unmatched addresses also leaves relocation noise unresolved, which is preferable to hiding a possible target change.

For REA, I’d suggest sharing a callee token only when correspondence is supported by evidence. Where correspondence is unresolved, preserve the reference or mark that aspect of the normalized view unknown.

I mostly focus on Hex-Rays pseudocode, not Ghidra assembly, so your stuff might need some tweaking.

<!-- gh-comment-id:6150554602 --> @batteryshark commented on GitHub (Oct 13, 2026): @Zburgers @pablodiazjorge , my rekit Diaphora adapter uses an approach that may help with the [changed-callee case reported above](https://github.com/morluto/rea/issues/1947#issuecomment-6122664833)(https://github.com/morluto/rea/issues/1947#issuecomment-6122664833). The distinction is that **a replacement token identifies a cross-build function match, rather than a call’s position within the caller**. * [build_token_map](https://github.com/batteryshark/rekit/blob/0806bcfbf7532d516bafe75d6c5bb692f8b4efd4/skills/diaphora-diff/scripts/run.py#L427-L441) consumes Diaphora’s matched function pairs and builds separate address→token maps for each binary. Both addresses in a pair receive the same token, using the primary function’s name or a synthetic `FN_<match-index>` when it is anonymous. * [normalize_pseudocode](https://github.com/batteryshark/rekit/blob/0806bcfbf7532d516bafe75d6c5bb692f8b4efd4/skills/diaphora-diff/scripts/run.py#L334-L362) replaces an address-derived identifier only when that address appears in the map. Otherwise, it preserves the original identifier. That gives two useful behaviors: - A corroborated `helper`→`helper` match normalizes identically despite relocation. - A caller switching from `helper` to `other_helper` retains distinct tokens when those targets have distinct matches. Unmatched addresses remain visible instead of becoming the same “first callee” placeholder. The relevant regression tests are: - [Matched callees share a token](https://github.com/batteryshark/rekit/blob/0806bcfbf7532d516bafe75d6c5bb692f8b4efd4/tests/test_diaphora_diff.py#L161-L168) - [Unmatched callees remain different](https://github.com/batteryshark/rekit/blob/0806bcfbf7532d516bafe75d6c5bb692f8b4efd4/tests/test_diaphora_diff.py#L170-L175) - [Anonymous but matched callees still normalize identically](https://github.com/batteryshark/rekit/blob/0806bcfbf7532d516bafe75d6c5bb692f8b4efd4/tests/test_diaphora_diff.py#L177-L182) This depends on the quality and ambiguity of the supplied matches; normalization does not establish callee identity itself. Preserving unmatched addresses also leaves relocation noise unresolved, which is preferable to hiding a possible target change. For REA, I’d suggest sharing a callee token only when correspondence is supported by evidence. Where correspondence is unresolved, preserve the reference or mark that aspect of the normalized view `unknown`. I mostly focus on Hex-Rays pseudocode, not Ghidra assembly, so your stuff might need some tweaking.
Author
Owner

@pablodiazjorge commented on GitHub (Oct 13, 2026):

Thanks @batteryshark, that's a useful distinction. In the pairs I ran on #2124, the current rule (the normalized view becomes unknown when calls is unknown and a callee was masked) traded false unchanged results for coverage: 7 of the 12 relocation-only comparisons that were correctly unchanged became unknown, because Ghidra reports calls as unknown for every function in these stripped binaries. A match-based token map would keep those 7 comparable where the callees can be paired, and keep changed_call distinct, since its new callee is a different function.

The open question for rea is what pairs callees when there are no names. In these binaries the only evidence is the callees' own bodies, so a pairing would have to come from something like an address-normalized digest of each callee, and it has to see through thunks: at x64 -O2 the changed call goes through a JMP thunk whose target only appears in thunk_target. Unpaired callees would then stay as addresses or mark the view unknown, as you suggest.

The pairs build reproducibly from rea-lab's fixtures/compare-pairs (one command, the same hashes on any machine), so anyone can rerun them. @Zburgers, if you try this, I can rerun them on Windows with real Ghidra.

<!-- gh-comment-id:6150792401 --> @pablodiazjorge commented on GitHub (Oct 13, 2026): Thanks @batteryshark, that's a useful distinction. In the [pairs I ran on #2124](https://github.com/morluto/rea/pull/2124#issuecomment-6130236738), the current rule (the normalized view becomes `unknown` when `calls` is unknown and a callee was masked) traded false `unchanged` results for coverage: 7 of the 12 relocation-only comparisons that were correctly `unchanged` became `unknown`, because Ghidra reports `calls` as unknown for every function in these stripped binaries. A match-based token map would keep those 7 comparable where the callees can be paired, and keep `changed_call` distinct, since its new callee is a different function. The open question for rea is what pairs callees when there are no names. In these binaries the only evidence is the callees' own bodies, so a pairing would have to come from something like an address-normalized digest of each callee, and it has to see through thunks: at x64 -O2 the changed call goes through a `JMP` thunk whose target only appears in `thunk_target`. Unpaired callees would then stay as addresses or mark the view unknown, as you suggest. The pairs build reproducibly from [rea-lab's `fixtures/compare-pairs`](https://github.com/pablodiazjorge/rea-lab/tree/main/fixtures/compare-pairs) (one command, the same hashes on any machine), so anyone can rerun them. @Zburgers, if you try this, I can rerun them on Windows with real Ghidra.
Author
Owner

@Zburgers commented on GitHub (Oct 14, 2026):

@pablodiazjorge @batteryshark I agree with the distinction: shared tokens should represent supported cross-build matches, not independently assigned callee positions.

I’ve now run PR #2124 at 8cde66673 through real Ghidra 12.1.4 on Windows. Full results are on the PR.

The conservative guard eliminates false unchanged results in all eight changed-call views, including the x64 -O2 thunk assembly case. Three views report changed; five remain unknown. Constant edits are detected in all eight views, and Wallace matches expectations.

The cost is substantial: only 4/12 relocation-only comparisons remain unchanged; 8/12 are unknown. So this addresses the correctness gap but does not fully meet the issue’s original relocation-coverage goal.

I haven’t implemented or tested matched-callee tokens. I think a separate experiment should consume explicit callee Evidence, handle thunk targets and ambiguous matches, and use these same fixtures to measure recovered coverage alongside false equality results. An address-normalized body digest could be a matching signal; it should not establish identity by itself.

Would you prefer landing the conservative opt-in views with this limitation documented, then evaluating matching separately, or treating matching as necessary before landing?

<!-- gh-comment-id:6168847539 --> @Zburgers commented on GitHub (Oct 14, 2026): @pablodiazjorge @batteryshark I agree with the distinction: shared tokens should represent supported cross-build matches, not independently assigned callee positions. I’ve now run PR #2124 at `8cde66673` through real Ghidra 12.1.4 on Windows. [Full results are on the PR](https://github.com/morluto/rea/pull/2124#issuecomment-6168685446). The conservative guard eliminates false `unchanged` results in all eight changed-call views, including the x64 -O2 thunk assembly case. Three views report `changed`; five remain `unknown`. Constant edits are detected in all eight views, and Wallace matches expectations. The cost is substantial: only 4/12 relocation-only comparisons remain `unchanged`; 8/12 are `unknown`. So this addresses the correctness gap but does not fully meet the issue’s original relocation-coverage goal. I haven’t implemented or tested matched-callee tokens. I think a separate experiment should consume explicit callee Evidence, handle thunk targets and ambiguous matches, and use these same fixtures to measure recovered coverage alongside false equality results. An address-normalized body digest could be a matching signal; it should not establish identity by itself. Would you prefer landing the conservative opt-in views with this limitation documented, then evaluating matching separately, or treating matching as necessary before landing?
Author
Owner

@pablodiazjorge commented on GitHub (Oct 14, 2026):

@Zburgers thanks for running it on real Ghidra with the rea-lab compare pairs. I'd land the conservative opt-in views now: they never report a false unchanged, which is the property this issue cares about most, and unknown is an honest answer. Please state the limitation where the views are described (on relocation-only pairs, 4 of 12 comparisons stay unchanged and the rest are unknown), so callers don't read unknown as a change. Matching can then be its own issue that consumes callee Evidence and is measured on the same fixtures, reporting recovered coverage and false equality side by side; @batteryshark's distinction fits there.

<!-- gh-comment-id:6169243713 --> @pablodiazjorge commented on GitHub (Oct 14, 2026): @Zburgers thanks for running it on real Ghidra with the [rea-lab compare pairs](https://github.com/pablodiazjorge/rea-lab/tree/main/fixtures/compare-pairs). I'd land the conservative opt-in views now: they never report a false `unchanged`, which is the property this issue cares about most, and `unknown` is an honest answer. Please state the limitation where the views are described (on relocation-only pairs, 4 of 12 comparisons stay `unchanged` and the rest are `unknown`), so callers don't read `unknown` as a change. Matching can then be its own issue that consumes callee Evidence and is measured on the same fixtures, reporting recovered coverage and false equality side by side; @batteryshark's distinction fits there.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
morluto/rea#647
No description provided.