diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts
index b4cf2e4ad..dd562d813 100644
--- a/docs/.vitepress/config.ts
+++ b/docs/.vitepress/config.ts
@@ -63,6 +63,7 @@ export default defineConfig({
{ text: "Ghidra DOS analysis", link: "/ghidra-dos" },
{ text: "Ghidra raw binary", link: "/raw-binary" },
{ text: "Ghidra NativeAOT", link: "/ghidra-nativeaot" },
+ { text: "Ghidra PowerPC", link: "/ghidra-ppc" },
{ text: "Ghidra semantics", link: "/ghidra-provider" },
],
},
diff --git a/docs/ghidra-ppc.md b/docs/ghidra-ppc.md
new file mode 100644
index 000000000..034158378
--- /dev/null
+++ b/docs/ghidra-ppc.md
@@ -0,0 +1,90 @@
+# PowerPC ELF analysis through Ghidra
+
+This lane extends native ELF analysis to 32-bit big-endian PowerPC; it does
+not execute PowerPC code. It is the first milestone of
+[#1741](https://github.com/morluto/rea/issues/1741). GameCube/Wii DOL
+executables, PPC64 and other PowerPC variants are separate follow-ups.
+
+## Admission boundary
+
+ELF `EM_PPC` (20) is retained as the provider-neutral `powerpc` family. The
+target also retains class, byte order, `e_type` and raw `e_flags`. Recognition
+does not mean every provider or PowerPC variant is supported. `EM_PPC64` (21)
+is not recognized as this family.
+
+The Ghidra lane admits ELF32 `ET_EXEC`, big endian. ELF64, little-endian,
+relocatable and shared objects are refused with a specific unsupported
+constraint. `EF_PPC_EMB` declares an embedded variant and is refused; the
+remaining `e_flags` bits only declare relocation conventions and do not change
+instruction decoding.
+
+## Bound language
+
+Ghidra's ELF loader selects `PowerPC:BE:32:e500:default` for a plain `EM_PPC`
+header with `e_flags` 0. e500 is the embedded Book-E variant whose SPE unit
+replaces the classic floating-point unit, so it misinterprets ordinary
+750-family code. Nothing in the header distinguishes the two.
+
+The lane therefore imports with `PowerPC:BE:32:default` and compiler spec
+`default`, commits that choice in the analysis profile as
+`language_selection: "provider-pinned-v1"`, and checks the language Ghidra
+reports after import. A different language set through
+`REA_GHIDRA_LANGUAGE_ID` (and optionally `REA_GHIDRA_COMPILER_SPEC_ID`)
+replaces the lane default and is admitted against the installed language
+catalog as usual; for example `PowerPC:BE:32:e500` for genuine e500 code.
+
+PowerPC header facts are committed in the analysis profile so interpretation
+changes invalidate profile-bound snapshots. The support-lane identity is
+versioned independently of file identity. Evidence, lifecycle outputs and saved
+snapshots preserve the `powerpc` family. Hopper and IDA adapters do not
+implicitly gain PowerPC support. Windows Ghidra P0 remains PE x86/x86-64 only.
+
+## Use
+
+With a caller-installed Ghidra and compatible JDK on a supported Linux/macOS host:
+
+```bash
+rea analyze /absolute/path/to/ppc.elf --provider ghidra --json
+rea function /absolute/path/to/ppc.elf function_name --provider ghidra --json
+```
+
+MCP uses the existing tools, including `open_binary` with an absolute path and
+`provider_id: "ghidra"`. No PowerPC-specific tool catalog or emulator is
+introduced.
+
+## Verification
+
+The source-owned freestanding fixture is `tests/conformance/c/ppc.c`. The
+optional cross-target lane requires a caller-supplied Clang with the PowerPC
+target, LLD, GNU `readelf`, Ghidra and its compatible JDK; ordinary host-native
+checks do not acquire these tools. `REA_PPC_CLANG` selects Clang and
+`REA_PPC_READELF` selects GNU readelf (for example
+`/opt/homebrew/opt/binutils/bin/readelf` on macOS). These are test-time
+prerequisites, not REA runtime providers.
+
+```bash
+GHIDRA_INSTALL_DIR=/absolute/path/to/ghidra npm run verify:ghidra:ppc
+```
+
+The lane compiles the fixture for a 750-family core (`-mcpu=750`) and never
+executes it. GNU readelf independently supplies header facts and symbol
+addresses, compared with the production REA target resolver before provider
+startup. Its version, bounded command output and selected tool path are
+retained in the verification report. The parser deliberately recognizes only
+this fixture's GNU output, rejecting missing, ambiguous or unexpected fields
+rather than substituting defaults.
+
+Existing public MCP operations check Ghidra's **observed** language and
+compiler specification and loaded-file hashes; an e500 import fails the lane.
+The source-owned `ppc-probe.S` supplies fixed `addi` and conditional-branch
+encodings: verification checks bytes, length, immediate and direct destination,
+independent of decompiler variable names. Global and string bytes are checked at
+independently reported symbol addresses.
+
+CLI/MCP discovery and decompilation, direct calls, target/profile/Evidence
+identity, snapshot round-trip and final process cleanup remain covered.
+Pseudocode presence is a liveness check, not proof of semantic equivalence.
+Instruction decoding and reader agreement do not establish runtime or complete
+floating-point semantics. Parser/profile tests are narrower checks, not
+substitutes for this real-provider run. Report the actual lanes run and leave
+unexecuted verification pending.
diff --git a/package.json b/package.json
index e09e51366..5ccf53c99 100644
--- a/package.json
+++ b/package.json
@@ -186,6 +186,7 @@
"verify:ghidra": "npm run build:cached && node scripts/verify-real-ghidra.mjs",
"verify:ghidra:seeds": "npm run build:cached && node scripts/verify/ghidra/seeds.mjs",
"verify:ghidra:mips": "npm run build:cached && node scripts/verify-real-ghidra-mips.mjs",
+ "verify:ghidra:ppc": "npm run build:cached && node scripts/verify-real-ghidra-ppc.mjs",
"verify:ghidra:riscv": "npm run build:cached && node scripts/verify-real-ghidra-riscv.mjs",
"verify:ghidra:6502": "npm run build:cached && node scripts/verify-real-ghidra-6502.mjs",
"verify:ghidra:mips-shared": "npm run build:cached && node scripts/verify-real-ghidra-mips-shared.mjs",
diff --git a/scripts/verify-real-ghidra-ppc.mjs b/scripts/verify-real-ghidra-ppc.mjs
new file mode 100644
index 000000000..591d32b2b
--- /dev/null
+++ b/scripts/verify-real-ghidra-ppc.mjs
@@ -0,0 +1,282 @@
+#!/usr/bin/env node
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { mkdtemp, mkdir, readFile, rm } from "node:fs/promises";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+import { Client } from "@modelcontextprotocol/client";
+import { StdioClientTransport } from "@modelcontextprotocol/client/stdio";
+import { parseBinaryTarget } from "../dist/application/BinaryTargetResolver.js";
+import {
+ inspectPowerPcReadelf,
+ assertPowerPcLoadedImage,
+ assertPowerPcProbe,
+ assertPowerPcGlobal,
+} from "../tests/conformance/ghidra/ppc-checks.mjs";
+import { parseEvidence } from "../dist/domain/evidence.js";
+import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
+import { functionDossierSchema } from "../dist/domain/hopperValues.js";
+import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
+import { buildPowerPcFixture } from "../tests/conformance/ghidra/ppc-fixture.mjs";
+import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
+import { createVerifierRun, completeVerifierRun } from "./lib/verifier-run.mjs";
+
+// Optional cross-target lane. No tool acquisition and no target execution.
+if (process.argv.length !== 2)
+ throw new Error("Usage: node scripts/verify-real-ghidra-ppc.mjs");
+if (!["linux", "darwin"].includes(process.platform))
+ throw new Error(
+ "PowerPC verification requires a supported Linux/macOS Ghidra host",
+ );
+if (process.env.GHIDRA_INSTALL_DIR === undefined)
+ throw new Error("Missing prerequisite: caller-supplied GHIDRA_INSTALL_DIR");
+const installation = inspectGhidraInstallation({
+ environment: process.env,
+ installDir: process.env.GHIDRA_INSTALL_DIR,
+ ...(process.env.JAVA_HOME === undefined
+ ? {}
+ : { javaHome: process.env.JAVA_HOME }),
+});
+if (installation.status !== "available")
+ throw new Error(
+ `Ghidra prerequisite unavailable: ${JSON.stringify(installation)}`,
+ );
+const execute = promisify(execFile);
+const entrypoint = fileURLToPath(new URL("./rea.mjs", import.meta.url));
+const run = createVerifierRun();
+const workspace = await mkdtemp(join(tmpdir(), "rea-ppc-proof-"));
+const runtime = join(workspace, "runtime");
+await mkdir(runtime);
+const env = {
+ PATH: process.env.PATH ?? "/usr/bin:/bin",
+ TMPDIR: runtime,
+ REA_LOG_LEVEL: "silent",
+ REA_ANALYSIS_PROVIDER: "ghidra",
+ REA_PROCESS_RUN_ID: run.run_id,
+ GHIDRA_INSTALL_DIR: installation.installDir,
+ JAVA_HOME: installation.javaHome,
+ HOPPER_LAUNCHER_PATH: "/rea-unconfigured-deep-provider/hopper",
+};
+for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
+ if (process.env[setting] !== undefined) env[setting] = process.env[setting];
+}
+const reports = [];
+let primaryFailure;
+try {
+ {
+ const fixture = await buildPowerPcFixture(workspace);
+ const independent = await inspectPowerPcReadelf(fixture.path);
+ const resolved = await parseBinaryTarget(fixture.path);
+ if (!resolved.ok) throw resolved.error;
+ assert.deepEqual(
+ resolved.value.powerpc,
+ independent.powerpc,
+ "REA ELF interpretation disagrees with GNU readelf",
+ );
+ assert.equal(resolved.value.sha256, fixture.sha256);
+ const snapshotPath = join(workspace, "ppc.snapshot.json");
+ const transport = new StdioClientTransport({
+ command: process.execPath,
+ args: [entrypoint, "mcp"],
+ env,
+ stderr: "pipe",
+ });
+ const client = new Client({ name: "rea-real-ppc-proof", version: "1" });
+ let stderr = "";
+ transport.stderr?.on("data", (chunk) => {
+ stderr = (stderr + chunk.toString()).slice(-65536);
+ });
+ const call = async (name, args = {}) => {
+ const reply = await client.callTool(
+ { name, arguments: args },
+ undefined,
+ { timeout: 180000 },
+ );
+ return requireMcpOperationResult(reply, name);
+ };
+ try {
+ await client.connect(transport);
+ const target = await call("open_binary", {
+ path: fixture.path,
+ provider_id: "ghidra",
+ });
+ assert.equal(target.architecture, "powerpc");
+ assert.equal(target.sha256, fixture.sha256);
+ // ELF load-image verification is not the DOS comparison lane. Its retained
+ // observations still expose the actual Ghidra language and loaded bytes;
+ // the plain loader would have chosen e500 for this header.
+ const image = await call("inspect_native_load_image");
+ assertPowerPcLoadedImage(image.observations, fixture.sha256);
+ const procedures = await call("list_procedures");
+ const entry = procedures.find((p) => p.value === "rea_ppc_entry");
+ const leaf = procedures.find((p) => p.value === "rea_ppc_leaf");
+ assert.ok(entry, "Entry function missing from real Ghidra inventory");
+ assert.ok(leaf, "Leaf function missing from real Ghidra inventory");
+ assert.equal(
+ BigInt(entry.address),
+ BigInt(independent.symbols.rea_ppc_entry),
+ );
+ assert.equal(
+ BigInt(leaf.address),
+ BigInt(independent.symbols.rea_ppc_leaf),
+ );
+ const probe = independent.symbols.rea_ppc_probe;
+ assert.ok(
+ procedures.some(
+ (p) =>
+ p.value === "rea_ppc_probe" && BigInt(p.address) === BigInt(probe),
+ ),
+ );
+ const move = await call("inspect_native_instruction", { address: probe });
+ const branch = await call("inspect_native_instruction", {
+ address: `0x${(BigInt(probe) + 8n).toString(16)}`,
+ });
+ assertPowerPcProbe(move, branch, probe);
+ const global = await call("read_bytes", {
+ address: independent.symbols.rea_ppc_global,
+ length: 4,
+ });
+ assert.equal(global.returned_bytes, 4);
+ assertPowerPcGlobal(global.bytes_hex);
+ const marker = await call("read_bytes", {
+ address: independent.symbols.rea_ppc_marker,
+ length: 29,
+ });
+ assert.equal(
+ marker.bytes_hex,
+ Buffer.from("rea-ppc-source-owned-fixture\0").toString("hex"),
+ );
+ const callees = await call("procedure_callees", {
+ procedure: entry.address,
+ });
+ assert.ok(
+ callees.includes(leaf.address),
+ "Expected direct call not recovered",
+ );
+ const assembly = await call("procedure_assembly", {
+ procedure: entry.address,
+ });
+ assert.ok(assembly.trim().length > 0);
+ const dossier = functionDossierSchema.parse(
+ await call("analyze_function", { procedure: entry.address }),
+ );
+ assert.ok(dossier.pseudocode.trim().length > 0);
+ const mapping = await call("address_to_file_offset", {
+ address: entry.address,
+ });
+ const memory = await call("read_bytes", {
+ address: entry.address,
+ length: 16,
+ });
+ const sourceBytes = await readFile(fixture.path);
+ assert.equal(memory.returned_bytes, 16);
+ assert.equal(
+ memory.bytes_hex,
+ sourceBytes
+ .subarray(mapping.file_offset, mapping.file_offset + 16)
+ .toString("hex"),
+ );
+ const saved = await call("close_binary", {
+ snapshot_path: snapshotPath,
+ });
+ assert.ok(saved.evidence_records > 0);
+ const snapshot = parseAnalysisSnapshot(
+ JSON.parse(await readFile(snapshotPath, "utf8")),
+ );
+ assert.equal(snapshot.target.architecture, "powerpc");
+ assert.equal(
+ snapshot.binding.analysis_profile.parameters.language_id,
+ "PowerPC:BE:32:default",
+ );
+ assert.ok(snapshot.evidence_bundle.records.length > 0);
+ for (const evidence of snapshot.evidence_bundle.records)
+ assert.equal(parseEvidence(evidence).subject?.architecture, "powerpc");
+ await call("open_binary", {
+ path: fixture.path,
+ provider_id: "ghidra",
+ snapshot_path: snapshotPath,
+ });
+ assert.deepEqual(await call("list_procedures"), procedures);
+ await call("close_binary");
+
+ // Same public operation through CLI, not a private injected provider.
+ const cli = await execute(
+ process.execPath,
+ [
+ entrypoint,
+ "function",
+ fixture.path,
+ "rea_ppc_entry",
+ "--provider",
+ "ghidra",
+ "--json",
+ ],
+ { env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
+ );
+ const evidence = parseEvidence(JSON.parse(cli.stdout));
+ assert.equal(evidence.subject?.architecture, "powerpc");
+ assert.equal(evidence.subject?.digest.sha256, fixture.sha256);
+ const cliDossier = functionDossierSchema.parse(
+ evidence.normalized_result,
+ );
+ assert.equal(cliDossier.procedure.address, dossier.procedure.address);
+ assert.ok(cliDossier.pseudocode.trim().length > 0);
+ assert.equal(
+ createHash("sha256")
+ .update(await readFile(fixture.path))
+ .digest("hex"),
+ fixture.sha256,
+ "Analysis modified the source executable",
+ );
+ reports.push({
+ ...fixture,
+ independent_reader: independent,
+ effective_language: image.observations.language_id,
+ effective_compiler_spec: image.observations.compiler_spec_id,
+ checks: [
+ "independent ELF header and symbols",
+ "loaded Ghidra identity and bound language",
+ "fixed instruction bytes and immediate",
+ "conditional branch destination",
+ "global word and marker bytes",
+ "CLI/MCP and snapshot identity",
+ ],
+ procedures: procedures.length,
+ status: "passed",
+ });
+ } catch (cause) {
+ throw new Error(`PowerPC verification failed; MCP stderr: ${stderr}`, {
+ cause,
+ });
+ } finally {
+ try {
+ await client.close();
+ } finally {
+ await transport.close();
+ }
+ }
+ }
+} catch (cause) {
+ primaryFailure = cause;
+}
+const completed = await completeVerifierRun(run);
+if (
+ completed.process_lineage.status !== "verified" ||
+ completed.process_lineage.descendants.length !== 0
+)
+ throw new Error(
+ `Cleanup unverified; retained diagnostic workspace: ${workspace}`,
+ { cause: primaryFailure },
+ );
+if (primaryFailure !== undefined)
+ throw new Error(
+ `PowerPC verification failed; retained workspace: ${workspace}`,
+ {
+ cause: primaryFailure,
+ },
+ );
+await rm(workspace, { recursive: true, force: true });
+console.log(JSON.stringify({ ...completed, fixtures: reports }, null, 2));
diff --git a/src/domain/binaryTarget.powerpc.test.ts b/src/domain/binaryTarget.powerpc.test.ts
new file mode 100644
index 000000000..f32b4f367
--- /dev/null
+++ b/src/domain/binaryTarget.powerpc.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { parseExecutableHeader } from "./binaryTarget.js";
+
+const header = (little: boolean, bits: 32 | 64 = 32, machine = 20): Buffer => {
+ const bytes = Buffer.alloc(bits === 32 ? 52 : 64);
+ bytes.set([0x7f, 0x45, 0x4c, 0x46, bits === 32 ? 1 : 2, little ? 1 : 2, 1]);
+ if (little) {
+ bytes.writeUInt16LE(2, 16);
+ bytes.writeUInt16LE(machine, 18);
+ bytes.writeUInt32LE(1, 20);
+ bytes.writeUInt16LE(bytes.length, bits === 32 ? 40 : 52);
+ } else {
+ bytes.writeUInt16BE(2, 16);
+ bytes.writeUInt16BE(machine, 18);
+ bytes.writeUInt32BE(1, 20);
+ bytes.writeUInt16BE(bytes.length, bits === 32 ? 40 : 52);
+ }
+ return bytes;
+};
+
+describe("PowerPC ELF header boundaries", () => {
+ it.each([false, true])(
+ "retains source endian and flags (little=%s)",
+ (little) => {
+ const parsed = parseExecutableHeader(header(little), "x64");
+ if (!parsed.ok) throw new Error(parsed.error);
+ expect(parsed.value).toEqual({
+ format: "elf",
+ architecture: "powerpc",
+ availableArchitectures: ["powerpc"],
+ powerpc: {
+ elfClass: 32,
+ byteOrder: little ? "little" : "big",
+ type: 2,
+ flags: 0,
+ },
+ });
+ },
+ );
+
+ it("reads ELF64 flags at their own offset without claiming support", () => {
+ const bytes = header(false, 64);
+ bytes.writeUInt32BE(0x80000000, 48);
+ const parsed = parseExecutableHeader(bytes, "x64");
+ if (!parsed.ok) throw new Error(parsed.error);
+ expect(parsed.value).toMatchObject({
+ architecture: "powerpc",
+ powerpc: { elfClass: 64, byteOrder: "big", flags: 0x80000000 },
+ });
+ });
+
+ it("does not recognize EM_PPC64 as the 32-bit family", () => {
+ expect(parseExecutableHeader(header(false, 64, 21), "x64")).toEqual({
+ ok: false,
+ error: "unsupported ELF architecture",
+ });
+ });
+});
diff --git a/src/domain/binaryTarget.ts b/src/domain/binaryTarget.ts
index 4e668e460..54798e1d1 100644
--- a/src/domain/binaryTarget.ts
+++ b/src/domain/binaryTarget.ts
@@ -5,6 +5,7 @@ import type {
MipsElfMetadata,
PeCodeViewRecord,
RiscvElfMetadata,
+ PowerPcElfMetadata,
UniversalMachOSlice,
} from "./binaryTargetTypes.js";
import { err, ok, type Result } from "./result.js";
@@ -30,6 +31,12 @@ export type ExecutableMetadata =
readonly availableArchitectures: readonly "riscv"[];
readonly riscv: RiscvElfMetadata;
}
+ | {
+ readonly format: "elf";
+ readonly architecture: "powerpc";
+ readonly availableArchitectures: readonly "powerpc"[];
+ readonly powerpc: PowerPcElfMetadata;
+ }
| {
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly architecture: HeaderArchitecture;
@@ -201,7 +208,11 @@ const parseElf = (
const machine = little ? bytes.readUInt16LE(18) : bytes.readUInt16BE(18);
const architecture = elfArchitecture(machine);
if (architecture === undefined) return err("unsupported ELF architecture");
- if (architecture === "mips" || architecture === "riscv") {
+ if (
+ architecture === "mips" ||
+ architecture === "riscv" ||
+ architecture === "powerpc"
+ ) {
const flagsOffset = elfClass === 32 ? 36 : 48;
const metadata: ElfFamilyMetadata = {
elfClass,
@@ -211,6 +222,13 @@ const parseElf = (
? bytes.readUInt32LE(flagsOffset)
: bytes.readUInt32BE(flagsOffset),
};
+ if (architecture === "powerpc")
+ return ok({
+ format: "elf",
+ architecture,
+ availableArchitectures: [architecture],
+ powerpc: metadata,
+ });
return architecture === "mips"
? ok({
format: "elf",
@@ -532,6 +550,8 @@ const elfArchitecture = (
return "x86_64";
case 8: // EM_MIPS: ISA, ABI and byte order remain separate ELF facts.
return "mips";
+ case 20: // EM_PPC; byte order and processor variant remain ELF declarations.
+ return "powerpc";
case 40:
return "arm";
case 183:
diff --git a/src/domain/binaryTargetTypes.ts b/src/domain/binaryTargetTypes.ts
index 8c16612bc..2cd6ae207 100644
--- a/src/domain/binaryTargetTypes.ts
+++ b/src/domain/binaryTargetTypes.ts
@@ -9,6 +9,7 @@ export const BINARY_ARCHITECTURES = [
"mips",
"6502",
"riscv",
+ "powerpc",
] as const;
export type BinaryArchitecture = (typeof BINARY_ARCHITECTURES)[number];
@@ -36,6 +37,7 @@ export interface PeCodeViewRecord {
export const ELF_METADATA_ARCHITECTURES = [
"mips",
"riscv",
+ "powerpc",
] as const satisfies readonly BinaryArchitecture[];
export type ElfMetadataArchitecture =
(typeof ELF_METADATA_ARCHITECTURES)[number];
@@ -90,6 +92,9 @@ export interface MipsAbiFlags {
*/
export type RiscvElfMetadata = ElfFamilyMetadata;
+/** Raw PowerPC declarations; family identity does not select a processor variant. */
+export type PowerPcElfMetadata = ElfFamilyMetadata;
+
/** Byte range of the selected slice inside its universal Mach-O container. */
export interface UniversalMachOSlice {
readonly offset: number;
@@ -140,6 +145,15 @@ export type BinaryTarget =
readonly executableRole?: never;
readonly managed?: never;
})
+ | (BinaryTargetIdentity & {
+ readonly kind: "executable";
+ readonly format: "elf";
+ readonly architecture: "powerpc";
+ readonly availableArchitectures: readonly "powerpc"[];
+ readonly powerpc: PowerPcElfMetadata;
+ readonly executableRole?: never;
+ readonly managed?: never;
+ })
| (ExecutableTarget & {
readonly format: "mach-o" | "elf" | "dos-mz" | "dos-com";
readonly universalSlice?: never;
diff --git a/src/ghidra/GhidraAnalysisProfile.ts b/src/ghidra/GhidraAnalysisProfile.ts
index d4bd5a433..8175b776b 100644
--- a/src/ghidra/GhidraAnalysisProfile.ts
+++ b/src/ghidra/GhidraAnalysisProfile.ts
@@ -21,6 +21,10 @@ import {
ghidraRiscvLanguageOverride,
ghidraRiscvProfileParameters,
} from "./GhidraRiscvProfile.js";
+import {
+ ghidraPowerPcLanguage,
+ ghidraPowerPcProfileParameters,
+} from "./GhidraPowerPcProfile.js";
import { ghidraRawBinaryLanguage } from "./GhidraRawBinaryLanguage.js";
import {
@@ -44,7 +48,9 @@ import {
export const ghidraPinnedLanguage = (
target: BinaryTarget,
): GhidraLanguageOverride | undefined =>
- ghidraMipsLanguageOverride(target) ?? ghidraRiscvLanguageOverride(target);
+ ghidraMipsLanguageOverride(target) ??
+ ghidraRiscvLanguageOverride(target) ??
+ ghidraPowerPcLanguage(target);
/** Resolve version-bound, deterministic semantics before Ghidra imports a target. */
export const resolveGhidraAnalysisProfile = async (
@@ -155,6 +161,7 @@ export const resolveGhidraAnalysisProfile = async (
const pinnedLanguage = ghidraPinnedLanguage(target);
if (
pinnedLanguage !== undefined &&
+ target.architecture !== "powerpc" &&
languageOverride !== undefined &&
(languageOverride.languageId !== pinnedLanguage.languageId ||
(languageOverride.compilerSpecId !== undefined &&
@@ -277,6 +284,7 @@ export const resolveGhidraAnalysisProfile = async (
...analyzerParameters,
...ghidraMipsProfileParameters(target, languageOverride),
...ghidraRiscvProfileParameters(target),
+ ...ghidraPowerPcProfileParameters(target),
...(pspExtension?.ok === true
? {
mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2",
diff --git a/src/ghidra/GhidraPowerPcProfile.test.ts b/src/ghidra/GhidraPowerPcProfile.test.ts
new file mode 100644
index 000000000..831a47f3b
--- /dev/null
+++ b/src/ghidra/GhidraPowerPcProfile.test.ts
@@ -0,0 +1,166 @@
+import { mkdir, writeFile } from "node:fs/promises";
+import { join } from "node:path";
+import { describe, expect, it } from "vitest";
+import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
+import { parseConfig } from "../config/parseConfig.js";
+import type {
+ BinaryTarget,
+ PowerPcElfMetadata,
+} from "../domain/binaryTargetTypes.js";
+import { silentLogger } from "../logger.js";
+import { HopperProvider } from "../hopper/HopperProvider.js";
+import { hopperLoaderArgsForTarget } from "../hopper/HopperAnalysisProfile.js";
+import { IdaProvider } from "../ida/IdaProvider.js";
+import {
+ ghidraPinnedLanguage,
+ ghidraProfileLanguageOverride,
+} from "./GhidraAnalysisProfile.js";
+import { GhidraProvider } from "./GhidraProvider.js";
+import type { GhidraInstallationHost } from "./GhidraInstallation.js";
+
+// Abbreviated from Ghidra 12.1.4 Processors/PowerPC/data/languages/ppc.ldefs.
+const PPC_LDEFS = `
+
+
+
+
+
+
+
+
+`;
+
+// Fake installation inspection tests admission/profile contracts, not Ghidra.
+const host: GhidraInstallationHost = {
+ platform: "linux",
+ architecture: "x64",
+ readText: () => "application.version=12.1.4\n",
+ executable: () => true,
+ probeJava: () => ({
+ version: "21.0.11",
+ major: 21,
+ home: "/jdk-21",
+ bits: 64,
+ runtime: "jdk",
+ }),
+};
+const configFor = (environment: NodeJS.ProcessEnv) => {
+ const parsed = parseConfig({ GHIDRA_INSTALL_DIR: "/ghidra", ...environment });
+ if (!parsed.ok) throw parsed.error;
+ return parsed.value;
+};
+const config = configFor({});
+const target = (change: Partial = {}): BinaryTarget => ({
+ path: "/fixture.elf",
+ sha256: "a".repeat(64),
+ kind: "executable",
+ format: "elf",
+ architecture: "powerpc",
+ availableArchitectures: ["powerpc"],
+ powerpc: { elfClass: 32, byteOrder: "big", type: 2, flags: 0, ...change },
+});
+
+describe("bounded Ghidra PowerPC admission", () => {
+ it("binds the generic language instead of the loader's e500 choice", async () => {
+ const ghidra = new GhidraProvider(config, silentLogger, {}, host);
+ expect(ghidra.inspectTargetSupport(target()).status).toBe("supported");
+ const resolved = await ghidra.resolveAnalysisProfile(target());
+ if (!resolved.ok) throw resolved.error;
+ const parameters = resolved.value.profile?.parameters ?? {};
+ expect(parameters).toMatchObject({
+ architecture: "powerpc",
+ language_id: "PowerPC:BE:32:default",
+ compiler_spec_id: "default",
+ language_selection: "provider-pinned-v1",
+ powerpc_elf: { elf_class: 32, byte_order: "big", type: 2, flags: 0 },
+ powerpc_support_lane: "elf32-be-exec-generic-v1",
+ });
+ expect(ghidraPinnedLanguage(target())).toEqual({
+ languageId: "PowerPC:BE:32:default",
+ compilerSpecId: "default",
+ });
+ expect(ghidraProfileLanguageOverride(parameters)).toEqual(
+ ghidraPinnedLanguage(target()),
+ );
+ });
+
+ it("lets a configured language replace the lane default", async () => {
+ const installDir = await createTestTempDirectory("rea-ghidra-ppc-");
+ const languages = join(
+ installDir,
+ "Ghidra/Processors/PowerPC/data/languages",
+ );
+ await mkdir(languages, { recursive: true });
+ await writeFile(join(languages, "ppc.ldefs"), PPC_LDEFS);
+ const ghidra = new GhidraProvider(
+ configFor({
+ GHIDRA_INSTALL_DIR: installDir,
+ REA_GHIDRA_LANGUAGE_ID: "PowerPC:BE:32:e500",
+ }),
+ silentLogger,
+ {},
+ host,
+ );
+ const resolved = await ghidra.resolveAnalysisProfile(target());
+ if (!resolved.ok) throw resolved.error;
+ const parameters = resolved.value.profile?.parameters ?? {};
+ expect(parameters).toMatchObject({
+ language_id: "PowerPC:BE:32:e500",
+ language_selection: "configured-v1",
+ });
+ expect(ghidraProfileLanguageOverride(parameters)).toEqual({
+ languageId: "PowerPC:BE:32:e500",
+ });
+ });
+
+ it("commits header flags so a changed interpretation changes the profile", async () => {
+ const ghidra = new GhidraProvider(config, silentLogger, {}, host);
+ const left = await ghidra.resolveAnalysisProfile(target());
+ const right = await ghidra.resolveAnalysisProfile(
+ target({ flags: 0x00010000 }),
+ );
+ if (!left.ok) throw left.error;
+ if (!right.ok) throw right.error;
+ expect(left.value.profile?.digest).not.toBe(right.value.profile?.digest);
+ });
+
+ it("does not widen Windows P0", () => {
+ const windows = new GhidraProvider(
+ config,
+ silentLogger,
+ {},
+ { ...host, platform: "win32" },
+ );
+ expect(windows.inspectTargetSupport(target()).status).toBe("unsupported");
+ });
+
+ it.each([
+ [{ elfClass: 64 }, "ELF32"],
+ [{ byteOrder: "little" }, "big-endian"],
+ [{ type: 1 }, "ET_EXEC"],
+ [{ type: 3 }, "ET_EXEC"],
+ [{ flags: 0x80000000 }, "EF_PPC_EMB"],
+ ] satisfies [Partial, string][])(
+ "refuses an unverified PowerPC interpretation %j",
+ async (change, reason) => {
+ const ghidra = new GhidraProvider(config, silentLogger, {}, host);
+ const value = target(change);
+ expect(ghidra.inspectTargetSupport(value)).toMatchObject({
+ status: "unsupported",
+ reason: expect.stringContaining(reason),
+ });
+ const resolved = await ghidra.resolveAnalysisProfile(value);
+ expect(resolved.ok).toBe(false);
+ },
+ );
+
+ it("keeps PowerPC out of unverified Hopper and IDA adapter lanes", () => {
+ const hopper = new HopperProvider(config, silentLogger, {}, "linux");
+ expect(hopper.inspectTargetSupport(target()).status).toBe("unsupported");
+ expect(hopperLoaderArgsForTarget(target()).ok).toBe(false);
+ const ida = new IdaProvider(config, {});
+ expect(ida.inspectTargetSupport(target()).status).toBe("unsupported");
+ });
+});
diff --git a/src/ghidra/GhidraPowerPcProfile.ts b/src/ghidra/GhidraPowerPcProfile.ts
new file mode 100644
index 000000000..c1655f252
--- /dev/null
+++ b/src/ghidra/GhidraPowerPcProfile.ts
@@ -0,0 +1,67 @@
+import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
+import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
+import type { JsonValue } from "../domain/jsonValue.js";
+
+/**
+ * Generic 32-bit big-endian PowerPC. Ghidra's ELF loader selects the e500
+ * (Book-E/SPE) variant for a plain EM_PPC header, so the lane binds this
+ * language instead of accepting the loader's choice.
+ */
+export const GHIDRA_POWERPC_LANGUAGE: GhidraLanguageOverride = {
+ languageId: "PowerPC:BE:32:default",
+ compilerSpecId: "default",
+};
+
+/** Keep family recognition separate from the deliberately bounded Ghidra lane. */
+export const ghidraPowerPcUnsupportedReason = (
+ target: BinaryTarget,
+): string | null => {
+ if (target.kind !== "executable" || target.architecture !== "powerpc")
+ return null;
+ const metadata = target.powerpc;
+ if (target.format !== "elf" || metadata === undefined)
+ return "PowerPC analysis requires ELF header metadata; a family name alone does not select a processor variant.";
+ if (metadata.elfClass !== 32)
+ return "This Ghidra PowerPC lane supports ELF32 only; PPC64 remains unsupported.";
+ if (metadata.byteOrder !== "big")
+ return "This Ghidra PowerPC lane supports big-endian ELF32 only; little-endian PowerPC needs separate verification.";
+ if (metadata.type !== 2)
+ return "This Ghidra PowerPC lane supports ET_EXEC only; relocatable objects and shared images need separate load verification.";
+ // EF_PPC_EMB (0x80000000) marks embedded variants; other bits only declare
+ // relocation conventions that do not change instruction decoding.
+ if ((metadata.flags & 0x80000000) !== 0)
+ return "EF_PPC_EMB declares an embedded PowerPC variant that needs a separately supported processor profile.";
+ return null;
+};
+
+/**
+ * The language the lane imports with, unless REA_GHIDRA_LANGUAGE_ID already
+ * selected one through the committed profile.
+ */
+export const ghidraPowerPcLanguage = (
+ target: BinaryTarget,
+): GhidraLanguageOverride | undefined =>
+ target.kind === "executable" && target.architecture === "powerpc"
+ ? GHIDRA_POWERPC_LANGUAGE
+ : undefined;
+
+/** Commit source ELF interpretation without confusing target ISA with host CPU. */
+export const ghidraPowerPcProfileParameters = (
+ target: BinaryTarget,
+): Readonly> => {
+ if (
+ target.kind !== "executable" ||
+ target.architecture !== "powerpc" ||
+ target.powerpc === undefined
+ )
+ return {};
+ return {
+ powerpc_elf: {
+ elf_class: target.powerpc.elfClass,
+ byte_order: target.powerpc.byteOrder,
+ type: target.powerpc.type,
+ flags: target.powerpc.flags,
+ },
+ powerpc_support_lane: "elf32-be-exec-generic-v1",
+ };
+};
diff --git a/src/ghidra/GhidraProvider.ts b/src/ghidra/GhidraProvider.ts
index 0a1cde0f0..b0ee8787d 100644
--- a/src/ghidra/GhidraProvider.ts
+++ b/src/ghidra/GhidraProvider.ts
@@ -68,6 +68,7 @@ const SUPPORTED_ARCHITECTURES = new Set([
"mips",
"6502",
"riscv",
+ "powerpc",
]);
/** Ghidra candidate backed by an isolated ephemeral headless import. */
diff --git a/src/ghidra/GhidraPspProfile.ts b/src/ghidra/GhidraPspProfile.ts
index 17260d038..3550d62ab 100644
--- a/src/ghidra/GhidraPspProfile.ts
+++ b/src/ghidra/GhidraPspProfile.ts
@@ -1,5 +1,6 @@
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js";
+import { ghidraPowerPcUnsupportedReason } from "./GhidraPowerPcProfile.js";
import { ghidraRiscvUnsupportedReason } from "./GhidraRiscvProfile.js";
/** Allegrex's ELF machine declaration, not a filename or inferred instruction. */
@@ -14,6 +15,8 @@ export const isGhidraPspTarget = (target: BinaryTarget): boolean =>
export const ghidraProcessorUnsupportedReason = (
target: BinaryTarget,
): string | null => {
+ if (target.kind === "executable" && target.architecture === "powerpc")
+ return ghidraPowerPcUnsupportedReason(target);
if (target.kind === "executable" && target.architecture === "riscv")
return ghidraRiscvUnsupportedReason(target);
if (
diff --git a/src/hopper/HopperAnalysisProfile.ts b/src/hopper/HopperAnalysisProfile.ts
index a4848533f..79ba3ab50 100644
--- a/src/hopper/HopperAnalysisProfile.ts
+++ b/src/hopper/HopperAnalysisProfile.ts
@@ -110,6 +110,7 @@ const HOPPER_DECLINED_ARCHITECTURE_LABELS: Record<
> = {
mips: "MIPS",
riscv: "RISC-V",
+ powerpc: "PowerPC",
};
/** Derive Hopper's complete non-interactive CLI loader selection. */
diff --git a/src/hopper/HopperProvider.ts b/src/hopper/HopperProvider.ts
index aec8c2082..f9826575b 100644
--- a/src/hopper/HopperProvider.ts
+++ b/src/hopper/HopperProvider.ts
@@ -126,7 +126,11 @@ export class HopperProvider implements AnalysisProviderCandidate {
status: "unsupported",
code: "architecture_unsupported",
reason: `REA's Hopper adapter does not admit ${
- target.architecture === "mips" ? "MIPS" : "RISC-V"
+ target.architecture === "mips"
+ ? "MIPS"
+ : target.architecture === "riscv"
+ ? "RISC-V"
+ : "PowerPC"
} targets.`,
diagnostics,
};
diff --git a/src/ida/IdaProvider.ts b/src/ida/IdaProvider.ts
index 09011caa5..5ce924fcd 100644
--- a/src/ida/IdaProvider.ts
+++ b/src/ida/IdaProvider.ts
@@ -113,7 +113,11 @@ export class IdaProvider implements AnalysisProviderCandidate {
status: "unsupported",
code: "architecture_unsupported",
reason: `REA's IDA adapter has no verified ${
- target.architecture === "mips" ? "MIPS" : "RISC-V"
+ target.architecture === "mips"
+ ? "MIPS"
+ : target.architecture === "riscv"
+ ? "RISC-V"
+ : "PowerPC"
} lane. Select a supported Ghidra profile instead.`,
diagnostics,
};
diff --git a/tests/composition/analysis-sessions/powerpc-identity.test.ts b/tests/composition/analysis-sessions/powerpc-identity.test.ts
new file mode 100644
index 000000000..3dd1d374a
--- /dev/null
+++ b/tests/composition/analysis-sessions/powerpc-identity.test.ts
@@ -0,0 +1,93 @@
+import { describe, expect, it } from "vitest";
+import type { BinaryTarget } from "../../../src/domain/binaryTargetTypes.js";
+import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
+import { createEvidence, parseEvidence } from "../../../src/domain/evidence.js";
+import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
+import {
+ createAnalysisSnapshotEntry,
+ parseAnalysisSnapshot,
+ serializeAnalysisSnapshot,
+ snapshotBinding,
+ snapshotTarget,
+ snapshotMatchesProfile,
+} from "../../../src/domain/analysisSnapshot.js";
+import { sessionOutputSchemas } from "../../../src/contracts/toolOutputSchemaGroups.js";
+
+const target: BinaryTarget = {
+ path: "/source-owned.elf",
+ sha256: "a".repeat(64),
+ kind: "executable",
+ format: "elf",
+ architecture: "powerpc",
+ availableArchitectures: ["powerpc"],
+ powerpc: { elfClass: 32, byteOrder: "big", type: 2, flags: 0 },
+};
+const provider = { id: "ghidra", name: "Ghidra", version: "12.1.4" };
+const profile = createAnalysisProfile(provider, {
+ powerpc_elf: { elf_class: 32, byte_order: "big", type: 2, flags: 0 },
+});
+
+describe("PowerPC identity through lifecycle, Evidence and snapshot consumers", () => {
+ it("round-trips query bindings without relaxing integrity", () => {
+ const result = [{ address: "0x100101b0", value: "rea_ppc_entry" }];
+ const parameters = {};
+ const evidence = createEvidence(target, provider, {
+ operation: "list_procedures",
+ parameters,
+ result,
+ analysisProfile: profile,
+ });
+ const binding = snapshotBinding(profile);
+ const entry = createAnalysisSnapshotEntry({
+ target: snapshotTarget(target),
+ binding,
+ operation: "list_procedures",
+ parameters,
+ execution: {
+ result,
+ rawResult: null,
+ provider,
+ analysisProfile: profile,
+ limitations: [],
+ locations: [],
+ subject: target,
+ },
+ });
+ const snapshot = {
+ target: snapshotTarget(target),
+ binding,
+ entries: [entry],
+ workflow_entries: [],
+ evidence_bundle: createEvidenceBundle([evidence]),
+ };
+ const restored = parseAnalysisSnapshot(
+ JSON.parse(serializeAnalysisSnapshot(snapshot)),
+ );
+ expect(restored.target.architecture).toBe("powerpc");
+ expect(restored.evidence_bundle.records[0]?.subject?.architecture).toBe(
+ "powerpc",
+ );
+ expect(restored.entries[0]?.execution.subject?.architecture).toBe(
+ "powerpc",
+ );
+ expect(
+ sessionOutputSchemas.open_binary.parse({ result: target }).result
+ .architecture,
+ ).toBe("powerpc");
+ expect(() =>
+ parseEvidence({
+ ...evidence,
+ subject: { ...evidence.subject, architecture: "x86" },
+ }),
+ ).toThrow("semantic identifier");
+ const changed = createAnalysisProfile(provider, {
+ powerpc_elf: {
+ elf_class: 32,
+ byte_order: "big",
+ type: 2,
+ flags: 0x00010000,
+ },
+ });
+ expect(snapshotMatchesProfile(binding, changed)).toBe(false);
+ });
+});
diff --git a/tests/conformance/c/ppc.c b/tests/conformance/c/ppc.c
new file mode 100644
index 000000000..3f62ca57e
--- /dev/null
+++ b/tests/conformance/c/ppc.c
@@ -0,0 +1,14 @@
+/* Freestanding, source-owned static-analysis fixture. Never executed by REA. */
+volatile unsigned int rea_ppc_global = 7;
+const char rea_ppc_marker[] = "rea-ppc-source-owned-fixture";
+
+__attribute__((noinline)) unsigned int rea_ppc_leaf(unsigned int value)
+{
+ return value * 3u + rea_ppc_global;
+}
+
+__attribute__((noinline)) unsigned int rea_ppc_entry(unsigned int selector)
+{
+ unsigned int adjusted = selector > 10u ? selector : selector + 1u;
+ return rea_ppc_leaf(adjusted) + (unsigned char)rea_ppc_marker[0];
+}
diff --git a/tests/conformance/ghidra/ppc-checks.d.mts b/tests/conformance/ghidra/ppc-checks.d.mts
new file mode 100644
index 000000000..38609de45
--- /dev/null
+++ b/tests/conformance/ghidra/ppc-checks.d.mts
@@ -0,0 +1,29 @@
+import type { PowerPcElfMetadata } from "../../../src/domain/binaryTargetTypes.js";
+
+export interface PowerPcReadelfFacts {
+ powerpc: PowerPcElfMetadata;
+ symbols: Record<
+ | "rea_ppc_entry"
+ | "rea_ppc_leaf"
+ | "rea_ppc_probe"
+ | "rea_ppc_global"
+ | "rea_ppc_marker",
+ string
+ >;
+}
+export function parsePowerPcReadelf(text: string): PowerPcReadelfFacts;
+export function inspectPowerPcReadelf(
+ path: string,
+): Promise<
+ PowerPcReadelfFacts & { command: string; version: string; raw: string }
+>;
+export function assertPowerPcLoadedImage(
+ observations: unknown,
+ sha256: string,
+): void;
+export function assertPowerPcProbe(
+ move: unknown,
+ branch: unknown,
+ address: string,
+): void;
+export function assertPowerPcGlobal(bytes: string): void;
diff --git a/tests/conformance/ghidra/ppc-checks.mjs b/tests/conformance/ghidra/ppc-checks.mjs
new file mode 100644
index 000000000..1136f2602
--- /dev/null
+++ b/tests/conformance/ghidra/ppc-checks.mjs
@@ -0,0 +1,132 @@
+import assert from "node:assert/strict";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const execute = promisify(execFile);
+// The lane binds this language; Ghidra's ELF loader alone selects e500.
+const LANGUAGE = "PowerPC:BE:32:default";
+const wordHex = (value) => {
+ const bytes = Buffer.alloc(4);
+ bytes.writeUInt32BE(value);
+ return bytes.toString("hex");
+};
+
+/** Parse only the documented GNU readelf fields needed by our tiny fixture. */
+export function parsePowerPcReadelf(text) {
+ const field = (expression, label) => {
+ const matches = [...text.matchAll(expression)];
+ assert.equal(matches.length, 1, `Missing or ambiguous readelf ${label}`);
+ return matches[0][1].trim();
+ };
+ assert.equal(field(/^\s*Class:\s*(.+)$/gmu, "class"), "ELF32");
+ assert.equal(
+ field(/^\s*Data:.*?,\s*(little|big) endian\s*$/gmu, "byte order"),
+ "big",
+ );
+ assert.match(field(/^\s*Type:\s*(.+)$/gmu, "type"), /^EXEC\b/u);
+ assert.equal(field(/^\s*Machine:\s*(.+)$/gmu, "machine"), "PowerPC");
+ const flags = Number(field(/^\s*Flags:\s*(0x[\da-f]+)\s*$/gmu, "flags"));
+ assert.equal(flags, 0, "Fixture ELF flags differ from build intent");
+ const symbols = {};
+ for (const name of [
+ "rea_ppc_entry",
+ "rea_ppc_leaf",
+ "rea_ppc_probe",
+ "rea_ppc_global",
+ "rea_ppc_marker",
+ ]) {
+ const value = field(
+ new RegExp(
+ `^\\s*\\d+:\\s+([\\da-f]+)\\s+\\d+\\s+(?:FUNC|OBJECT)\\s+GLOBAL\\s+\\S+\\s+\\d+\\s+${name}\\s*$`,
+ "gmu",
+ ),
+ `symbol ${name}`,
+ );
+ symbols[name] = `0x${BigInt(`0x${value}`).toString(16)}`;
+ }
+ return {
+ powerpc: { elfClass: 32, byteOrder: "big", type: 2, flags },
+ symbols,
+ };
+}
+
+/** Independent reader is a bounded test prerequisite, never an REA provider. */
+export async function inspectPowerPcReadelf(path) {
+ const command = process.env.REA_PPC_READELF ?? "readelf";
+ const options = {
+ env: { ...process.env, LC_ALL: "C" },
+ timeout: 10000,
+ maxBuffer: 1024 * 1024,
+ };
+ const version = await execute(command, ["--version"], options);
+ assert.match(
+ version.stdout,
+ /^GNU readelf\b/u,
+ "This lane requires GNU readelf",
+ );
+ const result = await execute(command, ["-h", "-s", "-W", path], options);
+ assert.equal(
+ result.stderr.trim(),
+ "",
+ `readelf diagnostics: ${result.stderr}`,
+ );
+ return {
+ ...parsePowerPcReadelf(result.stdout),
+ command,
+ version: version.stdout.split("\n")[0],
+ raw: result.stdout,
+ };
+}
+
+/** Check effective Ghidra observations, not the requested analysis profile. */
+export function assertPowerPcLoadedImage(observations, sha256) {
+ assert.equal(observations.language_id, LANGUAGE);
+ assert.equal(observations.compiler_spec_id, "default");
+ assert.ok(
+ observations.source_files.some(
+ (file) =>
+ file.original_sha256 === sha256 && file.modified_sha256 === sha256,
+ ),
+ "Ghidra loaded source identity changed",
+ );
+}
+
+/** Verify the exact constant/conditional destination in ppc-probe.S. */
+export function assertPowerPcProbe(move, branch, address) {
+ const start = BigInt(address);
+ for (const [instruction, offset, word] of [
+ [move, 0n, 0x38641234],
+ [branch, 8n, 0x41820008],
+ ]) {
+ assert.equal(instruction.status, "decoded");
+ assert.equal(BigInt(instruction.address), start + offset);
+ assert.equal(instruction.architecture, LANGUAGE);
+ assert.equal(instruction.length, 4);
+ assert.equal(instruction.bytes, wordHex(word));
+ }
+ assert.match(move.mnemonic, /^ADDI$/iu);
+ assert.ok(
+ move.operands.some((operand) =>
+ operand.components.some(
+ (part) =>
+ part.kind === "immediate" &&
+ part.value !== null &&
+ BigInt(part.value) === 0x1234n,
+ ),
+ ),
+ "ADDI immediate was not recovered",
+ );
+ assert.match(branch.mnemonic, /^BEQ$/iu);
+ assert.equal(branch.flow.kind, "jump");
+ assert.equal(branch.flow.conditional, true);
+ assert.equal(branch.flow.computed, false);
+ assert.deepEqual(
+ branch.flow.direct_destinations.map((value) => BigInt(value)),
+ [start + 16n],
+ );
+}
+
+/** Four bytes must encode the known source-owned global in big-endian order. */
+export function assertPowerPcGlobal(bytes) {
+ assert.equal(bytes, wordHex(7));
+}
diff --git a/tests/conformance/ghidra/ppc-checks.test.ts b/tests/conformance/ghidra/ppc-checks.test.ts
new file mode 100644
index 000000000..6328ce029
--- /dev/null
+++ b/tests/conformance/ghidra/ppc-checks.test.ts
@@ -0,0 +1,187 @@
+import { describe, expect, it } from "vitest";
+import {
+ assertPowerPcGlobal,
+ assertPowerPcLoadedImage,
+ assertPowerPcProbe,
+ parsePowerPcReadelf,
+} from "./ppc-checks.mjs";
+
+// GNU readelf 2.47 output for the source-owned Clang 23 / LLD fixture.
+// This tests the reader boundary, not Ghidra; the real lane rebuilds it.
+const readelf = `ELF Header:
+ Magic: 7f 45 4c 46 01 02 01 00 00 00 00 00 00 00 00 00
+ Class: ELF32
+ Data: 2's complement, big endian
+ Version: 1 (current)
+ OS/ABI: UNIX - System V
+ ABI Version: 0
+ Type: EXEC (Executable file)
+ Machine: PowerPC
+ Version: 0x1
+ Entry point address: 0x100101b0
+ Start of program headers: 52 (bytes into file)
+ Start of section headers: 2188 (bytes into file)
+ Flags: 0x0
+ Size of this header: 52 (bytes)
+ Size of program headers: 32 (bytes)
+ Number of program headers: 6
+ Size of section headers: 40 (bytes)
+ Number of section headers: 18
+ Section header string table index: 16
+
+Symbol table '.symtab' contains 7 entries:
+ Num: Value Size Type Bind Vis Ndx Name
+ 0: 00000000 0 NOTYPE LOCAL DEFAULT UND
+ 1: 00000000 0 FILE LOCAL DEFAULT ABS ppc.c
+ 2: 10010180 48 FUNC GLOBAL DEFAULT 4 rea_ppc_leaf
+ 3: 1002023c 4 OBJECT GLOBAL DEFAULT 5 rea_ppc_global
+ 4: 100101b0 116 FUNC GLOBAL DEFAULT 4 rea_ppc_entry
+ 5: 100000f4 29 OBJECT GLOBAL DEFAULT 1 rea_ppc_marker
+ 6: 10010224 24 FUNC GLOBAL DEFAULT 4 rea_ppc_probe
+`;
+const sha256 = "a".repeat(64);
+const image = {
+ language_id: "PowerPC:BE:32:default",
+ compiler_spec_id: "default",
+ source_files: [{ original_sha256: sha256, modified_sha256: sha256 }],
+};
+const move = {
+ address: "0x10010224",
+ status: "decoded",
+ architecture: "PowerPC:BE:32:default",
+ length: 4,
+ bytes: "38641234",
+ mnemonic: "addi",
+ operands: [
+ { index: 2, components: [{ kind: "immediate", value: "0x1234" }] },
+ ],
+};
+const branch = {
+ address: "0x1001022c",
+ status: "decoded",
+ architecture: "PowerPC:BE:32:default",
+ length: 4,
+ bytes: "41820008",
+ mnemonic: "beq",
+ flow: {
+ kind: "jump",
+ conditional: true,
+ computed: false,
+ direct_destinations: ["0x10010234"],
+ },
+};
+
+describe("PowerPC real-verifier independent expectations", () => {
+ it("reads GNU header and defined symbols without a Ghidra-derived oracle", () => {
+ const parsed = parsePowerPcReadelf(readelf);
+ expect(parsed.powerpc).toEqual({
+ elfClass: 32,
+ byteOrder: "big",
+ type: 2,
+ flags: 0,
+ });
+ expect(parsed.symbols.rea_ppc_probe).toBe("0x10010224");
+ });
+
+ it.each([
+ ["wrong class", "ELF32", "ELF64"],
+ ["wrong byte order", "big endian", "little endian"],
+ [
+ "wrong machine",
+ "Machine: PowerPC",
+ "Machine: PowerPC64",
+ ],
+ ["wrong object type", "EXEC (Executable file)", "DYN (Shared object file)"],
+ [
+ "wrong flags",
+ "Flags: 0x0",
+ "Flags: 0x80000000",
+ ],
+ ["missing symbol", "rea_ppc_probe", "unrelated_probe"],
+ ])(
+ "rejects %s instead of returning a guessed fixture interpretation",
+ (_name, from, to) => {
+ expect(() => parsePowerPcReadelf(readelf.replace(from, to))).toThrow();
+ },
+ );
+
+ it("rejects ambiguous producer fields", () => {
+ expect(() => parsePowerPcReadelf(`${readelf}\nClass: ELF32\n`)).toThrow(
+ /ambiguous/u,
+ );
+ });
+
+ it("checks the effective loaded image, including Ghidra's e500 default", () => {
+ assertPowerPcLoadedImage(image, sha256);
+ expect(() =>
+ assertPowerPcLoadedImage(
+ { ...image, language_id: "PowerPC:BE:32:e500" },
+ sha256,
+ ),
+ ).toThrow();
+ expect(() =>
+ assertPowerPcLoadedImage(
+ { ...image, compiler_spec_id: "macosx" },
+ sha256,
+ ),
+ ).toThrow();
+ expect(() =>
+ assertPowerPcLoadedImage(
+ {
+ ...image,
+ source_files: [
+ { original_sha256: sha256, modified_sha256: "b".repeat(64) },
+ ],
+ },
+ sha256,
+ ),
+ ).toThrow();
+ });
+
+ it("checks exact probe bytes, immediate and conditional destination", () => {
+ assertPowerPcProbe(move, branch, "0x10010224");
+ });
+
+ it("rejects plausible nonempty but incorrect instruction observations", () => {
+ expect(() =>
+ assertPowerPcProbe({ ...move, bytes: "38641235" }, branch, "0x10010224"),
+ ).toThrow();
+ expect(() =>
+ assertPowerPcProbe({ ...move, length: 2 }, branch, "0x10010224"),
+ ).toThrow();
+ expect(() =>
+ assertPowerPcProbe(
+ {
+ ...move,
+ operands: [
+ { index: 2, components: [{ kind: "immediate", value: "0x1235" }] },
+ ],
+ },
+ branch,
+ "0x10010224",
+ ),
+ ).toThrow();
+ expect(() =>
+ assertPowerPcProbe(
+ move,
+ {
+ ...branch,
+ flow: { ...branch.flow, direct_destinations: ["0x10010230"] },
+ },
+ "0x10010224",
+ ),
+ ).toThrow();
+ expect(() =>
+ assertPowerPcProbe(
+ move,
+ { ...branch, flow: { ...branch.flow, conditional: false } },
+ "0x10010224",
+ ),
+ ).toThrow();
+ });
+
+ it("checks the big-endian global word", () => {
+ assertPowerPcGlobal("00000007");
+ expect(() => assertPowerPcGlobal("07000000")).toThrow();
+ });
+});
diff --git a/tests/conformance/ghidra/ppc-fixture.mjs b/tests/conformance/ghidra/ppc-fixture.mjs
new file mode 100644
index 000000000..202c0dacf
--- /dev/null
+++ b/tests/conformance/ghidra/ppc-fixture.mjs
@@ -0,0 +1,62 @@
+import { execFile } from "node:child_process";
+import { createHash } from "node:crypto";
+import { readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { promisify } from "node:util";
+
+const execute = promisify(execFile);
+
+/** Build a source-owned freestanding ELF; never execute its target code. */
+export async function buildPowerPcFixture(directory) {
+ const compiler = process.env.REA_PPC_CLANG ?? "clang";
+ const target = "powerpc-unknown-linux-gnu";
+ const source = fileURLToPath(new URL("../c/ppc.c", import.meta.url));
+ const probe = fileURLToPath(new URL("./ppc-probe.S", import.meta.url));
+ const path = join(directory, `${target}.elf`);
+ const args = [
+ `--target=${target}`,
+ // A 750-family core with the classic FPU, unlike the e500 SPE variant.
+ "-mcpu=750",
+ "-fno-pic",
+ "-fuse-ld=lld",
+ "-nostdlib",
+ "-static",
+ "-O0",
+ "-g",
+ "-Wl,-e,rea_ppc_entry",
+ "-Wl,--build-id=none",
+ source,
+ probe,
+ "-o",
+ path,
+ ];
+ const version = await execute(compiler, ["--version"], { timeout: 10000 });
+ await execute(compiler, args, { timeout: 60000 });
+ const bytes = await readFile(path);
+ if (
+ bytes.length < 52 ||
+ bytes.toString("hex", 0, 4) !== "7f454c46" ||
+ bytes[4] !== 1 ||
+ bytes[5] !== 2 ||
+ bytes.readUInt16BE(16) !== 2 ||
+ bytes.readUInt16BE(18) !== 20 ||
+ bytes.readUInt32BE(36) !== 0
+ )
+ throw new Error(
+ "Compiler did not produce the declared ELF32 big-endian PowerPC fixture",
+ );
+ return {
+ path,
+ flags: bytes.readUInt32BE(36),
+ sha256: createHash("sha256").update(bytes).digest("hex"),
+ source_sha256: createHash("sha256")
+ .update(await readFile(source))
+ .digest("hex"),
+ probe_sha256: createHash("sha256")
+ .update(await readFile(probe))
+ .digest("hex"),
+ compiler: version.stdout.trim(),
+ arguments: args,
+ };
+}
diff --git a/tests/conformance/ghidra/ppc-probe.S b/tests/conformance/ghidra/ppc-probe.S
new file mode 100644
index 000000000..f67793942
--- /dev/null
+++ b/tests/conformance/ghidra/ppc-probe.S
@@ -0,0 +1,17 @@
+/* Source-owned 32-bit PowerPC instructions with fixed, reviewable encodings.
+ * No target execution. The conditional branch at +8 targets +16.
+ */
+.text
+.p2align 2
+.globl rea_ppc_probe
+.type rea_ppc_probe, @function
+rea_ppc_probe:
+ addi 3, 4, 0x1234
+ cmpwi 0, 4, 0
+ beq 0, .Lzero
+ blr
+.Lzero:
+ li 3, 7
+ blr
+.size rea_ppc_probe, .-rea_ppc_probe
+.section .note.GNU-stack,"",@progbits