diff --git a/bridge/ghidra/ReaGhidraBridge.java b/bridge/ghidra/ReaGhidraBridge.java
index c29ed30d4f..3f56922552 100644
--- a/bridge/ghidra/ReaGhidraBridge.java
+++ b/bridge/ghidra/ReaGhidraBridge.java
@@ -2004,9 +2004,20 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
JumpTable.LoadTable[] loadTables = jumpTable.getLoadTables();
RecoveredAArch64RelativeTable recoveredAArch64 =
- loadTables.length == 0
- ? recoverAArch64RelativeTable(jumpTable)
- : null;
+ recoverAArch64RelativeTable(jumpTable);
+ if (recoveredAArch64 != null && loadTables.length > 0) {
+ // A decoded table only adds evidence when Ghidra's own load table
+ // describes the same bytes; any disagreement keeps Ghidra's view.
+ boolean agrees = false;
+ for (JumpTable.LoadTable loadTable : loadTables) {
+ if (loadTable.getAddress().equals(recoveredAArch64.tableAddress) &&
+ loadTable.getSize() == recoveredAArch64.entrySize &&
+ loadTable.getNum() == recoveredAArch64.mappings.size()) {
+ agrees = true;
+ }
+ }
+ if (!agrees) recoveredAArch64 = null;
+ }
List
referencedDataAddresses = loadTables.length == 0
? dispatchPathDataReferences(jumpTable)
: List.of();
@@ -2040,7 +2051,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
source.add("evidence", evidence);
dataSources.add(source);
}
- if (recoveredAArch64 != null) {
+ if (recoveredAArch64 != null && loadTables.length == 0) {
JsonObject source = new JsonObject();
source.addProperty(
"address",
@@ -2055,9 +2066,16 @@ public final class ReaGhidraBridge extends HeadlessScript {
inferenceEvidence(
"jump-table",
"ghidra-instruction-and-memory",
- "Decoded " + recoveredAArch64.mappings.size() +
+ "Decoded " + recoveredAArch64.mappings.size() + " " +
+ (recoveredAArch64.signedEntries ? "signed" : "unsigned") +
" entries of size " + recoveredAArch64.entrySize + " bytes from " + canonicalAddress(recoveredAArch64.tableAddress) +
- "; the AArch64 LDRB-or-LDRH/ADR/ADD/BR chain uses a selector guarded by an unsigned upper-bound branch."
+ "; the AArch64 ADRP/ADD table, indexed load, ADR base, ADD" +
+ (recoveredAArch64.offsetShift == 0 ? "" : " LSL #" + recoveredAArch64.offsetShift) +
+ "/BR chain uses a selector bounded by CMP #" + recoveredAArch64.compareImmediate +
+ " and " + recoveredAArch64.guardMnemonic + " on one straight-line path" +
+ (recoveredAArch64.nullTarget == null
+ ? "."
+ : "; zero entries are routed by CBZ to " + canonicalAddress(recoveredAArch64.nullTarget) + ".")
)
);
source.add("evidence", evidence);
@@ -2096,6 +2114,33 @@ public final class ReaGhidraBridge extends HeadlessScript {
result.add("data_sources", dataSources);
Address[] targets = jumpTable.getCases();
TypedJumpTableCases typedCases = typedJumpTableCases(jumpTable, caseTokens);
+ // Table indices identify the register bounded by CMP. Typed labels may
+ // instead identify a selector before normalization (for example, SUB
+ // index, selector, #10). Do not merge those distinct value identities.
+ if (recoveredAArch64 != null && recoveredAArch64.priorSelectorDefinition != null) {
+ typedCases.limitations.add(
+ "REA decoded the relative table, but the compared selector has an earlier register definition at " +
+ canonicalAddress(recoveredAArch64.priorSelectorDefinition) + "; its table indices are not proven to use the typed switch selector's case values. Ghidra's typed labels are retained and additional case labels remain unknown."
+ );
+ recoveredAArch64 = null;
+ }
+ if (recoveredAArch64 != null) {
+ for (Map.Entry typed : typedCases.cases.entrySet()) {
+ long value = typed.getKey();
+ if (value < 0 || value >= recoveredAArch64.mappings.size()) {
+ typedCases.limitations.add("Typed case labels and decoded table indices do not use a verified common selector; Ghidra's typed labels are retained.");
+ recoveredAArch64 = null;
+ break;
+ }
+ RecoveredAArch64Mapping decoded = recoveredAArch64.mappings.get((int) value);
+ boolean divertedZero = decoded.nullEntry && typed.getValue().equals(recoveredAArch64.unreachableZeroTarget);
+ if (!typed.getValue().equals(decoded.targetAddress) && !divertedZero) {
+ typedCases.limitations.add("Typed case evidence and the decoded relative table disagree on a destination; Ghidra's typed labels are retained.");
+ recoveredAArch64 = null;
+ break;
+ }
+ }
+ }
JsonArray mappings = new JsonArray();
JsonArray defaultTargets = new JsonArray();
for (Address target : typedCases.defaults) {
@@ -2103,27 +2148,30 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
Set coveredTargets = new HashSet<>(typedCases.cases.values());
coveredTargets.addAll(typedCases.defaults);
+ if (recoveredAArch64 != null && recoveredAArch64.unreachableZeroTarget != null) {
+ Address unreachable = recoveredAArch64.unreachableZeroTarget;
+ coveredTargets.add(unreachable);
+ for (RecoveredAArch64Mapping recovered : recoveredAArch64.mappings) {
+ if (recovered.nullEntry && unreachable.equals(typedCases.cases.get((long) recovered.tableIndex))) {
+ typedCases.cases.remove((long) recovered.tableIndex);
+ }
+ }
+ typedCases.limitations.add(
+ "Ghidra recovered destination " + canonicalAddress(unreachable) +
+ " as the zero-offset decode of the relative table, but the CBZ before the dispatch diverts zero entries to " +
+ canonicalAddress(recoveredAArch64.nullTarget) + ", so the indirect branch never reaches it for an in-range case."
+ );
+ }
if (recoveredAArch64 != null) {
for (int index = 0; index < recoveredAArch64.mappings.size(); index += 1) {
monitor.checkCancelled();
RecoveredAArch64Mapping recovered = recoveredAArch64.mappings.get(index);
if (
typedCases.unknownTargets.contains(recovered.targetAddress) ||
- typedCases.ambiguousValues.contains((long) recovered.caseValue)
+ typedCases.ambiguousValues.contains((long) recovered.tableIndex)
) continue;
- Address typedTarget = typedCases.cases.get((long) recovered.caseValue);
- if (typedTarget != null && !typedTarget.equals(recovered.targetAddress)) {
- typedCases.cases.remove((long) recovered.caseValue);
- typedCases.ambiguousValues.add((long) recovered.caseValue);
- typedCases.unknownTargets.add(typedTarget);
- typedCases.unknownTargets.add(recovered.targetAddress);
- typedCases.limitations.add(
- "Typed case evidence and the verified relative table disagree on a destination; both destinations remain unknown for that case."
- );
- continue;
- }
JsonObject mapping = new JsonObject();
- mapping.addProperty("case_value", recovered.caseValue);
+ mapping.addProperty("case_value", recovered.tableIndex);
String targetAddress = canonicalAddress(recovered.targetAddress);
mapping.addProperty("target_address", targetAddress);
mapping.addProperty("confidence", "high");
@@ -2132,14 +2180,18 @@ public final class ReaGhidraBridge extends HeadlessScript {
inferenceEvidence(
"jump-table",
"ghidra-instruction-and-memory",
- "Case value " + recovered.caseValue + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
- (recovered.caseValue * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
- "; the loaded unsigned entry selects a four-byte instruction offset from " +
- canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + "."
+ "Case value " + recovered.tableIndex + (recoveredAArch64.entrySize == 1 ? " indexes byte " : " indexes halfword at byte offset ") +
+ (recovered.tableIndex * recoveredAArch64.entrySize) + " at " + canonicalAddress(recoveredAArch64.tableAddress) +
+ (recovered.nullEntry
+ ? "; the entry is zero, so the CBZ before the dispatch branches to " + targetAddress + "."
+ : "; the loaded " + (recoveredAArch64.signedEntries ? "signed" : "unsigned") +
+ " entry " + recovered.encodedEntry + " selects a " +
+ (recoveredAArch64.offsetShift == 2 ? "four-byte instruction" : "byte") +
+ " offset from " + canonicalAddress(recoveredAArch64.branchBase) + ", reaching " + targetAddress + ".")
)
);
mapping.add("evidence", evidence);
- if (!typedCases.cases.containsKey((long) recovered.caseValue)) {
+ if (!typedCases.cases.containsKey((long) recovered.tableIndex)) {
mappings.add(mapping);
}
coveredTargets.add(recovered.targetAddress);
@@ -2213,7 +2265,7 @@ public final class ReaGhidraBridge extends HeadlessScript {
);
}
if (recoveredAArch64 == null && loadTables.length == 0 && currentProgram.getLanguageID().getIdAsString().startsWith("AARCH64:")) {
- limitations.add("REA did not verify a supported byte/halfword relative dispatch chain with exact unsigned bounds and register definitions; unsupported encodings and ambiguous bounds remain unknown.");
+ limitations.add("REA did not verify a supported signed or unsigned byte/halfword relative dispatch chain with exact unsigned bounds and register definitions; unsupported encodings and ambiguous bounds remain unknown.");
}
if (targets.length == 0) {
limitations.add(
@@ -2343,12 +2395,21 @@ public final class ReaGhidraBridge extends HeadlessScript {
}
private static final class RecoveredAArch64Mapping {
- private final int caseValue;
+ private final int tableIndex;
private final Address targetAddress;
+ private final long encodedEntry;
+ private final boolean nullEntry;
- private RecoveredAArch64Mapping(int caseValue, Address targetAddress) {
- this.caseValue = caseValue;
+ private RecoveredAArch64Mapping(
+ int tableIndex,
+ Address targetAddress,
+ long encodedEntry,
+ boolean nullEntry
+ ) {
+ this.tableIndex = tableIndex;
this.targetAddress = targetAddress;
+ this.encodedEntry = encodedEntry;
+ this.nullEntry = nullEntry;
}
}
@@ -2356,24 +2417,70 @@ public final class ReaGhidraBridge extends HeadlessScript {
private final Address tableAddress;
private final Address branchBase;
private final int entrySize;
+ private final Address priorSelectorDefinition;
+ private final boolean signedEntries;
+ private final int offsetShift;
+ private final String guardMnemonic;
+ private final long compareImmediate;
+ private final Address nullTarget;
+ private final Address unreachableZeroTarget;
private final List mappings;
private RecoveredAArch64RelativeTable(
Address tableAddress,
Address branchBase,
int entrySize,
+ Address priorSelectorDefinition,
+ boolean signedEntries,
+ int offsetShift,
+ String guardMnemonic,
+ long compareImmediate,
+ Address nullTarget,
+ Address unreachableZeroTarget,
List mappings
) {
this.tableAddress = tableAddress;
this.branchBase = branchBase;
this.entrySize = entrySize;
+ this.priorSelectorDefinition = priorSelectorDefinition;
+ this.signedEntries = signedEntries;
+ this.offsetShift = offsetShift;
+ this.guardMnemonic = guardMnemonic;
+ this.compareImmediate = compareImmediate;
+ this.nullTarget = nullTarget;
+ this.unreachableZeroTarget = unreachableZeroTarget;
this.mappings = mappings;
}
}
+ private static final Pattern AARCH64_DISPATCH_ADD = Pattern.compile(
+ "(?i)^([wx]\\d+),\\s*([wx]\\d+),\\s*([wx]\\d+)(?:,\\s*LSL\\s*#(?:0x)?([0-9a-f]+))?$"
+ );
+ private static final Pattern AARCH64_TABLE_LOAD = Pattern.compile(
+ "(?i)^([wx]\\d+),\\s*\\[([wx]\\d+),\\s*([wx]\\d+)(?:,\\s*(LSL|UXTW)\\s*(?:#(?:0x)?([0-9a-f]+))?)?\\s*\\]$"
+ );
+ private static final Pattern AARCH64_REGISTER_AND_ADDRESS = Pattern.compile(
+ "(?i)^([wx]\\d+),\\s*(0x[0-9a-f]+)$"
+ );
+ private static final Pattern AARCH64_ADD_IMMEDIATE = Pattern.compile(
+ "(?i)^([wx]\\d+),\\s*([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$"
+ );
+ private static final Pattern AARCH64_COMPARE_IMMEDIATE = Pattern.compile(
+ "(?i)^([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$"
+ );
+ private static final Pattern AARCH64_MOVE_REGISTER = Pattern.compile(
+ "(?i)^([wx]\\d+),\\s*([wx]\\d+)$"
+ );
+ private static final Set AARCH64_FLAG_PRESERVING =
+ Set.of("MOV", "ADRP", "ADR", "ADD");
+
/**
- * Recover only the directly verified AArch64 byte/halfword-indexed relative branch
- * table form. Other instruction sequences keep their case labels unknown.
+ * Recover only directly verified AArch64 relative branch tables:
+ * ADRP/ADD table base, LDRB/LDRH/LDRSB/LDRSH entry load with an exact
+ * index scale, ADR branch base, optional CBZ on the loaded entry, and
+ * ADD (byte or LSL #2 scale)/BR, with a selector bounded by CMP #imm and an
+ * unsigned B.HI or B.CS/B.HS guard on one straight-line path. Any other
+ * instruction sequence keeps its case labels unknown.
*/
private RecoveredAArch64RelativeTable recoverAArch64RelativeTable(
JumpTable jumpTable
@@ -2395,167 +2502,265 @@ public final class ReaGhidraBridge extends HeadlessScript {
if (instruction.getAddress().equals(jumpTable.getSwitchAddress())) break;
}
int branchIndex = instructions.size() - 1;
- if (branchIndex < 1 ||
- !instructions.get(branchIndex).getMnemonicString().equalsIgnoreCase("BR")) {
+ if (branchIndex < 1 || !mnemonic(instructions.get(branchIndex)).equals("BR")) {
+ return null;
+ }
+ Set recoveredTargetSet = new HashSet<>();
+ for (Address target : jumpTable.getCases()) recoveredTargetSet.add(canonicalAddress(target));
+ if (recoveredTargetSet.isEmpty()) return null;
+ return matchAArch64RelativeDispatch(
+ instructions,
+ branchIndex,
+ recoveredTargetSet,
+ dispatchPathDataReferences(jumpTable)
+ );
+ }
+
+ private RecoveredAArch64RelativeTable matchAArch64RelativeDispatch(
+ List instructions,
+ int branchIndex,
+ Set recoveredTargetSet,
+ List dataAddresses
+ ) throws Exception {
+ String branchRegister = normalizedRegister(operands(instructions.get(branchIndex)));
+
+ // ADD Xd, Xn, Xm{, LSL #s}; BR Xd. LSL applies to Xm only.
+ Instruction dispatchAdd = instructions.get(branchIndex - 1);
+ if (!mnemonic(dispatchAdd).equals("ADD")) return null;
+ java.util.regex.Matcher addMatcher = AARCH64_DISPATCH_ADD.matcher(operands(dispatchAdd));
+ if (!addMatcher.matches() || !addMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) return null;
+ if (!normalizedRegister(addMatcher.group(1)).equals(branchRegister)) return null;
+ int offsetShift = addMatcher.group(4) == null ? 0 : Integer.parseInt(addMatcher.group(4), 16);
+ if (offsetShift != 0 && offsetShift != 2) return null;
+ String firstSource = normalizedRegister(addMatcher.group(2));
+ String secondSource = normalizedRegister(addMatcher.group(3));
+ if (firstSource.equals(secondSource)) return null;
+
+ // The entry register is the source whose latest definition is the
+ // table load; the other source must be defined by ADR.
+ int loadIndex = -1;
+ String entryRegister = null;
+ String baseRegister = null;
+ for (String candidate : offsetShift == 0 ? List.of(secondSource, firstSource) : List.of(secondSource)) {
+ int writer = latestWriter(instructions, branchIndex - 1, candidate);
+ if (writer >= 0 && Set.of("LDRB", "LDRH", "LDRSB", "LDRSH").contains(mnemonic(instructions.get(writer)))) {
+ loadIndex = writer;
+ entryRegister = candidate;
+ baseRegister = candidate.equals(firstSource) ? secondSource : firstSource;
+ break;
+ }
+ }
+ if (loadIndex < 0) return null;
+
+ Instruction load = instructions.get(loadIndex);
+ String loadMnemonic = mnemonic(load);
+ int entrySize = loadMnemonic.endsWith("B") ? 1 : 2;
+ boolean signedEntries = loadMnemonic.startsWith("LDRS");
+ java.util.regex.Matcher loadMatcher = AARCH64_TABLE_LOAD.matcher(operands(load));
+ if (!loadMatcher.matches()) return null;
+ // A signed load into a W register sign-extends only to 32 bits; the
+ // 64-bit dispatch ADD would then read a zero-extended value.
+ if (signedEntries && !loadMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) return null;
+ String tableBaseRegister = normalizedRegister(loadMatcher.group(2));
+ String indexRegister = normalizedRegister(loadMatcher.group(3));
+ boolean wordIndex = loadMatcher.group(3).toLowerCase(Locale.ROOT).startsWith("w");
+ String extend = loadMatcher.group(4) == null ? null : loadMatcher.group(4).toUpperCase(Locale.ROOT);
+ int indexShift = loadMatcher.group(5) == null ? 0 : Integer.parseInt(loadMatcher.group(5), 16);
+ if (wordIndex ? !"UXTW".equals(extend) : "UXTW".equals(extend)) return null;
+ if (indexShift != (entrySize == 1 ? 0 : 1)) return null;
+
+ // Only ADR and one CBZ on the entry may sit between load and ADD.
+ Address nullTarget = null;
+ for (int index = loadIndex + 1; index < branchIndex - 1; index += 1) {
+ Instruction instruction = instructions.get(index);
+ String name = mnemonic(instruction);
+ if (name.equals("ADR")) continue;
+ if (name.equals("CBZ") && nullTarget == null) {
+ String[] parts = operands(instruction).split(",");
+ Address[] flows = instruction.getFlows();
+ if (parts.length != 2 || flows.length != 1 ||
+ !normalizedRegister(parts[0]).equals(entryRegister)) return null;
+ nullTarget = flows[0];
+ continue;
+ }
return null;
}
- Address[] recoveredTargets = jumpTable.getCases();
- Set recoveredTargetSet = new HashSet<>();
- for (Address target : recoveredTargets) recoveredTargetSet.add(canonicalAddress(target));
- List dataAddresses = dispatchPathDataReferences(jumpTable);
+ int baseIndex = latestWriter(instructions, branchIndex - 1, baseRegister);
+ if (baseIndex < 0 || !mnemonic(instructions.get(baseIndex)).equals("ADR")) return null;
+ java.util.regex.Matcher baseMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(baseIndex)));
+ if (!baseMatcher.matches()) return null;
+ Address branchBase = addressFromOperand(baseMatcher.group(2));
- for (int loadIndex = 0; loadIndex < branchIndex; loadIndex += 1) {
- Instruction load = instructions.get(loadIndex);
- int entrySize = load.getMnemonicString().equalsIgnoreCase("LDRB") ? 1 : load.getMnemonicString().equalsIgnoreCase("LDRH") ? 2 : 0;
- if (entrySize == 0 || branchIndex != loadIndex + 2 || loadIndex < 3 ||
- !instructions.get(loadIndex - 1).getMnemonicString().equalsIgnoreCase("ADR") ||
- !instructions.get(loadIndex - 2).getMnemonicString().equalsIgnoreCase("ADD") ||
- !instructions.get(loadIndex - 3).getMnemonicString().equalsIgnoreCase("ADRP")) continue;
- String loadOperands = operands(load);
- java.util.regex.Matcher loadMatcher = java.util.regex.Pattern
- .compile(entrySize == 1 ? "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+)\\]$" : "(?i)^[wx]\\d+,\\s*\\[([wx]\\d+),\\s*([wx]\\d+),\\s*LSL\\s*#(?:0x)?1\\]$")
- .matcher(loadOperands);
- if (!loadMatcher.matches()) continue;
- String loadedRegister = normalizedRegister(loadOperands.substring(0, loadOperands.indexOf(',')));
- String tableBaseRegister = normalizedRegister(loadMatcher.group(1));
- String indexRegister = normalizedRegister(loadMatcher.group(2));
+ int tableAddIndex = latestWriter(instructions, loadIndex, tableBaseRegister);
+ if (tableAddIndex < 0 || !mnemonic(instructions.get(tableAddIndex)).equals("ADD")) return null;
+ java.util.regex.Matcher tableAddMatcher = AARCH64_ADD_IMMEDIATE.matcher(operands(instructions.get(tableAddIndex)));
+ if (!tableAddMatcher.matches() ||
+ !normalizedRegister(tableAddMatcher.group(1)).equals(tableBaseRegister) ||
+ !normalizedRegister(tableAddMatcher.group(2)).equals(tableBaseRegister)) return null;
+ int tablePageIndex = latestWriter(instructions, tableAddIndex, tableBaseRegister);
+ if (tablePageIndex < 0 || !mnemonic(instructions.get(tablePageIndex)).equals("ADRP")) return null;
+ java.util.regex.Matcher pageMatcher = AARCH64_REGISTER_AND_ADDRESS.matcher(operands(instructions.get(tablePageIndex)));
+ if (!pageMatcher.matches() || !normalizedRegister(pageMatcher.group(1)).equals(tableBaseRegister)) return null;
+ Address tableAddress = addressFromOperand(pageMatcher.group(2)).add(parseInteger(tableAddMatcher.group(3)));
+ if (!currentProgram.getMemory().contains(tableAddress) ||
+ (!dataAddresses.isEmpty() && !dataAddresses.contains(tableAddress))) return null;
- Address resolvedTableAddress = null;
- for (int index = loadIndex - 3; index < loadIndex - 1; index += 1) {
- Instruction instruction = instructions.get(index);
- String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
- String text = operands(instruction);
- if (mnemonic.equals("ADRP") &&
- text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
- String[] parts = text.split(",");
- if (normalizedRegister(parts[0]).equals(tableBaseRegister)) {
- resolvedTableAddress = addressFromOperand(parts[1]);
- }
- }
- else if (mnemonic.equals("ADD") && resolvedTableAddress != null &&
- text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*#?(?:0x[0-9a-f]+|[0-9]+)$")) {
- String[] parts = text.split(",");
- if (normalizedRegister(parts[0]).equals(tableBaseRegister) &&
- normalizedRegister(parts[1]).equals(tableBaseRegister)) {
- resolvedTableAddress = resolvedTableAddress.add(parseInteger(parts[2].trim().replaceFirst("^#", "")));
- }
- }
+ // Unsigned guard: CMP Rs, #imm, then B.HI (imm + 1 entries) or
+ // B.CS/B.HS (imm entries), with only flag-preserving moves and
+ // address arithmetic between the compare and the load.
+ int guardIndex = -1;
+ for (int index = loadIndex - 1; index >= 0; index -= 1) {
+ String name = mnemonic(instructions.get(index));
+ if (name.startsWith("B.")) {
+ guardIndex = index;
+ break;
}
- if (resolvedTableAddress == null || !currentProgram.getMemory().contains(resolvedTableAddress) ||
- (!dataAddresses.isEmpty() && !dataAddresses.contains(resolvedTableAddress))) continue;
- Address tableAddress = resolvedTableAddress;
-
- Address branchBase = null;
- boolean addressChain = false;
- for (int index = loadIndex - 1; index < branchIndex; index += 1) {
- Instruction instruction = instructions.get(index);
- String mnemonic = instruction.getMnemonicString().toUpperCase(Locale.ROOT);
- String text = operands(instruction);
- if (index < loadIndex && mnemonic.equals("ADR") && text.matches("(?i)^[wx]\\d+,\\s*0x[0-9a-f]+$")) {
- int comma = text.indexOf(',');
- String destination = normalizedRegister(text.substring(0, comma));
- if (!destination.equals(normalizedRegister(operands(instructions.get(branchIndex))))) continue;
- branchBase = addressFromOperand(text.substring(comma + 1));
- }
- if (index > loadIndex && mnemonic.equals("ADD") && branchBase != null &&
- text.matches("(?i)^[wx]\\d+,\\s*[wx]\\d+,\\s*[wx]\\d+,\\s*LSL\\s*#(?:0x)?2$")) {
- String[] parts = text.split(",");
- String destination = normalizedRegister(parts[0]);
- String base = normalizedRegister(parts[1]);
- String offset = normalizedRegister(parts[2]);
- if (destination.equals(base) &&
- base.equals(normalizedRegister(operands(instructions.get(branchIndex)))) &&
- offset.equals(loadedRegister)) {
- addressChain = true;
- }
- }
- }
- if (!addressChain || branchBase == null) continue;
-
- int entryCount = -1;
- Address defaultTarget = null;
- for (int compareIndex = 0; compareIndex < loadIndex; compareIndex += 1) {
- Instruction compare = instructions.get(compareIndex);
- if (!compare.getMnemonicString().equalsIgnoreCase("CMP")) continue;
- java.util.regex.Matcher compareMatcher = java.util.regex.Pattern
- .compile("(?i)^([wx]\\d+),\\s*#?(0x[0-9a-f]+|[0-9]+)$")
- .matcher(operands(compare));
- if (!compareMatcher.matches()) continue;
- String comparedRegister = normalizedRegister(compareMatcher.group(1));
- long upperBound = parseInteger(compareMatcher.group(2));
- if (upperBound < 0 || upperBound >= 4096) continue;
- for (int guardIndex = compareIndex + 1; guardIndex < loadIndex; guardIndex += 1) {
- Instruction guard = instructions.get(guardIndex);
- String mnemonic = guard.getMnemonicString().toUpperCase(Locale.ROOT);
- if (!mnemonic.equals("B.HI")) continue;
- Address[] flows = guard.getFlows();
- if (flows.length != 1 || !recoveredTargetSet.contains(canonicalAddress(flows[0]))) continue;
- boolean supportedSequence = true;
- for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex += 1) {
- String sequenceMnemonic = instructions.get(sequenceIndex)
- .getMnemonicString().toUpperCase(Locale.ROOT);
- if (!Set.of("B.HI", "MOV", "ADRP", "ADD", "ADR").contains(sequenceMnemonic)) {
- supportedSequence = false;
- break;
- }
- }
- Set selectorAliases = new HashSet<>();
- selectorAliases.add(comparedRegister);
- Set boundedIndexes = new HashSet<>();
- if (compareMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x")) boundedIndexes.add(comparedRegister);
- for (int sequenceIndex = compareIndex + 1; sequenceIndex < loadIndex; sequenceIndex++) {
- Instruction instruction = instructions.get(sequenceIndex);
- String[] parts = operands(instruction).split(",");
- if (parts.length == 0) continue;
- if (instruction.getMnemonicString().equalsIgnoreCase("MOV")) {
- String destination = normalizedRegister(parts[0]);
- String source = parts.length == 2 ? normalizedRegister(parts[1]) : "";
- boolean aliasesSelector = selectorAliases.contains(source);
- boolean fullWidthBounded = boundedIndexes.contains(source) ||
- (parts[0].trim().toLowerCase(Locale.ROOT).startsWith("w") && aliasesSelector);
- if (aliasesSelector) selectorAliases.add(destination);
- else selectorAliases.remove(destination);
- if (fullWidthBounded) boundedIndexes.add(destination);
- else boundedIndexes.remove(destination);
- } else if (Set.of("ADRP", "ADD", "ADR").contains(instruction.getMnemonicString().toUpperCase(Locale.ROOT))) {
- selectorAliases.remove(normalizedRegister(parts[0]));
- boundedIndexes.remove(normalizedRegister(parts[0]));
- }
- }
- boolean selectorFeedsTable = boundedIndexes.contains(indexRegister);
- if (compareIndex < loadIndex - 3 && selectorFeedsTable && supportedSequence) {
- entryCount = (int) upperBound + 1;
- defaultTarget = flows[0];
- break;
- }
- }
- if (entryCount > 0) break;
- }
- if (entryCount <= 0 || entryCount > 4096 || defaultTarget == null) continue;
-
- List mappings = new ArrayList<>();
- Set mappedTargets = new HashSet<>();
- boolean valid = true;
- for (int caseValue = 0; caseValue < entryCount; caseValue += 1) {
- Address entry = tableAddress.add((long) caseValue * entrySize);
- int encodedOffset = entrySize == 1 ? currentProgram.getMemory().getByte(entry) & 0xff : currentProgram.getMemory().getShort(entry) & 0xffff;
- Address target = branchBase.add((long) encodedOffset * 4L);
- String canonicalTarget = canonicalAddress(target);
- if (!recoveredTargetSet.contains(canonicalTarget)) {
- valid = false;
- break;
- }
- mappedTargets.add(canonicalTarget);
- mappings.add(new RecoveredAArch64Mapping(caseValue, target));
- }
- if (!valid || mappedTargets.contains(canonicalAddress(defaultTarget))) continue;
- Set accountedTargets = new HashSet<>(mappedTargets);
- accountedTargets.add(canonicalAddress(defaultTarget));
- if (!accountedTargets.equals(recoveredTargetSet)) continue;
- return new RecoveredAArch64RelativeTable(tableAddress, branchBase, entrySize, List.copyOf(mappings));
+ if (!AARCH64_FLAG_PRESERVING.contains(name)) return null;
}
- return null;
+ if (guardIndex < 1) return null;
+ Instruction guard = instructions.get(guardIndex);
+ String guardMnemonic = mnemonic(guard);
+ if (!Set.of("B.HI", "B.CS", "B.HS").contains(guardMnemonic)) return null;
+ Address[] guardFlows = guard.getFlows();
+ if (guardFlows.length != 1) return null;
+ Address defaultTarget = guardFlows[0];
+ int compareIndex = -1;
+ for (int index = guardIndex - 1; index >= 0; index -= 1) {
+ String name = mnemonic(instructions.get(index));
+ if (name.equals("CMP")) {
+ compareIndex = index;
+ break;
+ }
+ if (!AARCH64_FLAG_PRESERVING.contains(name)) return null;
+ }
+ if (compareIndex < 0) return null;
+ java.util.regex.Matcher compareMatcher = AARCH64_COMPARE_IMMEDIATE.matcher(operands(instructions.get(compareIndex)));
+ if (!compareMatcher.matches()) return null;
+ String comparedRegister = normalizedRegister(compareMatcher.group(1));
+ boolean comparedFullWidth = compareMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("x");
+ long compareImmediate = parseInteger(compareMatcher.group(2));
+ long entryCountLong = guardMnemonic.equals("B.HI") ? compareImmediate + 1 : compareImmediate;
+ if (entryCountLong <= 0 || entryCountLong > 4096) return null;
+ int entryCount = (int) entryCountLong;
+
+ // The loaded index must carry the compared value: either the compared
+ // register itself, unchanged since CMP, or a MOV copy made after CMP.
+ // A W compare bounds only the low 32 bits, so an X index needs a
+ // zero-extending W copy; a W index is zero-extended by UXTW.
+ int indexWriter = latestWriter(instructions, loadIndex, indexRegister);
+ boolean bounded = false;
+ if (indexWriter < compareIndex) {
+ bounded = indexRegister.equals(comparedRegister) && (wordIndex || comparedFullWidth);
+ }
+ else if (mnemonic(instructions.get(indexWriter)).equals("MOV")) {
+ java.util.regex.Matcher moveMatcher = AARCH64_MOVE_REGISTER.matcher(operands(instructions.get(indexWriter)));
+ if (moveMatcher.matches() &&
+ normalizedRegister(moveMatcher.group(2)).equals(comparedRegister) &&
+ latestWriter(instructions, indexWriter, comparedRegister) < compareIndex) {
+ boolean wordCopy = moveMatcher.group(1).toLowerCase(Locale.ROOT).startsWith("w");
+ bounded = wordCopy || comparedFullWidth;
+ }
+ }
+ if (!bounded) return null;
+
+ // Every fact above holds only on one straight-line fall-through path
+ // with no other entry into the window.
+ int windowStart = Math.min(Math.min(tablePageIndex, baseIndex), compareIndex);
+ for (int index = windowStart + 1; index <= branchIndex; index += 1) {
+ monitor.checkCancelled();
+ Instruction previous = instructions.get(index - 1);
+ Instruction current = instructions.get(index);
+ Address fallThrough = previous.getFallThrough();
+ if (fallThrough == null || !fallThrough.equals(current.getAddress())) return null;
+ ReferenceIterator incoming = currentProgram.getReferenceManager().getReferencesTo(current.getAddress());
+ while (incoming.hasNext()) {
+ if (incoming.next().getReferenceType().isFlow()) return null;
+ }
+ }
+
+ List mappings = new ArrayList<>();
+ Set mappedTargets = new HashSet<>();
+ for (int tableIndex = 0; tableIndex < entryCount; tableIndex += 1) {
+ monitor.checkCancelled();
+ Address entry = tableAddress.add((long) tableIndex * entrySize);
+ if (!currentProgram.getMemory().contains(entry)) return null;
+ long encoded;
+ if (entrySize == 1) {
+ byte value = currentProgram.getMemory().getByte(entry);
+ encoded = signedEntries ? value : value & 0xffL;
+ }
+ else {
+ short value = currentProgram.getMemory().getShort(entry);
+ encoded = signedEntries ? value : value & 0xffffL;
+ }
+ if (encoded == 0 && nullTarget != null) {
+ mappings.add(new RecoveredAArch64Mapping(tableIndex, nullTarget, encoded, true));
+ continue;
+ }
+ Address target;
+ try {
+ target = branchBase.addNoWrap(encoded << offsetShift);
+ }
+ catch (ghidra.program.model.address.AddressOverflowException overflow) {
+ return null;
+ }
+ String canonicalTarget = canonicalAddress(target);
+ if (!recoveredTargetSet.contains(canonicalTarget)) return null;
+ mappedTargets.add(canonicalTarget);
+ mappings.add(new RecoveredAArch64Mapping(tableIndex, target, encoded, false));
+ }
+ if (mappedTargets.contains(canonicalAddress(defaultTarget))) return null;
+ // Ghidra's recovered destinations must be fully accounted for by the
+ // decoded entries, the guard default, and the CBZ null path.
+ Set accountedTargets = new HashSet<>(mappedTargets);
+ accountedTargets.add(canonicalAddress(defaultTarget));
+ if (nullTarget != null) accountedTargets.add(canonicalAddress(nullTarget));
+ // Ghidra does not model the CBZ, so it may decode a zero entry as a
+ // branch to the base itself. Only a zero entry encodes offset 0, and
+ // the CBZ diverts every zero entry, so the BR never reaches the base.
+ Address unreachableZeroTarget = null;
+ boolean divertedZeroEntry = mappings.stream().anyMatch(mapping -> mapping.nullEntry);
+ if (divertedZeroEntry &&
+ recoveredTargetSet.contains(canonicalAddress(branchBase)) &&
+ !mappedTargets.contains(canonicalAddress(branchBase)) &&
+ !branchBase.equals(defaultTarget) &&
+ !branchBase.equals(nullTarget)) {
+ unreachableZeroTarget = branchBase;
+ accountedTargets.add(canonicalAddress(branchBase));
+ }
+ if (!accountedTargets.containsAll(recoveredTargetSet)) return null;
+ int selectorWriter = latestWriter(instructions, compareIndex, comparedRegister);
+ return new RecoveredAArch64RelativeTable(
+ tableAddress,
+ branchBase,
+ entrySize,
+ selectorWriter < 0 ? null : instructions.get(selectorWriter).getAddress(),
+ signedEntries,
+ offsetShift,
+ guardMnemonic,
+ compareImmediate,
+ nullTarget,
+ unreachableZeroTarget,
+ List.copyOf(mappings)
+ );
+ }
+
+ private static String mnemonic(Instruction instruction) {
+ return instruction.getMnemonicString().toUpperCase(Locale.ROOT);
+ }
+
+ /** Index of the last instruction before {@code before} that writes {@code register}, or -1. */
+ private static int latestWriter(List instructions, int before, String register) {
+ for (int index = before - 1; index >= 0; index -= 1) {
+ for (Object result : instructions.get(index).getResultObjects()) {
+ if (result instanceof Register written &&
+ normalizedRegister(written.getBaseRegister().getName()).equals(register)) {
+ return index;
+ }
+ }
+ }
+ return -1;
}
private static String operands(Instruction instruction) {
diff --git a/docs/testing.md b/docs/testing.md
index 300bf473e6..cbb4bf39cd 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -450,15 +450,15 @@ installation (`application.java.min` through `application.java.max`). Current
12.1 releases require JDK 21 or newer and set no maximum. The lanes below still
prove behavior on the verified Ghidra 12.1.4 and JDK 21 build.
-| Ghidra lane | Supported runner/target | Additional local tools |
-| ------------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
-| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
-| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
-| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
-| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
-| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
-| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
-| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
+| Ghidra lane | Supported runner/target | Additional local tools |
+| ------------------------------------------ | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
+| `npm run verify:ghidra` | Linux x64/arm64 ELF or macOS x64/arm64 Mach-O | Host C compiler, Ghidra 12.1.4, and full JDK 21 |
+| `npm run verify:ghidra:seeds` | POSIX host; source-owned 32-bit x86 raw fixture; function order and BSS labels | Ghidra 12.1.4 and full JDK 21 |
+| `npm run verify:ghidra:swift` | macOS x64/arm64 Mach-O | Host Swift compiler, Ghidra 12.1.4, and full JDK 21 |
+| `npm run verify:ghidra:switch` | Linux x64 ELF; GCC/Clang optimized and stripped switch fixtures | GCC, Clang, GNU nm/objdump/strip, Ghidra 12.1.4, and full JDK 21 |
+| `npm run verify:ghidra:aarch64-jump-table` | Any supported Ghidra host; AArch64 ELF; signed/unsigned byte/halfword tables; host ARM64 Mach-O | Clang with AArch64 target support, Ghidra 12.1.4, and full JDK 21 |
+| `npm run verify:ghidra:cross-format` | Any supported Ghidra host; also analyzes AArch64 ELF, x86-64 PE, and x86-64 Mach-O | `clang`, LLD, and `lld-link` in addition to host-lane prerequisites |
+| `npm run verify:ghidra:windows` | Controlled Windows x64 with native x86-64 PE; `-- --x86` selects native x86 PE | Ghidra 12.1.4, full JDK 21, and the matching native artifact |
The optional Swift lane compiles a source-owned fixture and checks modern and
provider-demangled procedure names through real stdio MCP, with and without the
diff --git a/scripts/verify-real-ghidra-function.mjs b/scripts/verify-real-ghidra-function.mjs
index 16bb172b6f..8acfeb1021 100644
--- a/scripts/verify-real-ghidra-function.mjs
+++ b/scripts/verify-real-ghidra-function.mjs
@@ -674,6 +674,113 @@ export async function verifyRelativeSwitch(client, procedures, entrySize) {
};
}
+/**
+ * Oracle for relative-switch-signed.S: signed halfword byte offsets from a base
+ * inside the case bodies, a CBZ route for the zero entry, and a B.CS bound.
+ */
+const SIGNED_RELATIVE_SWITCH_CASES = [
+ { value: 0, immediate: 200 },
+ { value: 1, immediate: 201 },
+ { value: 2, immediate: 202 },
+ { value: 3, immediate: 250 },
+ { value: 4, immediate: 204 },
+];
+
+export async function verifyRelativeSignedSwitch(client, selectorBias = 0) {
+ const names = await inventoryCall(client, "list_names", {
+ document: null,
+ address: null,
+ });
+ const procedure = requireProcedure(names, "rea_relative_switch_signed");
+ const dossier = await functionCall(client, "analyze_function", {
+ procedure: procedure.address,
+ });
+ const table = dossier.native_api?.jump_tables.find((item) =>
+ item.data_sources.some(
+ (source) =>
+ source.entry_size_bytes === 2 &&
+ source.entry_count === SIGNED_RELATIVE_SWITCH_CASES.length,
+ ),
+ );
+ if (table === undefined)
+ throw new Error(
+ `Exact signed halfword relative table unavailable: ${JSON.stringify(dossier.native_api)}`,
+ );
+ const expectedValues = SIGNED_RELATIVE_SWITCH_CASES.map(
+ ({ value }) => value + selectorBias,
+ );
+ if (
+ table.mappings.some(
+ (mapping) =>
+ mapping.case_value !== null &&
+ !expectedValues.includes(mapping.case_value),
+ )
+ )
+ throw new Error(
+ `Signed relative switch emitted an unsupported case label: ${JSON.stringify(table)}`,
+ );
+ if (table.mappings.length !== SIGNED_RELATIVE_SWITCH_CASES.length)
+ throw new Error(
+ `Signed relative switch has unexpected mappings: ${JSON.stringify(table)}`,
+ );
+ if (selectorBias !== 0) {
+ if (
+ !table.limitations.some((limitation) =>
+ limitation.includes("table indices are not proven"),
+ ) ||
+ !table.mappings.some((mapping) => mapping.case_value === null) ||
+ table.mappings.some((mapping) => mapping.case_value === selectorBias + 3)
+ )
+ throw new Error(
+ `Normalized selector must retain unknown case evidence: ${JSON.stringify(table)}`,
+ );
+ }
+ for (const { value: tableIndex, immediate } of SIGNED_RELATIVE_SWITCH_CASES) {
+ const value = tableIndex + selectorBias;
+ // Without a proven relationship between the machine index and the typed
+ // switch selector, the CBZ-only case must stay unknown rather than being
+ // assigned the normalized index or Ghidra's unreachable base destination.
+ if (selectorBias !== 0 && tableIndex === 3) continue;
+ const mapping = table.mappings.find((item) => item.case_value === value);
+ if (
+ mapping === undefined ||
+ mapping.confidence !== "high" ||
+ (selectorBias !== 0 &&
+ !mapping.evidence.some(
+ (evidence) => evidence.source === "ghidra-clang-case-token",
+ ))
+ )
+ throw new Error(
+ `Signed relative switch case ${value} was not verified: ${JSON.stringify(table)}`,
+ );
+ const instruction = await functionCall(
+ client,
+ "inspect_native_instruction",
+ { address: mapping.target_address },
+ );
+ if (
+ instruction.status !== "decoded" ||
+ !instruction.operands.some((operand) =>
+ operand.components.some(
+ (component) =>
+ component.kind === "immediate" &&
+ component.value === `0x${immediate.toString(16)}`,
+ ),
+ )
+ )
+ throw new Error(
+ `Signed relative switch target does not implement source case ${value}: ${JSON.stringify(instruction)}`,
+ );
+ }
+ return {
+ entry_size: 2,
+ signed: true,
+ selector_bias: selectorBias,
+ dispatch_address: table.dispatch_address,
+ mappings: table.mappings.length,
+ };
+}
+
export async function verifyNativeValueTrace(client, procedures, target) {
const entry = requireProcedure(procedures, "rea_ghidra_inventory_entry");
const names = await inventoryCall(client, "list_names", {
diff --git a/scripts/verify-real-ghidra.mjs b/scripts/verify-real-ghidra.mjs
index 2ed9b0013e..8eec5ef031 100644
--- a/scripts/verify-real-ghidra.mjs
+++ b/scripts/verify-real-ghidra.mjs
@@ -38,6 +38,7 @@ import {
verifyJumpTableDenseSwitch,
verifyNativeTypeLayout,
verifyRelativeSwitch,
+ verifyRelativeSignedSwitch,
verifyNativeValueTrace,
} from "./verify-real-ghidra-function.mjs";
const exec = promisify(execFile);
@@ -173,6 +174,36 @@ try {
]);
}
}
+ const signedRelativeSource = fileURLToPath(
+ new URL(
+ "../tests/conformance/ghidra/relative-switch-signed.S",
+ import.meta.url,
+ ),
+ );
+ for (const selectorBias of [0, 2, 10]) {
+ for (const format of [
+ "elf",
+ ...(expectedNativeTarget.architecture === "arm64" ? ["mach-o"] : []),
+ ]) {
+ const targetPath = join(
+ fixtureRoot,
+ `relative-signed-${selectorBias}-${format}.o`,
+ );
+ await exec(clang, [
+ ...(format === "elf" ? ["--target=aarch64-linux-gnu"] : []),
+ `-DREA_SELECTOR_BIAS=${selectorBias}`,
+ "-c",
+ signedRelativeSource,
+ "-o",
+ targetPath,
+ ]);
+ crossTargets.push([
+ targetPath,
+ `relative-signed-${selectorBias}`,
+ { format, architecture: "arm64" },
+ ]);
+ }
+ }
await exec(clang, [
"--target=aarch64-linux-gnu",
@@ -522,25 +553,30 @@ async function verifyTarget(targetPath, variant, expectedTarget = null) {
client,
})
: null;
- const probes = variant.startsWith("relative-")
- ? await verifyRelativeSwitch(
+ const probes = variant.startsWith("relative-signed-")
+ ? await verifyRelativeSignedSwitch(
client,
- procedures,
- Number(variant.slice(-1)),
+ Number(variant.split("-").at(-1)),
)
- : variant === "type-layout"
- ? await verifyNativeTypeLayout(client, names)
- : variant === "jump-table"
- ? await verifyJumpTableDenseSwitch(client, procedures)
- : variant === "custom" || variant === "aarch64-jump-table"
- ? null
- : await verifyInventoryOperations({
- client,
- variant,
- procedures,
- names,
- strings,
- });
+ : variant.startsWith("relative-")
+ ? await verifyRelativeSwitch(
+ client,
+ procedures,
+ Number(variant.slice(-1)),
+ )
+ : variant === "type-layout"
+ ? await verifyNativeTypeLayout(client, names)
+ : variant === "jump-table"
+ ? await verifyJumpTableDenseSwitch(client, procedures)
+ : variant === "custom" || variant === "aarch64-jump-table"
+ ? null
+ : await verifyInventoryOperations({
+ client,
+ variant,
+ procedures,
+ names,
+ strings,
+ });
const nativeValues =
variant === "debug"
? await verifyNativeValueTrace(client, procedures, parsedTarget.value)
diff --git a/tests/conformance/ghidra/README.md b/tests/conformance/ghidra/README.md
index 6247efa8f9..ae9cc7b4c5 100644
--- a/tests/conformance/ghidra/README.md
+++ b/tests/conformance/ghidra/README.md
@@ -39,3 +39,11 @@ bounded, and address-bearing; it never compares their text with Hopper. The
callback fixture also proves that an unresolved targetless flow is not silently
promoted to a direct callee. Every target runs in a separate owned project and
must leave no process, socket, project, or runtime root after close.
+
+`relative-switch-signed.S` covers signed halfword offsets, the CBZ zero-entry
+route, and selector biases of 0, 2 and 10 in the AArch64 jump-table lane. The
+nonzero biases exercise overlapping and disjoint typed label/index ranges. REA
+retains Ghidra's typed labels when the compared selector has an earlier register
+definition; additional labels remain unknown until their relationship to the
+typed switch selector can be proved. The oracle rejects extra numeric labels
+and verifies each retained target against the source instruction immediate.
diff --git a/tests/conformance/ghidra/relative-switch-signed.S b/tests/conformance/ghidra/relative-switch-signed.S
new file mode 100644
index 0000000000..eb0343ce77
--- /dev/null
+++ b/tests/conformance/ghidra/relative-switch-signed.S
@@ -0,0 +1,83 @@
+// Hand-written signed halfword relative dispatch, modelled on the AOSP
+// RenderScript blend kernel shape reported in issue #487:
+// adrp/add table; cmp; b.cs default; ldrsh; cbz null; adr base; add; br.
+// Entries are signed byte offsets from Lsigned_base (no scaling), so cases
+// before the base encode negative offsets. A zero entry means "no case body"
+// and is routed through the CBZ before the branch. The source labels and the
+// immediates below are the oracle; no REA or Ghidra result defines them.
+// case 0 -> 200, 1 -> 201, 2 -> 202, 3 -> null path (250), 4 -> 204,
+// selector >= 5 -> default (-1).
+#ifndef REA_SELECTOR_BIAS
+#define REA_SELECTOR_BIAS 0
+#endif
+// Nonzero variants switch on the original selector (bias .. bias+4),
+// while the guarded table index is selector - bias.
+#if defined(__APPLE__)
+.section __TEXT,__text,regular,pure_instructions
+.globl _rea_relative_switch_signed
+.p2align 2
+_rea_relative_switch_signed:
+#else
+.text
+.globl rea_relative_switch_signed
+.type rea_relative_switch_signed,%function
+.p2align 2
+rea_relative_switch_signed:
+#endif
+#if defined(__APPLE__)
+ adrp x5, Lsigned_table@PAGE
+ add x5, x5, Lsigned_table@PAGEOFF
+#else
+ adrp x5, Lsigned_table
+ add x5, x5, :lo12:Lsigned_table
+#endif
+#if REA_SELECTOR_BIAS != 0
+ sub w8, w0, #REA_SELECTOR_BIAS
+ cmp w8, #5
+ b.cs Lsigned_default
+ ldrsh x6, [x5, w8, uxtw #1]
+#else
+ cmp w0, #5
+ b.cs Lsigned_default
+ ldrsh x6, [x5, w0, uxtw #1]
+#endif
+ cbz x6, Lsigned_null
+ adr x5, Lsigned_base
+ add x6, x5, x6
+ br x6
+Lsigned_case0:
+ mov w0, #200
+ ret
+Lsigned_case1:
+ mov w0, #201
+ // The base sits inside case 1 so that no real case has a zero offset.
+Lsigned_base:
+ ret
+Lsigned_case2:
+ mov w0, #202
+ ret
+Lsigned_case4:
+ mov w0, #204
+ ret
+Lsigned_null:
+ mov w0, #250
+ ret
+Lsigned_default:
+ mov w0, #-1
+ ret
+#if !defined(__APPLE__)
+.size rea_relative_switch_signed, .-rea_relative_switch_signed
+#endif
+
+#if defined(__APPLE__)
+.section __TEXT,__const
+#else
+.section .rodata
+#endif
+.p2align 1
+Lsigned_table:
+ .short Lsigned_case0-Lsigned_base
+ .short Lsigned_case1-Lsigned_base
+ .short Lsigned_case2-Lsigned_base
+ .short 0
+ .short Lsigned_case4-Lsigned_base