feat(ghidra): admit bounded PSP ELF through installed Allegrex (#1599)

* feat(ghidra): admit bounded PSP ELF through installed Allegrex

Build on merged generic MIPS without weakening its policy. Bind caller-installed processor files into the analysis profile, check them again before launch, force and verify PSP language selection, retain limitations, and extend the public CLI/MCP verifier with source-owned PSP and target-switching checks. Local actual focused Vitest: 102 passed, one existing permission skip. Hosted static and real-provider results are pending.

* fix(ghidra): validate PSPSDK ABI records in PSP admission

* test(ghidra): bind PSP ABI verification to snapshot profiles

* test(ghidra): declare independent PSP ABI reader contract

* test(ghidra): retain the PSP fixture discriminated type

* test(ghidra): contextually type PSP metadata as the MIPS target variant

* test(ghidra): keep carved firmware checks scoped to generic MIPS fixtures

* style(ghidra): format PSP language-override guard

* style(test): format PSP language override regressions

* fix(ghidra): bound PSP extension enumeration before allocation

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
Jay-Alexander Elliot
2026-10-11 05:47:33 -06:00
committed by GitHub
parent 0153b4d95b
commit e4407dbed5
17 changed files with 1637 additions and 146 deletions
+1 -1
View File
@@ -3,7 +3,7 @@
This lane extends native ELF analysis; it does not execute MIPS code or rehost
firmware. It advances the generic-MIPS portion of
[#718](https://github.com/morluto/rea/issues/718), not the whole firmware roadmap.
PSP/Allegrex remains a separate specialization tracked in
PSP/Allegrex has a [separate static-ELF profile](ghidra-psp.md) tracked in
[#1330](https://github.com/morluto/rea/issues/1330).
## Admission boundary
+105
View File
@@ -0,0 +1,105 @@
# PSP static ELF analysis through Ghidra
This specialization builds on [generic MIPS support](ghidra-mips.md) for
[#1330](https://github.com/morluto/rea/issues/1330). It reuses REA's existing
CLI/MCP, Ghidra bridge, Evidence and snapshot workflows. Allegrex instructions,
PSP loading and calling conventions remain owned by
[ghidra-allegrex](https://github.com/kotcrab/ghidra-allegrex).
## Initial boundary
The initial profile is limited to Linux x64, little-endian ELF32 `ET_EXEC`,
`EM_MIPS` and the PSPSDK declaration `e_flags=0x10a23001`. Those bits declare
MIPS II, Allegrex, EABI32 and NOREORDER; they are not proof of every instruction's
semantics. The profile also requires an inspected version-0 MIPS ABI flags
record declaring ISA level 2/revision 0, 32-bit GPR and CPR1, no CPR2,
single-precision hard float (`fpAbi=2`), no ISA extension or ASEs,
`flags1=1` and `flags2=0`. These are the declarations independently reported by
PSPDEV v20261001's GNU readelf 2.44 and its 24-byte section dump. Missing,
uninspected, contradictory and other producer declarations are refused.
The PSP profile is not an exception to the generic MIPS32r2/o32 policy; that
separate gate is unchanged. Profile revision v2 records this corrected boundary.
PRX, relocatable/shared images, PBP/ISO/CSO containers, runtime emulation and live
code overlays are outside this first profile. Other hosts and flag combinations
are not silently admitted. File names do not identify a PSP target.
## Bring your own compatible extension
Install the extension in the selected Ghidra distribution at
`$GHIDRA_INSTALL_DIR/Ghidra/Extensions/ghidra-allegrex`, with its
`Module.manifest`, `extension.properties`, language files and loader JARs.
User-home installations are not searched because REA uses an isolated analysis
home. REA never downloads, installs, rebuilds or vendors the extension.
The published compatibility experiment used Ghidra **12.1.3**, extension
**v21.4**, PSPDEV **v20261001** and JDK 21. New public-provider results must be
reported separately from that historical bridge experiment. The extension's
`version` property declares its compatible **Ghidra version**, not its release
tag; the property must match the selected installation. Other same-version
builds are not described as independently verified merely because they pass
this metadata check. A malformed or unloadable extension still fails real import.
REA fingerprints installed extension files before resolving the analysis
profile and checks that identity again immediately before launch. Symlinks,
nonregular files, missing components and incompatible metadata are refused.
Inspection is bounded to 1,024 entries, 16 directory levels and 64 MiB, with
streamed file hashing and 64 KiB properties. Rebuilds or content changes yield a
different profile; an old snapshot is not silently reused with the new profile.
This is content identity, not a signature or a sandbox for installed code.
Do not modify the Ghidra installation while a session is running.
## Ordinary public interfaces
```bash
rea analyze /absolute/path/to/psp.elf --provider ghidra --json
rea function /absolute/path/to/psp.elf function_name --provider ghidra --json
```
MCP uses `open_binary` with an absolute path and `provider_id: "ghidra"`, then
ordinary inventory and function operations. The loader is explicitly
`PspElfLoader`, language `Allegrex:LE:32:default`, compiler `default`. The
handshake must confirm the language and compiler before serving observations.
Missing prerequisites return `PSP_EXTENSION_UNAVAILABLE` with an installation
remedy; no provider fallback or automatic installation occurs.
Evidence retains the `mips` architecture family and its PSP-specific profile,
including extension content identity. It also carries the VFPU and static-only
limitations. The extension does not model VFPU prefix effects completely;
nonempty decompiled code is not proof of correct vector behavior. Existing
session-local metadata editing does not change executable bytes or turn a
snapshot into a persistent Ghidra project.
## Verification
Use the existing real MIPS verifier with its PSP option. Required tools are
caller-supplied `make`, `psp-config`, `psp-gcc`, `psp-readelf`, `psp-objdump`,
`PSPDEV`, `GHIDRA_INSTALL_DIR` and a compatible `JAVA_HOME`.
```bash
npm run build:cached
node scripts/verify-real-ghidra-mips.mjs --psp
# In a disposable installation without Allegrex, test actual public refusals:
node scripts/verify-real-ghidra-mips.mjs --psp-missing-extension
# Generic MIPS must remain generic with Allegrex installed:
npm run verify:ghidra:mips
```
The generic verifier also exercises a section-truncated firmware fixture. That
check uses generic MIPS symbols and language expectations and runs only in generic
mode; the PSP report explicitly records carved-PSP coverage as not run.
The maintained PSP fixture builds from `tests/conformance/psp/`, never executes,
and is independent of commercial game data. PSP GNU tools supply ELF/ABI,
raw ABI-record, symbol and encoding observations; REA's parsed target and saved
snapshot must agree with the independent ABI declaration. Public CLI/MCP must
recover known functions, a direct
call, the immediate `0x1234`, BITREV, a global word and a marker. The verifier
checks observed loader/language, original bytes, Evidence, snapshot reopening,
and an A-B-A target switch with different global contents at the same address.
A snapshot for A must not open B. Cleanup must finish before success is reported.
These checks are selected static facts, not full behavioral equivalence, VFPU
semantics or arbitrary PSP compatibility. Independently published PSP binaries
are supplemental calibration targets after payload classification and license
review; unsupported PRX/PBP must not count as a successful static-ELF case.
+311 -130
View File
@@ -21,12 +21,26 @@ import { parseAnalysisSnapshot } from "../dist/domain/analysisSnapshot.js";
import { functionDossierSchema } from "../dist/domain/hopperValues.js";
import { inspectGhidraInstallation } from "../dist/ghidra/GhidraInstallation.js";
import { buildMipsFixture } from "../tests/conformance/ghidra/mips-fixture.mjs";
import { requireMcpOperationResult } from "./lib/mcp-verifier-results.mjs";
import {
buildPspFixture,
assertPspLoadedImage,
assertPspProbe,
} from "../tests/conformance/ghidra/psp-fixture.mjs";
import {
requireMcpToolError,
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-mips.mjs");
const mode = process.argv[2];
const psp = mode === "--psp" || mode === "--psp-missing-extension";
const missingExtension = mode === "--psp-missing-extension";
if (process.argv.length > 3 || (mode !== undefined && !psp))
throw new Error(
"Usage: node scripts/verify-real-ghidra-mips.mjs [--psp|--psp-missing-extension]",
);
const prefix = psp ? "rea_psp" : "rea_mips";
if (!["linux", "darwin"].includes(process.platform))
throw new Error(
"MIPS verification requires a supported Linux/macOS Ghidra host",
@@ -66,16 +80,29 @@ for (const setting of ["GHIDRA_HEADLESS_MAXMEM", "GHIDRA_MAXMEM"]) {
const reports = [];
let primaryFailure;
try {
for (const byteOrder of ["little", "big"]) {
const fixture = await buildMipsFixture(workspace, byteOrder);
const independent = await inspectMipsReadelf(fixture.path, byteOrder);
for (const byteOrder of psp ? ["little"] : ["little", "big"]) {
const fixture = psp
? await buildPspFixture(workspace)
: await buildMipsFixture(workspace, byteOrder);
const independent = psp
? fixture
: await inspectMipsReadelf(fixture.path, byteOrder);
const resolved = await parseBinaryTarget(fixture.path);
if (!resolved.ok) throw resolved.error;
assert.deepEqual(
resolved.value.mips,
independent.mips,
"REA ELF/ABI interpretation disagrees with GNU readelf",
);
if (psp) {
assert.equal(resolved.value.mips.flags, 0x10a23001);
assert.deepEqual(
resolved.value.mips,
independent.mips,
"REA PSP ELF/ABI interpretation disagrees with GNU readelf and raw ABI bytes",
);
} else {
assert.deepEqual(
resolved.value.mips,
independent.mips,
"REA ELF/ABI interpretation disagrees with GNU readelf",
);
}
assert.equal(resolved.value.sha256, fixture.sha256);
const snapshotPath = join(workspace, `${byteOrder}.snapshot.json`);
const transport = new StdioClientTransport({
@@ -99,6 +126,51 @@ try {
};
try {
await client.connect(transport);
if (missingExtension) {
const rejected = await client.callTool(
{
name: "open_binary",
arguments: { path: fixture.path, provider_id: "ghidra" },
},
undefined,
{ timeout: 60000 },
);
const error = requireMcpToolError(rejected);
assert.match(JSON.stringify(error), /PSP_EXTENSION_UNAVAILABLE/u);
let cliError;
try {
await execute(
process.execPath,
[
entrypoint,
"function",
fixture.path,
`${prefix}_entry`,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 60000, maxBuffer: 1024 * 1024 },
);
} catch (cause) {
cliError = cause;
}
assert.ok(
cliError && Number.isInteger(cliError.code) && cliError.code !== 0,
"Expected a typed public rejection, not a timeout or success",
);
assert.match(
`${cliError.stdout}\n${cliError.stderr}`,
/PSP_EXTENSION_UNAVAILABLE/u,
);
reports.push({
path: fixture.path,
status: "expected-missing-extension-refusal",
cli_exit: cliError.code,
mcp_error: error,
});
continue;
}
const target = await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
@@ -108,45 +180,56 @@ try {
// ELF load-image verification is not the DOS comparison lane. Its retained
// observations still expose the actual Ghidra language and loaded bytes.
const image = await call("inspect_native_load_image");
assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
if (psp) assertPspLoadedImage(image.observations, fixture.sha256);
else assertMipsLoadedImage(image.observations, byteOrder, fixture.sha256);
const procedures = await call("list_procedures");
const entry = procedures.find((p) => p.value === "rea_mips_entry");
const leaf = procedures.find((p) => p.value === "rea_mips_leaf");
const entry = procedures.find((p) => p.value === `${prefix}_entry`);
const leaf = procedures.find((p) => p.value === `${prefix}_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_mips_entry),
BigInt(independent.symbols[`${prefix}_entry`]),
);
assert.equal(
BigInt(leaf.address),
BigInt(independent.symbols.rea_mips_leaf),
BigInt(independent.symbols[`${prefix}_leaf`]),
);
const probe = independent.symbols.rea_mips_probe;
const probe = independent.symbols[`${prefix}_probe`];
assert.ok(
procedures.some(
(p) =>
p.value === "rea_mips_probe" && BigInt(p.address) === BigInt(probe),
p.value === `${prefix}_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) + 4n).toString(16)}`,
});
assertMipsProbe(move, branch, byteOrder, probe);
if (psp) assertPspProbe(move, branch);
else assertMipsProbe(move, branch, byteOrder, probe);
const global = await call("read_bytes", {
address: independent.symbols.rea_mips_global,
address: independent.symbols[`${prefix}_global`],
length: 4,
});
assert.equal(global.returned_bytes, 4);
assertMipsGlobal(global.bytes_hex, byteOrder);
const marker = await call("read_bytes", {
address: independent.symbols.rea_mips_marker,
length: 30,
address: independent.symbols[`${prefix}_marker`],
length: Buffer.byteLength(
psp
? "rea-psp-source-owned-fixture\0"
: "rea-mips-source-owned-fixture\0",
),
});
assert.equal(
marker.bytes_hex,
Buffer.from("rea-mips-source-owned-fixture\0").toString("hex"),
Buffer.from(
psp
? "rea-psp-source-owned-fixture\0"
: "rea-mips-source-owned-fixture\0",
).toString("hex"),
);
const callees = await call("procedure_callees", {
procedure: entry.address,
@@ -186,11 +269,47 @@ try {
JSON.parse(await readFile(snapshotPath, "utf8")),
);
assert.equal(snapshot.target.architecture, "mips");
assert.equal(
snapshot.binding.analysis_profile.parameters.mips_elf.byte_order,
byteOrder,
const abi = independent.mips.abiFlags;
assert.deepEqual(
snapshot.binding.analysis_profile.parameters.mips_elf,
{
elf_class: independent.mips.elfClass,
byte_order: independent.mips.byteOrder,
type: independent.mips.type,
flags: independent.mips.flags,
abi_flags: {
version: abi.version,
isa_level: abi.isaLevel,
isa_revision: abi.isaRevision,
gpr_size: abi.gprSize,
cpr1_size: abi.cpr1Size,
cpr2_size: abi.cpr2Size,
fp_abi: abi.fpAbi,
isa_extension: abi.isaExtension,
ases: abi.ases,
flags1: abi.flags1,
flags2: abi.flags2,
},
},
"Snapshot profile did not retain the independently inspected ELF/ABI declaration",
);
assert.ok(snapshot.evidence_bundle.records.length > 0);
if (psp) {
assert.equal(
snapshot.binding.analysis_profile.parameters.language_id,
"Allegrex:LE:32:default",
);
assert.match(
snapshot.binding.analysis_profile.parameters.psp_extension.sha256,
/^[a-f0-9]{64}$/u,
);
assert.ok(
snapshot.evidence_bundle.records.some((record) =>
JSON.stringify(record).includes("VFPU prefix"),
),
"PSP limitations missing from retained Evidence",
);
}
for (const evidence of snapshot.evidence_bundle.records)
assert.equal(parseEvidence(evidence).subject?.architecture, "mips");
await call("open_binary", {
@@ -201,6 +320,52 @@ try {
assert.deepEqual(await call("list_procedures"), procedures);
await call("close_binary");
if (psp) {
const alternate = join(workspace, "alternate");
await mkdir(alternate);
const other = await buildPspFixture(alternate, 11);
assert.notEqual(other.sha256, fixture.sha256);
assert.equal(
other.symbols.rea_psp_global,
fixture.symbols.rea_psp_global,
"Target-switch control must reuse the same global address",
);
for (const item of [fixture, other, fixture]) {
const selected = await call("open_binary", {
path: item.path,
provider_id: "ghidra",
});
assert.equal(selected.sha256, item.sha256);
const word = await call("read_bytes", {
address: item.symbols.rea_psp_global,
length: 4,
});
const expected = Buffer.alloc(4);
expected.writeUInt32LE(item.global_value);
assert.equal(word.bytes_hex, expected.toString("hex"));
await call("close_binary");
}
const wrongSnapshot = await client.callTool(
{
name: "open_binary",
arguments: {
path: other.path,
provider_id: "ghidra",
snapshot_path: snapshotPath,
},
},
undefined,
{ timeout: 180000 },
);
requireMcpToolError(wrongSnapshot);
// A bad snapshot must not poison the next clean public session.
await call("open_binary", {
path: fixture.path,
provider_id: "ghidra",
});
await call("close_binary");
}
// Same public operation through CLI, not a private injected provider.
const cli = await execute(
process.execPath,
@@ -208,7 +373,7 @@ try {
entrypoint,
"function",
fixture.path,
"rea_mips_entry",
`${prefix}_entry`,
"--provider",
"ghidra",
"--json",
@@ -230,103 +395,120 @@ try {
fixture.sha256,
"Analysis modified the source executable",
);
// Carved firmware keeps all program headers/load bytes but loses sections.
const read16 = (offset) =>
byteOrder === "little"
? sourceBytes.readUInt16LE(offset)
: sourceBytes.readUInt16BE(offset);
const read32 = (offset) =>
byteOrder === "little"
? sourceBytes.readUInt32LE(offset)
: sourceBytes.readUInt32BE(offset);
const sectionOffset = read32(32);
const cut = sectionOffset - 1;
const programOffset = read32(28);
const programCount = read16(44);
assert.equal(read16(42), 32);
assert.ok(cut >= programOffset + programCount * 32);
for (let i = 0; i < programCount; i += 1) {
const offset = programOffset + i * 32;
assert.ok(read32(offset + 4) + read32(offset + 16) <= cut);
}
const carvedBytes = sourceBytes.subarray(0, cut);
const carvedPath = join(workspace, `${byteOrder}-carved.elf`);
const carvedSha256 = createHash("sha256")
.update(carvedBytes)
.digest("hex");
await writeFile(carvedPath, carvedBytes);
const carvedResolved = await parseBinaryTarget(carvedPath);
if (!carvedResolved.ok) throw carvedResolved.error;
assert.deepEqual(
carvedResolved.value.mips.abiFlags,
independent.mips.abiFlags,
);
assert.match(
carvedResolved.value.mips.limitations.join(" "),
/section header table/u,
);
const carvedTarget = await call("open_binary", {
path: carvedPath,
provider_id: "ghidra",
});
assert.equal(carvedTarget.sha256, carvedSha256);
const carvedImage = await call("inspect_native_load_image");
assertMipsLoadedImage(carvedImage.observations, byteOrder, carvedSha256);
const carvedProcedures = await call("list_procedures");
assert.ok(
carvedProcedures.some(
(p) =>
BigInt(p.address) === BigInt(independent.symbols.rea_mips_entry),
),
);
const carvedDossier = functionDossierSchema.parse(
await call("analyze_function", {
procedure: independent.symbols.rea_mips_entry,
}),
);
assert.ok(carvedDossier.pseudocode.trim().length > 0);
const carvedSnapshotPath = join(
workspace,
`${byteOrder}-carved.snapshot.json`,
);
await call("close_binary", { snapshot_path: carvedSnapshotPath });
const carvedSnapshot = parseAnalysisSnapshot(
JSON.parse(await readFile(carvedSnapshotPath, "utf8")),
);
assert.equal(carvedSnapshot.target.sha256, carvedSha256);
assert.ok(
carvedSnapshot.evidence_bundle.records.some((record) =>
parseEvidence(record).limitations.some((item) =>
// This upstream carved-firmware proof is specific to the generic MIPS fixture.
// PSP keeps its independent Allegrex/A-B-A checks; carved PSP is not verified.
let carved;
if (!psp) {
// Carved firmware keeps all program headers/load bytes but loses sections.
const read16 = (offset) =>
byteOrder === "little"
? sourceBytes.readUInt16LE(offset)
: sourceBytes.readUInt16BE(offset);
const read32 = (offset) =>
byteOrder === "little"
? sourceBytes.readUInt32LE(offset)
: sourceBytes.readUInt32BE(offset);
const sectionOffset = read32(32);
const cut = sectionOffset - 1;
const programOffset = read32(28);
const programCount = read16(44);
assert.equal(read16(42), 32);
assert.ok(cut >= programOffset + programCount * 32);
for (let i = 0; i < programCount; i += 1) {
const offset = programOffset + i * 32;
assert.ok(read32(offset + 4) + read32(offset + 16) <= cut);
}
const carvedBytes = sourceBytes.subarray(0, cut);
const carvedPath = join(workspace, `${byteOrder}-carved.elf`);
const carvedSha256 = createHash("sha256")
.update(carvedBytes)
.digest("hex");
await writeFile(carvedPath, carvedBytes);
const carvedResolved = await parseBinaryTarget(carvedPath);
if (!carvedResolved.ok) throw carvedResolved.error;
assert.deepEqual(
carvedResolved.value.mips.abiFlags,
independent.mips.abiFlags,
);
assert.match(
carvedResolved.value.mips.limitations.join(" "),
/section header table/u,
);
const carvedTarget = await call("open_binary", {
path: carvedPath,
provider_id: "ghidra",
});
assert.equal(carvedTarget.sha256, carvedSha256);
const carvedImage = await call("inspect_native_load_image");
assertMipsLoadedImage(
carvedImage.observations,
byteOrder,
carvedSha256,
);
const carvedProcedures = await call("list_procedures");
assert.ok(
carvedProcedures.some(
(p) =>
BigInt(p.address) === BigInt(independent.symbols.rea_mips_entry),
),
);
const carvedDossier = functionDossierSchema.parse(
await call("analyze_function", {
procedure: independent.symbols.rea_mips_entry,
}),
);
assert.ok(carvedDossier.pseudocode.trim().length > 0);
const carvedSnapshotPath = join(
workspace,
`${byteOrder}-carved.snapshot.json`,
);
await call("close_binary", { snapshot_path: carvedSnapshotPath });
const carvedSnapshot = parseAnalysisSnapshot(
JSON.parse(await readFile(carvedSnapshotPath, "utf8")),
);
assert.equal(carvedSnapshot.target.sha256, carvedSha256);
assert.ok(
carvedSnapshot.evidence_bundle.records.some((record) =>
parseEvidence(record).limitations.some((item) =>
item.includes("section header table"),
),
),
);
const carvedCli = await execute(
process.execPath,
[
entrypoint,
"function",
carvedPath,
independent.symbols.rea_mips_entry,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const carvedEvidence = parseEvidence(JSON.parse(carvedCli.stdout));
assert.equal(carvedEvidence.subject?.digest.sha256, carvedSha256);
assert.ok(
carvedEvidence.limitations.some((item) =>
item.includes("section header table"),
),
),
);
const carvedCli = await execute(
process.execPath,
[
entrypoint,
"function",
carvedPath,
independent.symbols.rea_mips_entry,
"--provider",
"ghidra",
"--json",
],
{ env, timeout: 240000, maxBuffer: 16 * 1024 * 1024 },
);
const carvedEvidence = parseEvidence(JSON.parse(carvedCli.stdout));
assert.equal(carvedEvidence.subject?.digest.sha256, carvedSha256);
assert.ok(
carvedEvidence.limitations.some((item) =>
item.includes("section header table"),
),
);
assert.equal(
functionDossierSchema.parse(carvedEvidence.normalized_result).procedure
.address,
carvedDossier.procedure.address,
);
assert.deepEqual(await readFile(carvedPath), carvedBytes);
);
assert.equal(
functionDossierSchema.parse(carvedEvidence.normalized_result)
.procedure.address,
carvedDossier.procedure.address,
);
assert.deepEqual(await readFile(carvedPath), carvedBytes);
carved = {
sha256: carvedSha256,
source_bytes: carvedBytes.length,
section_header_offset: sectionOffset,
effective_language: carvedImage.observations.language_id,
limitations: carvedResolved.value.mips.limitations,
procedures: carvedProcedures.length,
};
}
reports.push({
...fixture,
independent_reader: independent,
@@ -336,18 +518,17 @@ try {
"independent ELF/ABI and symbols",
"loaded Ghidra identity",
"fixed instruction bytes and immediate",
"conditional branch destination",
psp
? "Allegrex BITREV decoding and A-B-A target identity"
: "conditional branch destination",
"global word and marker bytes",
"CLI/MCP and snapshot identity",
],
procedures: procedures.length,
carved: {
sha256: carvedSha256,
source_bytes: carvedBytes.length,
section_header_offset: sectionOffset,
effective_language: carvedImage.observations.language_id,
limitations: carvedResolved.value.mips.limitations,
procedures: carvedProcedures.length,
carved: carved ?? {
status: "not_run",
reason:
"Carved-firmware verification covers generic MIPS fixtures; carved PSP coverage is not established.",
},
status: "passed",
});
+30 -5
View File
@@ -13,10 +13,13 @@ import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import type { GhidraInstallationInspection } from "./GhidraInstallation.js";
import { ghidraMipsProfileParameters } from "./GhidraMipsProfile.js";
import {
ghidraMipsProfileParameters,
ghidraMipsUnsupportedReason,
} from "./GhidraMipsProfile.js";
ghidraProcessorUnsupportedReason,
isGhidraPspTarget,
} from "./GhidraPspProfile.js";
import { resolveGhidraPspExtension } from "./GhidraPspExtension.js";
import {
NATIVE_AOT_MAX_REPORT_BYTES,
NATIVE_AOT_MAX_SOURCE_BYTES,
@@ -26,7 +29,7 @@ import {
} from "../domain/native/nativeAotPe.js";
/** Resolve version-bound, deterministic semantics before Ghidra imports a target. */
export const resolveGhidraAnalysisProfile = (
export const resolveGhidraAnalysisProfile = async (
target: BinaryTarget,
identity: ProviderIdentity,
installation: GhidraInstallationInspection,
@@ -41,7 +44,7 @@ export const resolveGhidraAnalysisProfile = (
return Promise.resolve(
err(new ProviderAdapterError(identity.id, "resolve_analysis_profile")),
);
const mipsReason = ghidraMipsUnsupportedReason(target);
const mipsReason = ghidraProcessorUnsupportedReason(target);
if (mipsReason !== null)
return Promise.resolve(
err(
@@ -52,6 +55,19 @@ export const resolveGhidraAnalysisProfile = (
),
),
);
// A caller-selected generic processor cannot override this verified PSP loader.
if (isGhidraPspTarget(target) && languageOverride !== undefined)
return err(
new AnalysisUnsupportedTargetError(
"resolve_analysis_profile",
target.path,
"The bounded PSP Allegrex profile does not accept REA_GHIDRA_LANGUAGE_ID overrides.",
),
);
const pspExtension = isGhidraPspTarget(target)
? await resolveGhidraPspExtension(installation, signal)
: null;
if (pspExtension !== null && !pspExtension.ok) return pspExtension;
const provider = { ...identity, version: installation.providerVersion };
const dosMz = target.format === "dos-mz";
const dosCom = target.format === "dos-com";
@@ -151,6 +167,15 @@ export const resolveGhidraAnalysisProfile = (
: {}),
analyzer_preset: "ghidra-default",
...ghidraMipsProfileParameters(target),
...(pspExtension?.ok === true
? {
mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2",
loader: "PspElfLoader",
language_id: "Allegrex:LE:32:default",
compiler_spec_id: "default",
psp_extension: { ...pspExtension.value },
}
: {}),
}),
}),
);
+58 -7
View File
@@ -5,7 +5,7 @@ import {
} from "./GhidraAnalysisSeeds.js";
import type { GhidraLanguageOverride } from "../config/ghidraLanguageOverride.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import { mkdir } from "node:fs/promises";
import { mkdir, realpath } from "node:fs/promises";
import { basename, dirname, join, posix, win32 } from "node:path";
import writeFileAtomic from "write-file-atomic";
@@ -37,6 +37,11 @@ import {
type GhidraExtension,
} from "./extensions/GhidraExtensions.js";
import {
inspectGhidraPspExtension,
type GhidraPspExtension,
} from "./GhidraPspExtension.js";
/** Private paths and identity material for one headless Ghidra import. */
export interface GhidraLaunchSession {
readonly runtimeRoot: string;
@@ -101,6 +106,7 @@ export interface GhidraHeadlessLauncherOptions {
/** Caller seeds committed by the analysis profile, applied before analysis. */
readonly analysisSeeds?: GhidraSeedCommitment;
readonly analysisExtensions?: readonly GhidraExtension[];
readonly pspExtension?: GhidraPspExtension;
/** Spawn seam for provider-boundary lifecycle tests. */
readonly spawnProcess?: typeof spawnOwnedProviderProcess;
}
@@ -125,6 +131,31 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
const platform = this.options.platform ?? process.platform;
let started: SpawnedOwnedProviderProcess | undefined;
try {
const psp = this.options.pspExtension;
if (psp !== undefined) {
const installedRoot = await realpath(
join(
dirname(this.options.analyzeHeadlessPath),
"..",
"Ghidra",
"Extensions",
"ghidra-allegrex",
),
);
if (installedRoot !== psp.root)
throw new GhidraLaunchError(
"Allegrex profile does not belong to the selected Ghidra installation.",
);
const inspected = await inspectGhidraPspExtension(
psp.root,
psp.ghidra_version,
options.signal,
);
if (inspected.sha256 !== psp.sha256)
throw new GhidraLaunchError(
"Installed Allegrex content changed since profile resolution; reopen the target to resolve a fresh profile.",
);
}
await createGhidraRuntimeDirectories(paths, platform);
const seedPath =
this.options.analysisSeeds === undefined
@@ -157,6 +188,7 @@ export class GhidraHeadlessLauncher implements GhidraLauncher {
return err(new AnalysisCancelledError("open_binary"));
const headlessArguments = ghidraHeadlessArguments({
platform,
...(psp === undefined ? {} : { psp: true as const }),
projectRoot: paths.projectRoot,
targetPath: session.targetPath,
bridgeScriptPath: this.options.bridgeScriptPath,
@@ -376,6 +408,7 @@ export interface GhidraHeadlessArgumentOptions {
readonly descriptorPath: string;
readonly ghidraLogPath: string;
readonly scriptLogPath: string;
readonly psp?: true;
readonly dosMz?: true;
readonly dosCom?: true;
readonly languageOverride?: GhidraLanguageOverride;
@@ -393,6 +426,15 @@ export const ghidraHeadlessArguments = (
(options.platform ?? process.platform) === "win32" ? win32 : posix;
const bridgeDirectory = scriptPath.dirname(options.bridgeScriptPath);
const override = options.languageOverride;
if (
options.psp === true &&
(options.dosMz === true ||
options.dosCom === true ||
override !== undefined)
)
throw new Error(
"A PSP ELF import cannot be combined with DOS or a generic Ghidra language override.",
);
if (
override !== undefined &&
(options.dosMz === true || options.dosCom === true)
@@ -425,15 +467,24 @@ export const ghidraHeadlessArguments = (
"-cspec",
"default",
]
: override !== undefined
: options.psp === true
? [
"-loader",
"PspElfLoader",
"-processor",
override.languageId,
...(override.compilerSpecId === undefined
? []
: ["-cspec", override.compilerSpecId]),
"Allegrex:LE:32:default",
"-cspec",
"default",
]
: []),
: override !== undefined
? [
"-processor",
override.languageId,
...(override.compilerSpecId === undefined
? []
: ["-cspec", override.compilerSpecId]),
]
: []),
"-readOnly",
"-deleteProject",
"-log",
+1 -1
View File
@@ -105,7 +105,7 @@ describe("bounded Ghidra MIPS admission", () => {
[{ type: 1 }, "ET_EXEC"],
[{ type: 3 }, "ET_EXEC"],
[{ type: 0xffa0 }, "ET_EXEC"],
[{ flags: 0x10a23001 }, "Machine-specific"],
[{ flags: 0x10a23001 }, "separate MIPS ABI flags"],
[{ flags: 0x70001021 }, "o32"],
[{ flags: 0x70000001 }, "o32"],
[{ flags: 0x70002001 }, "o32"],
+10 -2
View File
@@ -37,7 +37,10 @@ import {
admitGhidraLanguageOverride,
readGhidraLanguageCatalog,
} from "./GhidraLanguageCatalog.js";
import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js";
import {
ghidraProcessorUnsupportedReason,
isGhidraPspTarget,
} from "./GhidraPspProfile.js";
import { resolveGhidraExtensions } from "./extensions/GhidraExtensions.js";
import {
CAPABILITIES,
@@ -149,7 +152,12 @@ export class GhidraProvider implements AnalysisProviderCandidate {
};
if (hostPlatform === "win32")
return inspectWindowsP0TargetSupport(target, diagnostics);
const mipsReason = ghidraMipsUnsupportedReason(target);
const mipsReason =
isGhidraPspTarget(target) &&
(hostPlatform !== "linux" ||
(this.installationHost?.architecture ?? process.arch) !== "x64")
? "The initial PSP Allegrex profile is verified on Linux x64 only."
: ghidraProcessorUnsupportedReason(target);
if (mipsReason !== null)
return {
status: "unsupported",
+35
View File
@@ -49,6 +49,11 @@ import {
ghidraInstallationDiagnostics,
type GhidraInstallationInspection,
} from "./GhidraInstallation.js";
import {
isGhidraPspTarget,
GHIDRA_PSP_LIMITATIONS,
} from "./GhidraPspProfile.js";
import { ghidraPspExtensionSchema } from "./GhidraPspExtension.js";
import { unverifiedGhidraBuildLimitation } from "./GhidraInstallationPolicy.js";
import { GhidraHeadlessLauncher } from "./GhidraLauncher.js";
import { attestGhidraNativeLoadImage } from "./GhidraLoadImageAttest.js";
@@ -353,6 +358,26 @@ export const createGhidraProviderClient = (input: {
);
if (!prerequisites.ok) return unavailableClient(prerequisites.error);
const committedProfile = prerequisites.value.profile;
const psp = isGhidraPspTarget(target);
const pspExtension = psp
? ghidraPspExtensionSchema.safeParse(
committedProfile.parameters.psp_extension,
)
: null;
if (
pspExtension !== null &&
(!pspExtension.success ||
committedProfile.parameters.language_id !== "Allegrex:LE:32:default" ||
committedProfile.parameters.loader !== "PspElfLoader")
)
return unavailableClient(
new ProviderAdapterError("ghidra", "open_binary", {
diagnostics: {
reason:
"Resolve the supported PSP profile and installed Allegrex extension before opening the session.",
},
}),
);
const extensionProfile = ghidraExtensionSchema
.array()
.safeParse(committedProfile.parameters.analysis_extensions ?? []);
@@ -444,6 +469,9 @@ export const createGhidraProviderClient = (input: {
bridgeScriptPath: fileURLToPath(
new URL("../../bridge/ghidra/ReaGhidraBridge.java", import.meta.url),
),
...(pspExtension?.success === true
? { pspExtension: pspExtension.data }
: {}),
...(target.format === "dos-mz" ? { dosMz: true } : {}),
...(target.format === "dos-com" ? { dosCom: true } : {}),
...(languageOverride === undefined ? {} : { languageOverride }),
@@ -475,6 +503,12 @@ export const createGhidraProviderClient = (input: {
? {}
: { expectedCompilerSpecId: languageOverride.compilerSpecId }),
}),
...(psp
? {
expectedLanguageId: "Allegrex:LE:32:default",
expectedCompilerSpecId: "default",
}
: {}),
...(context === undefined ? {} : { runId: context.runId }),
logger: logger.child({ layer: "ghidra-bridge" }),
});
@@ -510,6 +544,7 @@ export const createGhidraProviderClient = (input: {
const sessionLimitations = (): readonly string[] => [
...providerLimitations,
...targetLimitations,
...(psp ? GHIDRA_PSP_LIMITATIONS : []),
...ghidraExtensionLimitations(extensions),
...seedLimitations,
...(releaseLimitation === undefined ? [] : [releaseLimitation]),
+212
View File
@@ -0,0 +1,212 @@
import { createHash } from "node:crypto";
import { lstat, opendir, realpath } from "node:fs/promises";
import { isAbsolute, join } from "node:path";
import { z } from "zod";
import {
openRegularFile,
sameRegularFileState,
} from "../filesystem/RegularFile.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
} from "../domain/analysisErrorCore.js";
import type { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import type { GhidraInstallationInspection } from "./GhidraInstallation.js";
/** Installed processor code and data, distinct from REA's analysis-extension ABI. */
export const ghidraPspExtensionSchema = z.strictObject({
id: z.literal("ghidra-allegrex"),
root: z.string().refine(isAbsolute),
ghidra_version: z.string().min(1),
sha256: z.string().regex(/^[a-f0-9]{64}$/u),
});
export type GhidraPspExtension = z.infer<typeof ghidraPspExtensionSchema>;
// Resource bounds for scanning a caller-installed extension, not target limits.
const MAX_ENTRIES = 1024;
const MAX_BYTES = 64 * 1024 * 1024;
const MAX_DEPTH = 16;
const REQUIRED = [
"Module.manifest",
"extension.properties",
"data/languages/allegrex.ldefs",
"data/languages/allegrex.sla",
"data/languages/allegrex.pspec",
"data/languages/allegrex.cspec",
];
/** Read-only fingerprint of all installed files; no home scanning or installation. */
export const inspectGhidraPspExtension = async (
root: string,
ghidraVersion: string,
signal?: AbortSignal,
): Promise<GhidraPspExtension> => {
signal?.throwIfAborted();
if (!isAbsolute(root) || (await realpath(root)) !== root)
throw new Error(
"The installed Allegrex directory must be a canonical non-symlink path.",
);
const files: string[] = [];
let entries = 1; // The root itself consumes one inventory entry.
const visit = async (relative: string, depth: number): Promise<void> => {
signal?.throwIfAborted();
if (depth > MAX_DEPTH)
throw new Error(
"Allegrex extension directory nesting exceeds the inspection limit.",
);
const path = join(root, relative);
const metadata = await lstat(path);
if (metadata.isDirectory()) {
const children: string[] = [];
for await (const child of await opendir(path)) {
signal?.throwIfAborted();
if (++entries > MAX_ENTRIES)
throw new Error(
"Allegrex extension inventory exceeds the inspection limit.",
);
children.push(child.name);
}
children.sort();
for (const child of children)
await visit(
relative === "" ? child : `${relative}/${child}`,
depth + 1,
);
} else if (metadata.isFile()) {
files.push(relative);
} else
throw new Error(
`Allegrex extension entries must be regular files or directories: ${relative}`,
);
};
await visit("", 0);
for (const required of REQUIRED)
if (!files.includes(required))
throw new Error(`Missing installed Allegrex component: ${required}`);
if (!files.some((path) => path.startsWith("lib/") && path.endsWith(".jar")))
throw new Error("The installed Allegrex extension has no loader JAR.");
const hash = createHash("sha256");
let bytesRead = 0;
let properties = "";
for (const relative of files) {
signal?.throwIfAborted();
const path = join(root, relative);
const handle = await openRegularFile(path, { symlinks: "reject", signal });
try {
const before = await handle.stat();
if (
!Number.isSafeInteger(before.size) ||
before.size > MAX_BYTES - bytesRead
)
throw new Error(
"Allegrex extension bytes exceed the inspection limit.",
);
if (relative === "extension.properties" && before.size > 65536)
throw new Error(
"Allegrex extension properties exceed the inspection limit.",
);
hash.update(JSON.stringify([relative, before.size]));
const buffer = Buffer.alloc(Math.min(65536, before.size + 1));
let offset = 0;
const chunks: Buffer[] = [];
while (offset < before.size) {
signal?.throwIfAborted();
const read = await handle.read(
buffer,
0,
Math.min(buffer.length, before.size - offset),
offset,
);
if (read.bytesRead === 0)
throw new Error(`Truncated Allegrex component: ${relative}`);
const chunk = buffer.subarray(0, read.bytesRead);
hash.update(chunk);
if (relative === "extension.properties")
chunks.push(Buffer.from(chunk));
offset += read.bytesRead;
}
if (
!sameRegularFileState(before, await handle.stat()) ||
!sameRegularFileState(before, await lstat(path))
)
throw new Error(
`Allegrex component changed during inspection: ${relative}`,
);
bytesRead += before.size;
if (relative === "extension.properties")
properties = Buffer.concat(chunks).toString("utf8");
} finally {
await handle.close();
}
}
// Ghidra's extension.properties version is the compatible Ghidra version,
// not the extension release (e.g. v21.4); content identity supplies the latter.
const property = (name: string): string => {
const values = properties.split(/\r?\n/u).flatMap((line) => {
const match = /^\s*([A-Za-z][A-Za-z0-9]*)\s*=\s*(.*?)\s*$/u.exec(line);
return match?.[1] === name ? [match[2] ?? ""] : [];
});
if (values.length !== 1)
throw new Error(`Allegrex extension must declare exactly one ${name}.`);
return values[0] ?? "";
};
if (
property("name") !== "ghidra-allegrex" ||
property("version") !== ghidraVersion
)
throw new Error(
`Allegrex extension metadata does not match the selected Ghidra ${ghidraVersion}; install or rebuild a compatible extension.`,
);
signal?.throwIfAborted();
return {
id: "ghidra-allegrex",
root,
ghidra_version: ghidraVersion,
sha256: hash.digest("hex"),
};
};
/** Resolve prerequisites before opening or admitting cached PSP analysis. */
export const resolveGhidraPspExtension = async (
installation: GhidraInstallationInspection,
signal?: AbortSignal,
): Promise<Result<GhidraPspExtension, AnalysisError>> => {
if (signal?.aborted)
return err(new AnalysisCancelledError("resolve_analysis_profile"));
try {
if (
installation.status !== "available" ||
installation.platform !== "linux" ||
installation.architecture !== "x64"
)
throw new Error(
"The initial PSP profile requires a supported Linux x64 Ghidra installation.",
);
const root = join(
await realpath(installation.installDir),
"Ghidra",
"Extensions",
"ghidra-allegrex",
);
return ok(
await inspectGhidraPspExtension(
root,
installation.providerVersion,
signal,
),
);
} catch (cause: unknown) {
if (signal?.aborted)
return err(new AnalysisCancelledError("resolve_analysis_profile"));
const reason = `PSP_EXTENSION_UNAVAILABLE: ${cause instanceof Error ? cause.message : String(cause)} Use a compatible caller-installed ghidra-allegrex under GHIDRA_INSTALL_DIR/Ghidra/Extensions; REA does not install it.`;
return err(
new AnalysisCapabilityUnavailableError(
"ghidra",
"resolve_analysis_profile",
reason,
{ cause, userMessage: reason },
),
);
}
};
+60
View File
@@ -0,0 +1,60 @@
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { ghidraMipsUnsupportedReason } from "./GhidraMipsProfile.js";
/** Allegrex's ELF machine declaration, not a filename or inferred instruction. */
export const isGhidraPspTarget = (target: BinaryTarget): boolean =>
target.kind === "executable" &&
target.format === "elf" &&
target.architecture === "mips" &&
target.mips !== undefined &&
(target.mips.flags & 0x00ff0000) === 0x00a20000;
/** PSP has its own verified interpretation; generic MIPS rules remain unchanged. */
export const ghidraProcessorUnsupportedReason = (
target: BinaryTarget,
): string | null => {
if (
target.kind !== "executable" ||
target.architecture !== "mips" ||
!isGhidraPspTarget(target)
)
return ghidraMipsUnsupportedReason(target);
const metadata = target.mips;
if (metadata === undefined) return "PSP analysis requires ELF metadata.";
if (metadata.elfClass !== 32 || metadata.byteOrder !== "little")
return "The PSP Allegrex profile requires little-endian ELF32.";
if (metadata.type !== 2)
return "The PSP Allegrex profile supports static ET_EXEC only; PRX, shared and relocatable images require separate verification.";
// PSPSDK emits MIPS-II / Allegrex / EABI32 with NOREORDER. Do not accept
// unknown producer flags or treat this as proof of instruction semantics.
if (metadata.flags !== 0x10a23001)
return "The PSP Allegrex profile requires the verified PSPSDK ELF declaration 0x10a23001; other ISA/ABI/ASE flags are not inferred.";
const abi = metadata.abiFlags;
if (abi === undefined || abi === null)
return "The PSP Allegrex profile requires an inspected separate MIPS ABI flags record; missing declarations are not inferred.";
// PSPDEV v20261001: GNU readelf 2.44 and the 24-byte record agree on
// MIPS II, 32-bit registers, single-precision hard float and flags1=1.
// This is a bounded producer declaration, not a claim about VFPU semantics.
if (
abi.version !== 0 ||
abi.isaLevel !== 2 ||
abi.isaRevision !== 0 ||
abi.gprSize !== 1 ||
abi.cpr1Size !== 1 ||
abi.cpr2Size !== 0 ||
abi.fpAbi !== 2 ||
abi.isaExtension !== 0 ||
abi.ases !== 0 ||
abi.flags1 !== 1 ||
abi.flags2 !== 0
)
return "The separate MIPS ABI flags record does not match the verified PSP MIPS-II/32-bit/single-float declaration; other versions, register modes and extension flags require separate verification.";
return null;
};
/** Propagated with every PSP observation, including successful decompilation. */
export const GHIDRA_PSP_LIMITATIONS = [
"PSP/Allegrex analysis uses the caller-installed ghidra-allegrex extension. ELF declarations and selected instruction checks are not proof of runtime semantics.",
"ghidra-allegrex does not model VFPU prefix effects completely in decompiled output. Verify vector behavior independently; pseudocode is not an execution oracle.",
"This PSP profile covers static ELF32 ET_EXEC only. PRX relocations, PBP/ISO containers, live overlays and PSP emulation are outside this verified lane.",
] as const;
@@ -0,0 +1,506 @@
import { mkdir, rename, symlink, truncate, writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { describe, expect, it, vi } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { parseConfig } from "../../../src/config/parseConfig.js";
import { silentLogger } from "../../../src/logger.js";
import type {
BinaryTarget,
MipsAbiFlags,
} from "../../../src/domain/binaryTargetTypes.js";
import { parseBinaryTarget } from "../../../src/application/BinaryTargetResolver.js";
import { GhidraProvider } from "../../../src/ghidra/GhidraProvider.js";
import { ghidraProcessorUnsupportedReason } from "../../../src/ghidra/GhidraPspProfile.js";
import { inspectGhidraPspExtension } from "../../../src/ghidra/GhidraPspExtension.js";
import {
GhidraHeadlessLauncher,
ghidraHeadlessArguments,
} from "../../../src/ghidra/GhidraLauncher.js";
import type { GhidraInstallationHost } from "../../../src/ghidra/GhidraInstallation.js";
// Synthetic installation files exercise the filesystem/profile boundary, not
// Allegrex execution. Only the optional real CLI/MCP lane proves tool behavior.
// Independently inspected with PSPDEV v20261001 / GNU readelf 2.44.
const pspAbiFlags: MipsAbiFlags = {
version: 0,
isaLevel: 2,
isaRevision: 0,
gprSize: 1,
cpr1Size: 1,
cpr2Size: 0,
fpAbi: 2,
isaExtension: 0,
ases: 0,
flags1: 1,
flags2: 0,
};
const target = {
kind: "executable",
format: "elf",
architecture: "mips",
availableArchitectures: ["mips"],
path: "/caller/psp.elf",
sha256: "a".repeat(64),
mips: {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
abiFlags: pspAbiFlags,
},
} satisfies BinaryTarget;
const host: GhidraInstallationHost = {
platform: "linux",
architecture: "x64",
readText: () => "application.version=12.1.3\n",
executable: () => true,
probeJava: () => ({
version: "21.0.12",
major: 21,
home: "/jdk-21",
bits: 64,
runtime: "jdk",
}),
};
async function installation() {
const directory = await createTestTempDirectory("rea-psp-profile-");
const root = join(directory, "Ghidra", "Extensions", "ghidra-allegrex");
const contents = {
"Module.manifest": "",
"extension.properties": "name=ghidra-allegrex\nversion=12.1.3\n",
"data/languages/allegrex.ldefs": "synthetic language definition",
"data/languages/allegrex.sla": "synthetic compiled processor",
"data/languages/allegrex.pspec": "synthetic processor specification",
"data/languages/allegrex.cspec": "synthetic compiler specification",
"lib/extension.jar": "synthetic loader artifact",
};
for (const [path, text] of Object.entries(contents)) {
await mkdir(dirname(join(root, path)), { recursive: true });
await writeFile(join(root, path), text);
}
await mkdir(join(directory, "support"));
const parsed = parseConfig({ GHIDRA_INSTALL_DIR: directory });
if (!parsed.ok) throw parsed.error;
return {
directory,
root,
config: parsed.value,
provider: new GhidraProvider(parsed.value, silentLogger, {}, host),
};
}
describe.skipIf(process.platform === "win32")(
"PSP installed-extension and profile boundary",
() => {
it("binds source interpretation and installed content, not mtimes or discovery order", async () => {
const { provider, root } = await installation();
expect(provider.inspectTargetSupport(target).status).toBe("supported");
const first = await provider.resolveAnalysisProfile(target);
if (!first.ok) throw first.error;
const again = await provider.resolveAnalysisProfile(target);
if (!again.ok) throw again.error;
expect(again.value.profile?.digest).toBe(first.value.profile?.digest);
expect(first.value.profile?.parameters).toMatchObject({
architecture: "mips",
language_id: "Allegrex:LE:32:default",
compiler_spec_id: "default",
loader: "PspElfLoader",
mips_support_lane: "psp-elf32-exec-eabi32-allegrex-v2",
mips_elf: {
elf_class: 32,
byte_order: "little",
type: 2,
flags: 0x10a23001,
abi_flags: {
version: 0,
isa_level: 2,
isa_revision: 0,
gpr_size: 1,
cpr1_size: 1,
cpr2_size: 0,
fp_abi: 2,
isa_extension: 0,
ases: 0,
flags1: 1,
flags2: 0,
},
},
psp_extension: {
id: "ghidra-allegrex",
root,
ghidra_version: "12.1.3",
sha256: expect.stringMatching(/^[a-f0-9]{64}$/u),
},
});
await writeFile(
join(root, "data/languages/allegrex.sla"),
"changed processor semantics",
);
const changed = await provider.resolveAnalysisProfile(target);
if (!changed.ok) throw changed.error;
expect(changed.value.profile?.digest).not.toBe(
first.value.profile?.digest,
);
});
it("fails before starting a provider after resolved extension bytes change", async () => {
const { directory, root } = await installation();
const extension = await inspectGhidraPspExtension(root, "12.1.3");
await writeFile(join(root, "lib/extension.jar"), "changed loader");
const spawn = vi.fn();
const launcher = new GhidraHeadlessLauncher({
environment: {},
analyzeHeadlessPath: join(directory, "support/analyzeHeadless"),
javaHome: "/jdk-21",
bridgeScriptPath: "/packaged/ReaGhidraBridge.java",
pspExtension: extension,
spawnProcess: spawn,
});
const launched = await launcher.launch({
runtimeRoot: join(directory, "runtime"),
transport: "unix-socket",
endpointPath: join(directory, "socket"),
token: "test-only-token",
runId: "00000000-0000-4000-8000-000000000001",
targetPath: target.path,
targetSha256: target.sha256,
providerVersion: "12.1.3",
profileDigest: "b".repeat(64),
});
expect(launched.ok).toBe(false);
if (launched.ok)
throw new Error("Changed extension reached provider startup");
expect(launched.error.cause).toBeInstanceOf(Error);
expect(String(launched.error.cause)).toContain(
"changed since profile resolution",
);
expect(spawn).not.toHaveBeenCalled();
});
it("requires a fresh extension when a saved profile's installation has disappeared", async () => {
const { provider, root } = await installation();
const before = await provider.resolveAnalysisProfile(target);
if (!before.ok) throw before.error;
await rename(root, `${root}-removed`);
const missing = await provider.resolveAnalysisProfile(target);
expect(missing.ok).toBe(false);
if (missing.ok) throw new Error("Missing installation was accepted");
expect(missing.error.message).toContain("PSP_EXTENSION_UNAVAILABLE");
});
it.each([
["name=other\nversion=12.1.3\n", "does not match"],
["name=ghidra-allegrex\nversion=12.1.4\n", "does not match"],
[
"name=ghidra-allegrex\nversion=12.1.3\nversion=12.1.3\n",
"exactly one version",
],
])(
"rejects incompatible or ambiguous extension metadata %s",
async (properties, reason) => {
const { root } = await installation();
await writeFile(join(root, "extension.properties"), properties);
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
reason,
);
},
);
it("does not treat an uninstall marker or missing compiled language as installed", async () => {
const { root } = await installation();
await rename(
join(root, "Module.manifest"),
join(root, "Module.manifest.uninstalled"),
);
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
"Module.manifest",
);
});
it("rejects symlinked code instead of hashing an untracked external producer", async () => {
const { root, directory } = await installation();
const path = join(root, "lib/extension.jar");
await rename(path, join(directory, "external.jar"));
await symlink(join(directory, "external.jar"), path);
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
"regular files",
);
});
it("bounds sparse extension input before reading or allocating its declared extent", async () => {
const { root } = await installation();
await truncate(join(root, "lib/extension.jar"), 64 * 1024 * 1024 + 1);
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
"inspection limit",
);
});
},
);
describe.skipIf(process.platform === "win32")(
"PSP extension inventory bounds",
() => {
it.each([1, 2])(
"bounds the total inventory across %i directories",
async (directories) => {
const { root } = await installation();
for (let directory = 0; directory < directories; directory++) {
const path = join(root, `inventory-${directory}`);
await mkdir(path);
await Promise.all(
Array.from({ length: 1024 / directories }, (_, index) =>
writeFile(join(path, `entry-${index}`), ""),
),
);
}
await expect(inspectGhidraPspExtension(root, "12.1.3")).rejects.toThrow(
"inventory exceeds the inspection limit",
);
},
);
},
);
describe.skipIf(process.platform === "win32")(
"PSP admission failures and explicit loader",
() => {
it("preserves already-aborted cancellation rather than reporting missing prerequisites", async () => {
const { provider } = await installation();
const result = await provider.resolveAnalysisProfile(target, {
signal: AbortSignal.abort(),
});
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Cancellation was ignored");
expect(result.error._tag).toBe("AnalysisCancelledError");
});
it("does not broaden the separately verified host platforms", async () => {
const { config } = await installation();
for (const platform of ["darwin", "win32"] as const) {
const provider = new GhidraProvider(
config,
silentLogger,
{},
{ ...host, platform },
);
expect(provider.inspectTargetSupport(target).status).toBe(
"unsupported",
);
}
});
it.each([
[{ byteOrder: "big" }, "little-endian"],
[{ elfClass: 64 }, "ELF32"],
[{ type: 0xffa0 }, "ET_EXEC"],
[{ flags: 0x12a23001 }, "0x10a23001"],
[{ flags: 0x10a23021 }, "0x10a23001"],
])(
"refuses unverified PSP declarations %j before extension reads",
async (change, reason) => {
const { provider } = await installation();
const altered = {
...target,
mips: { ...target.mips, ...change },
} as BinaryTarget;
expect(ghidraProcessorUnsupportedReason(altered)).toContain(reason);
expect(provider.inspectTargetSupport(altered).status).toBe(
"unsupported",
);
const result = await provider.resolveAnalysisProfile(altered);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Invalid target was admitted");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
},
);
},
);
describe.skipIf(process.platform === "win32")(
"PSP inspected ABI declaration boundary",
() => {
it("retains independently inspected PSP ABI bytes through the real target reader", async () => {
const { directory, provider } = await installation();
const abiOffset = 8192;
const bytes = Buffer.alloc(abiOffset + 24);
bytes.set([0x7f, 0x45, 0x4c, 0x46, 1, 1, 1]);
bytes.writeUInt16LE(2, 16);
bytes.writeUInt16LE(8, 18);
bytes.writeUInt32LE(1, 20);
bytes.writeUInt32LE(52, 28);
bytes.writeUInt32LE(0x10a23001, 36);
bytes.writeUInt16LE(52, 40);
bytes.writeUInt16LE(32, 42);
bytes.writeUInt16LE(1, 44);
bytes.writeUInt32LE(0x70000003, 52);
bytes.writeUInt32LE(abiOffset, 56);
bytes.writeUInt32LE(24, 68);
bytes.writeUInt32LE(24, 72);
// GNU readelf -x .MIPS.abiflags, not bytes generated by REA.
bytes.set(
Buffer.from("000002000101000200000000000000000100000000000000", "hex"),
abiOffset,
);
const path = join(directory, "psp-with-abi.elf");
await writeFile(path, bytes);
const parsed = await parseBinaryTarget(path);
if (!parsed.ok) throw parsed.error;
if (
parsed.value.kind !== "executable" ||
parsed.value.architecture !== "mips"
)
throw new Error("PSP ELF was not recognized as MIPS");
expect(parsed.value.mips.abiFlags).toEqual(pspAbiFlags);
expect(provider.inspectTargetSupport(parsed.value).status).toBe(
"supported",
);
const profile = await provider.resolveAnalysisProfile(parsed.value);
if (!profile.ok) throw profile.error;
expect(profile.value.profile?.parameters.mips_elf).toMatchObject({
flags: 0x10a23001,
abi_flags: { isa_level: 2, fp_abi: 2, flags1: 1 },
});
});
it.each([
["version", 1],
["isaLevel", 32],
["isaRevision", 1],
["gprSize", 2],
["cpr1Size", 2],
["cpr2Size", 1],
["fpAbi", 1],
["fpAbi", 3],
["fpAbi", 5],
["isaExtension", 1],
["ases", 1],
["flags1", 0],
["flags1", 3],
["flags2", 1],
] satisfies [keyof MipsAbiFlags, number][])(
"rejects unverified PSP ABI field %s=%i before extension discovery",
async (field, value) => {
const { provider, root } = await installation();
await rename(root, `${root}-unavailable`);
const altered: Extract<BinaryTarget, { architecture: "mips" }> = {
...target,
mips: {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
abiFlags: { ...pspAbiFlags, [field]: value },
},
};
expect(provider.inspectTargetSupport(altered).status).toBe(
"unsupported",
);
const result = await provider.resolveAnalysisProfile(altered);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Invalid ABI was admitted");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
expect(result.error.message).toContain(
"separate MIPS ABI flags record",
);
},
);
it.each([undefined, null])(
"does not guess an uninspected or absent PSP ABI record: %s",
async (abiFlags) => {
const { provider, root } = await installation();
await rename(root, `${root}-unavailable`);
const altered: Extract<BinaryTarget, { architecture: "mips" }> = {
...target,
mips: {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
...(abiFlags === undefined ? {} : { abiFlags }),
},
};
const result = await provider.resolveAnalysisProfile(altered);
expect(result.ok).toBe(false);
if (result.ok) throw new Error("Missing ABI was inferred");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
expect(result.error.message).toContain(
"inspected separate MIPS ABI flags record",
);
},
);
},
);
describe.skipIf(process.platform === "win32")(
"PSP explicit loader boundary",
() => {
it("rejects mixing the PSP loader with a configured generic processor", async () => {
const { directory } = await installation();
const config = parseConfig({
GHIDRA_INSTALL_DIR: directory,
REA_GHIDRA_LANGUAGE_ID: "x86:LE:64:default",
});
if (!config.ok) throw config.error;
const provider = new GhidraProvider(config.value, silentLogger, {}, host);
const result = await provider.resolveAnalysisProfile(target);
expect(result.ok).toBe(false);
if (result.ok)
throw new Error("Conflicting language override was accepted");
expect(result.error._tag).toBe("AnalysisUnsupportedTargetError");
expect(result.error.message).toContain(
"does not accept REA_GHIDRA_LANGUAGE_ID overrides",
);
});
it("keeps PSP and generic language-override launch paths mutually exclusive", () => {
const paths = {
projectRoot: "/project",
targetPath: "/target.elf",
bridgeScriptPath: "/bridge/Rea.java",
descriptorPath: "/session",
ghidraLogPath: "/log",
scriptLogPath: "/scriptlog",
};
const languageOverride = { languageId: "x86:LE:64:default" };
expect(() =>
ghidraHeadlessArguments({ ...paths, psp: true, languageOverride }),
).toThrow("PSP ELF import cannot be combined");
expect(ghidraHeadlessArguments({ ...paths, languageOverride })).toEqual(
expect.arrayContaining(["-processor", "x86:LE:64:default"]),
);
expect(ghidraHeadlessArguments({ ...paths, psp: true })).toEqual(
expect.arrayContaining([
"-loader",
"PspElfLoader",
"-processor",
"Allegrex:LE:32:default",
]),
);
});
it("forces the PSP loader/language only for the selected profile", () => {
const paths = {
projectRoot: "/project",
targetPath: "/target.elf",
bridgeScriptPath: "/bridge/Rea.java",
descriptorPath: "/session",
ghidraLogPath: "/log",
scriptLogPath: "/scriptlog",
};
expect(ghidraHeadlessArguments({ ...paths, psp: true })).toEqual(
expect.arrayContaining([
"-loader",
"PspElfLoader",
"-processor",
"Allegrex:LE:32:default",
"-cspec",
"default",
]),
);
expect(ghidraHeadlessArguments(paths)).not.toContain(
"Allegrex:LE:32:default",
);
});
},
);
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import { it } from "vitest";
import { parsePspReadelfAbi } from "./psp-fixture.mjs";
// GNU readelf 2.44 output from the maintained PSPDEV v20261001 fixture.
const observed = `MIPS ABI Flags Version: 0
ISA: MIPS2
GPR size: 32
CPR1 size: 32
CPR2 size: 0
FP ABI: Hard float (single precision)
ISA Extension: None
ASEs:
None
FLAGS 1: 00000001
FLAGS 2: 00000000
`;
it("reads the independent PSP producer's single-float declaration", () => {
assert.deepEqual(parsePspReadelfAbi(observed), {
version: 0,
isaLevel: 2,
isaRevision: 0,
gprSize: 1,
cpr1Size: 1,
cpr2Size: 0,
fpAbi: 2,
isaExtension: 0,
ases: 0,
flags1: 1,
flags2: 0,
});
});
it.each([
["ISA: MIPS2", "ISA: MIPS32r2"],
["GPR size: 32", "GPR size: 64"],
["Hard float (single precision)", "Hard float (double precision)"],
["FLAGS 1: 00000001", "FLAGS 1: 00000003"],
["FLAGS 2: 00000000", "FLAGS 2: 00000001"],
["MIPS ABI Flags Version: 0", "MIPS ABI Flags Version: 1"],
["CPR1 size: 32", "CPR1 size: 0"],
["ISA Extension: None", "ISA Extension: R5900"],
["GPR size: 32", "GPR size: 32\nGPR size: 32"],
["FLAGS 1: 00000001", ""],
])(
"rejects unsupported or ambiguous independent ABI output: %s",
(before, after) => {
assert.throws(() => parsePspReadelfAbi(observed.replace(before, after)));
},
);
@@ -0,0 +1,3 @@
import type { MipsAbiFlags } from "../../../src/domain/binaryTargetTypes.js";
export declare function parsePspReadelfAbi(text: string): MipsAbiFlags;
+212
View File
@@ -0,0 +1,212 @@
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { copyFile, readFile } from "node:fs/promises";
import { join } from "node:path";
import { promisify } from "node:util";
const execute = promisify(execFile);
const sha256 = (bytes) => createHash("sha256").update(bytes).digest("hex");
/** Build the maintained, non-PRX PSPSDK fixture, without executing the target. */
export async function buildPspFixture(directory, globalValue = 7) {
assert.ok(globalValue === 7 || globalValue === 11);
const options = {
cwd: directory,
env: { ...process.env, LC_ALL: "C", LANG: "C" },
timeout: 10000,
maxBuffer: 1024 * 1024,
};
const versions = {};
for (const command of ["make", "psp-gcc", "psp-readelf", "psp-objdump"])
versions[command] = (
await execute(command, ["--version"], options)
).stdout.trim();
const sourceDigests = {};
for (const name of ["main.c", "probe.S", "Makefile"]) {
const source = new URL(`../psp/${name}`, import.meta.url);
sourceDigests[name] = sha256(await readFile(source));
await copyFile(source, join(directory, name));
}
const build = await execute(
"make",
[`REA_PSP_GLOBAL=${globalValue}`, "rea_psp_fixture.elf"],
{ ...options, timeout: 60000 },
);
const path = join(directory, "rea_psp_fixture.elf");
const bytes = await readFile(path);
assert.ok(bytes.length >= 52);
assert.equal(bytes.toString("hex", 0, 6), "7f454c460101");
assert.equal(bytes.readUInt16LE(16), 2);
assert.equal(bytes.readUInt16LE(18), 8);
assert.equal(bytes.readUInt32LE(36), 0x10a23001);
const readelf = await execute(
"psp-readelf",
["-h", "-A", "-sW", path],
options,
);
assert.equal(readelf.stderr.trim(), "");
assert.match(readelf.stdout, /Class:\s+ELF32/u);
assert.match(readelf.stdout, /Data:\s+2's complement, little endian/u);
assert.match(readelf.stdout, /Type:\s+EXEC\b/u);
assert.match(readelf.stdout, /Flags:\s+0x10a23001\b/u);
const abiFlags = parsePspReadelfAbi(readelf.stdout);
const abiDump = await execute(
"psp-readelf",
["-x", ".MIPS.abiflags", path],
options,
);
assert.equal(abiDump.stderr.trim(), "");
const abiWords = abiDump.stdout
.split("\n")
.filter((line) => /^\s*0x[0-9a-f]+\s/u.test(line))
.flatMap((line) => line.match(/\b[0-9a-f]{8}\b/gu) ?? []);
assert.equal(
abiWords.join(""),
"000002000101000200000000000000000100000000000000",
"PSPSDK ABI bytes differ from independently inspected build intent",
);
const symbols = {};
for (const [name, type] of [
["rea_psp_entry", "FUNC"],
["rea_psp_leaf", "FUNC"],
["rea_psp_probe", "FUNC"],
["rea_psp_global", "OBJECT"],
["rea_psp_marker", "OBJECT"],
]) {
const rows = readelf.stdout
.split("\n")
.map((line) => line.trim().split(/\s+/u))
.filter((row) => row.length === 8 && row[7] === name);
assert.equal(rows.length, 1, `Expected one defined PSP symbol: ${name}`);
const row = rows[0];
assert.match(row[1], /^[0-9a-fA-F]{8}$/u);
assert.equal(row[3], type);
assert.equal(row[4], "GLOBAL");
assert.match(row[6], /^\d+$/u);
symbols[name] = `0x${Number.parseInt(row[1], 16).toString(16)}`;
}
const objdump = await execute(
"psp-objdump",
["-d", "--disassemble=rea_psp_probe", path],
options,
);
assert.equal(objdump.stderr.trim(), "");
assert.match(objdump.stdout, /\bbitrev\b/u);
return {
path,
sha256: sha256(bytes),
source_sha256: sourceDigests,
symbols,
flags: "0x10a23001",
mips: {
elfClass: 32,
byteOrder: "little",
type: 2,
flags: 0x10a23001,
abiFlags,
},
abi_record_hex_dump: abiDump.stdout,
global_value: globalValue,
versions,
readelf: readelf.stdout,
objdump: objdump.stdout,
build_stderr: build.stderr,
};
}
/** Independent GNU fields for the pinned PSPSDK fixture, not REA's parser. */
export function parsePspReadelfAbi(text) {
const field = (expression, label) => {
const matches = [...text.matchAll(expression)];
assert.equal(
matches.length,
1,
`Missing or ambiguous PSP readelf ${label}`,
);
return matches[0][1].trim();
};
const width = (name) => {
const bits = Number(
field(new RegExp(`^${name} size:\\s*(\\d+)\\s*$`, "gmu"), name),
);
assert.ok(bits === 0 || bits === 32, `Unverified PSP ${name} width`);
return bits === 32 ? 1 : 0;
};
const isaLevel = Number(field(/^ISA:\s*MIPS(\d+)\s*$/gmu, "ISA"));
assert.equal(
field(/^FP ABI:\s*(.+)$/gmu, "FP ABI"),
"Hard float (single precision)",
);
assert.equal(field(/^ISA Extension:\s*(.+)$/gmu, "ISA extension"), "None");
assert.equal(field(/^ASEs:\s*\n[ \t]*(.+)$/gmu, "ASEs"), "None");
const abi = {
version: Number(
field(/^MIPS ABI Flags Version:\s*(\d+)\s*$/gmu, "ABI version"),
),
isaLevel,
isaRevision: 0,
gprSize: width("GPR"),
cpr1Size: width("CPR1"),
cpr2Size: width("CPR2"),
fpAbi: 2,
isaExtension: 0,
ases: 0,
flags1: Number.parseInt(
field(/^FLAGS 1:\s*([\da-f]+)\s*$/gmu, "flags1"),
16,
),
flags2: Number.parseInt(
field(/^FLAGS 2:\s*([\da-f]+)\s*$/gmu, "flags2"),
16,
),
};
assert.deepEqual(
abi,
{
version: 0,
isaLevel: 2,
isaRevision: 0,
gprSize: 1,
cpr1Size: 1,
cpr2Size: 0,
fpAbi: 2,
isaExtension: 0,
ases: 0,
flags1: 1,
flags2: 0,
},
"PSPSDK ABI declaration differs from independently inspected build intent",
);
return abi;
}
/** Observations from Ghidra, not an echo of the requested profile. */
export function assertPspLoadedImage(image, targetSha256) {
assert.equal(image.executable_format, "PSP Executable (ELF)");
assert.equal(image.language_id, "Allegrex:LE:32:default");
assert.equal(image.compiler_spec_id, "default");
assert.equal(image.source_files.length, 1);
assert.equal(image.source_files[0].original_sha256, targetSha256);
assert.equal(image.source_files[0].modified_sha256, targetSha256);
}
/** Fixed source encodings and independently chosen immediate, not pseudocode. */
export function assertPspProbe(move, bitrev) {
assert.equal(move.status, "decoded");
assert.equal(move.length, 4);
assert.equal(move.bytes, "34120224");
assert.ok(
move.operands.some((operand) =>
operand.components.some(
(part) =>
part.kind === "immediate" &&
part.value !== null &&
BigInt(part.value) === 0x1234n,
),
),
);
assert.equal(bitrev.status, "decoded");
assert.equal(bitrev.length, 4);
assert.match(bitrev.mnemonic, /^bitrev$/iu);
}
+8
View File
@@ -0,0 +1,8 @@
TARGET = rea_psp_fixture
OBJS = main.o probe.o
BUILD_PRX = 0
REA_PSP_GLOBAL ?= 7
CFLAGS = -O0 -g -G0 -fno-inline -fno-builtin -DREA_PSP_GLOBAL=$(REA_PSP_GLOBAL)
ASFLAGS = -g
PSPSDK := $(shell psp-config --pspsdk-path)
include $(PSPSDK)/lib/build.mak
+20
View File
@@ -0,0 +1,20 @@
#include <pspkernel.h>
PSP_MODULE_INFO("rea_psp_fixture", 0, 1, 0);
#ifndef REA_PSP_GLOBAL
#define REA_PSP_GLOBAL 7
#endif
volatile unsigned int rea_psp_global = REA_PSP_GLOBAL;
const char rea_psp_marker[] = "rea-psp-source-owned-fixture";
unsigned int rea_psp_probe(void);
__attribute__((noinline)) unsigned int rea_psp_leaf(unsigned int value) {
return value * 3u + rea_psp_global;
}
__attribute__((noinline)) unsigned int rea_psp_entry(unsigned int selector) {
unsigned int adjusted = selector > 10u ? selector : selector + 1u;
return rea_psp_leaf(adjusted) + rea_psp_probe();
}
int main(void) {
return (int)rea_psp_entry(11u);
}
+14
View File
@@ -0,0 +1,14 @@
/* Source-owned Allegrex decoding probe. This fixture is never executed. */
.set noreorder
.text
.align 2
.globl rea_psp_probe
.type rea_psp_probe, @function
.ent rea_psp_probe
rea_psp_probe:
addiu $v0, $zero, 0x1234
bitrev $v0, $v0
jr $ra
nop
.end rea_psp_probe
.size rea_psp_probe, .-rea_psp_probe