Merge pull request #22264 from hrydgard/re-module-dump

Module dumper for reverse engineering
This commit is contained in:
Henrik Rydgård authored and GitHub committed 2026-09-08 16:07:37 -06:00
commit 47c17b5589
14 files changed
+902 -5

No files matched your search

+22
View File
@@ -20,6 +20,7 @@ for it:
| [docs/pspautotests-hardware.md](docs/pspautotests-hardware.md) | Writing a new pspautotest, and running it on a real PSP over PSPLink to record its `.expected` |
| [docs/frametest.md](docs/frametest.md) | Framedump rendering tests |
| [docs/WebSocketDebugger.md](docs/WebSocketDebugger.md) | WebSocket debugger protocol reference |
| [docs/reverse-engineering.md](docs/reverse-engineering.md) | Disassembling a firmware PRX with `--re-module`, to find out what the hardware actually does |
## General instructions
@@ -136,6 +137,27 @@ UWP/PPSSPP_UWPMain.cpp
android/jni/app-android.cpp
libretro/libretro.cpp
## Reverse-engineering the firmware
When a question about hardware behaviour can't be settled from the docs or from JPCSP - what a
field in a codec context means, what a library actually returns when a buffer runs dry - the
firmware itself can be read. `PPSSPPHeadless --re-module flash0:/kd/libmp3.prx --re-out DIR`
loads one PRX standalone and writes an annotated disassembly, the export/import tables with NIDs
resolved, and a call graph. It needs a firmware dump (`--memstick` pointing at one; PPSSPP can
unpack an updater itself with `--unpack-updater`).
Full usage, and how to accumulate names in a `.ppsym` file so the disassembly stays readable:
[docs/reverse-engineering.md](docs/reverse-engineering.md).
Two things to know before trusting what you read there:
- **Don't infer a function's arity from the registers it reads.** MIPS code routinely leaves an
argument untouched for a callee to pick up, so a function that reads only `a0` may well take
three. The per-function register evidence block flags this as `FORWARDED`; follow the callees.
- **Record how you know.** A comment saying which module and function a fact came from is worth
more than the fact alone, since the next person can re-derive it. Behavioural findings belong
in the tree; bulk transcriptions of Sony's code do not.
## Command-line parsing
All command-line parsing for both the main app and headless builds belongs in `Core/CmdLine.cpp` /
+2
View File
@@ -1297,6 +1297,8 @@ if(HEADLESS)
headless/Headless.cpp
headless/Compare.cpp
headless/Compare.h
headless/ReverseEngineer.cpp
headless/ReverseEngineer.h
)
if(SDL_LIB_TARGET)
list(APPEND HeadlessSource
+4
View File
@@ -212,6 +212,10 @@ static const CommandLineParam g_autoParams[] = {
{POFF(unpackUpdaterModel), CmdParamType::String, "unpack-updater-model", '\0', "PSP model to unpack for (01g..12g, default any)", CmdLineMode::Headless},
{POFF(unpackUpdaterFilter), CmdParamType::String, "unpack-updater-filter", '\0', "Only unpack entries under this path, e.g. flash0:/font/", CmdLineMode::Headless},
{POFF(installPkg), CmdParamType::String, "install-pkg", '\0', "Install the game update in a .pkg into DIR and exit", CmdLineMode::Headless},
{POFF(reModule), CmdParamType::String, "re-module", '\0', "Load one PRX standalone and write a reverse-engineering report, then exit", CmdLineMode::Headless},
{POFF(reOut), CmdParamType::String, "re-out", '\0', "Directory for --re-module output (default: re-out)", CmdLineMode::Headless},
{POFF(reFunc), CmdParamType::String, "re-func", '\0', "Only disassemble this function of --re-module, by name or address", CmdLineMode::Headless},
{POFF(reSyms), CmdParamType::String, "re-syms", '\0', "Apply a .ppsym file of known names before dumping --re-module", CmdLineMode::Headless},
{POFF(odsLog), CmdParamType::Bool, "odslog", 'o', "Also log through OutputDebugString (Windows)", CmdLineMode::Headless},
{POFF(generateInterpreterDispatch), CmdParamType::Bool, "generate-interpreter-dispatch", '\0', "Generate C++ interpreter dispatch code (ExecInstruction) to stdout and exit", CmdLineMode::Headless},
{POFF(resolutionScale), CmdParamType::Int, "resolution-scale", '\0', "Set the resolution scale factor"},
+12
View File
@@ -88,6 +88,18 @@ struct CommandLineOptions {
// Core/Util/PkgUnpack.h.
std::optional<std::string> installPkg;
// Headless: load one PRX standalone (no game) and write a reverse-engineering report -
// module header, exports/imports, per-function disassembly and a call graph - then exit.
// See headless/ReverseEngineer.cpp.
std::optional<std::string> reModule;
// Where the report goes. Defaults to "re-out" next to the current directory.
std::optional<std::string> reOut;
// Only disassemble this one function, by name or "0x08801234".
std::optional<std::string> reFunc;
// A .ppsym file of already-known names to apply before dumping, so the disassembly comes
// out readable. Module-relative, same format the emulator saves.
std::optional<std::string> reSyms;
std::optional<int> memReadAction;
std::optional<int> memWriteAction;
std::optional<int> breakAction;
+13
View File
@@ -247,7 +247,20 @@ DisableHLEFlags GetEffectiveDisableHLEFlags() {
}
// Note: name is the modname from prx, not the export module name!
// See SetForceRealModuleLoads.
static bool g_forceRealModuleLoads = false;
void SetForceRealModuleLoads(bool force) {
g_forceRealModuleLoads = force;
}
bool ShouldHLEModule(std::string_view modname, bool *wasDisabledManually) {
if (g_forceRealModuleLoads) {
if (wasDisabledManually) {
*wasDisabledManually = false;
}
return false;
}
if (wasDisabledManually) {
*wasDisabledManually = false;
}
+7
View File
@@ -110,6 +110,13 @@ struct HLEModuleMeta {
const HLEModuleMeta *GetHLEModuleMetaByFlag(DisableHLEFlags flag);
const HLEModuleMeta *GetHLEModuleMeta(std::string_view modname);
bool ShouldHLEModule(std::string_view modname, bool *wasDisabledManually = nullptr);
// When set, ShouldHLEModule always says no, so every module genuinely loads and runs Sony's
// code. Needed by the headless reverse-engineering dump (headless/ReverseEngineer.cpp), which
// exists precisely to look at the real thing - including modules like sceAudiocodec_Driver that
// have no DisableHLEFlags bit of their own and so can't be turned off the normal way.
// Only affects loading; imports still resolve through the HLE tables, which is what gives
// imported functions their names.
void SetForceRealModuleLoads(bool force);
bool ShouldHLEModuleByImportName(std::string_view importModuleName);
// May return nullptr
+18 -5
View File
@@ -1695,16 +1695,29 @@ static PSPModule *__KernelLoadELFFromPtr(const u8 *ptr, size_t elfSize, u32 load
u32 scanEnd = module->textEnd;
if (Memory::IsValid4AlignedRange(scanStart, scanEnd - scanStart)) {
// libent/libstub come from the module's own header, and nothing has checked that
// they land inside the range validated just above. flash0:/kd/sysmem.prx and
// loadcore.prx from a real firmware dump put them tens of megabytes past the end
// of the text. So let's clamp.
const u32 textStart = scanStart;
auto clampToText = [textStart, scanEnd](u32 addr) {
return std::min(std::max(addr, textStart), scanEnd);
};
auto scanRange = [&insertSymbols](u32 from, u32 to) {
if (from < to) {
insertSymbols = MIPSAnalyst::ScanForFunctions(from, to, insertSymbols);
}
};
// Skip the exports and imports sections, they're not code.
if (scanEnd >= std::min(modinfo->libent, modinfo->libstub)) {
insertSymbols = MIPSAnalyst::ScanForFunctions(scanStart, std::min(modinfo->libent, modinfo->libstub), insertSymbols);
scanStart = std::min(modinfo->libentend, modinfo->libstubend);
scanRange(scanStart, clampToText(std::min(modinfo->libent, modinfo->libstub)));
scanStart = clampToText(std::min(modinfo->libentend, modinfo->libstubend));
}
if (scanEnd >= std::max(modinfo->libent, modinfo->libstub)) {
insertSymbols = MIPSAnalyst::ScanForFunctions(scanStart, std::max(modinfo->libent, modinfo->libstub), insertSymbols);
scanStart = std::max(modinfo->libentend, modinfo->libstubend);
scanRange(scanStart, clampToText(std::max(modinfo->libent, modinfo->libstub)));
scanStart = clampToText(std::max(modinfo->libentend, modinfo->libstubend));
}
insertSymbols = MIPSAnalyst::ScanForFunctions(scanStart, scanEnd, insertSymbols);
scanRange(scanStart, scanEnd);
} else {
ERROR_LOG(Log::Loader, "Bad text scan range %08x-%08x", scanStart, scanEnd);
}
+1
View File
@@ -1017,6 +1017,7 @@ ifeq ($(HEADLESS),1)
LOCAL_MODULE := ppsspp_headless
LOCAL_SRC_FILES := \
$(SRC)/headless/Headless.cpp \
$(SRC)/headless/ReverseEngineer.cpp \
$(SRC)/headless/Compare.cpp
include $(BUILD_EXECUTABLE)
+81
View File
@@ -0,0 +1,81 @@
# Reverse-engineering a PSP firmware module
`PPSSPPHeadless --re-module` loads a single PRX on its own - no game, no boot - and writes a report
about it: the module header, its exports and imports with NIDs resolved to names, one annotated
disassembly file per function, and the call graph.
This is a developer tool for understanding the PSP, not something a user ever runs.
## Running it
```bash
./build/PPSSPPHeadless --memstick ~/.config/ppsspp \
--re-module flash0:/kd/libmp3.prx \
--re-out /tmp/re
```
`--re-module` takes either a host path or a PSP-style `flash0:/kd/foo.prx`, which is resolved
against the configured NAND directory - so `--memstick` (or `--nand`) has to point at a dump.
PPSSPP can produce one itself from a firmware updater with `--unpack-updater`; see
[PsarFileFormat.md](PsarFileFormat.md).
| Option | |
|---|---|
| `--re-module PATH` | The module to load. Host path, or `flash0:/kd/foo.prx`. |
| `--re-out DIR` | Where the report goes. Created if missing; defaults to `re-out`. |
| `--re-func NAME` | Only disassemble this one function, by name or `0x08801234`. |
| `--re-syms FILE` | A `.ppsym` file of names to apply first, so the disassembly reads properly. |
## What comes out
- `<module>.index.md` - header, segments, export table (library, NID, address, name), import table,
and every function with its size, caller/callee counts and whether it writes `v0`.
- `<module>/<addr>_<name>.asm` - one file per function.
- `<module>.xref.json` - the call graph, for asking "who calls this" without grepping.
Two things in the disassembly are worth knowing about:
**`lui`/`addiu` pairs are folded** and reported as the address they form, with a symbol name where
one is known (`; = 08004a10 <sampleRateTable>`). Every global and constant table is reached through
such a pair, so this is usually how you find the data a function works on.
**Each function gets a register evidence block rather than a guessed signature.** It reports, for
`a0`-`a3`, whether each was read before being written, written before being read, never touched, or
- the interesting case - *never read but still live across a call*:
```
; a0 READ before written -> used as a parameter here
; a1 never read, but live across a call -> FORWARDED from our caller
```
That last one matters because MIPS code routinely takes an argument it never touches and leaves it
in place for a callee to pick up. A tool that inferred "this function takes one argument" from the
reads alone would be wrong, and so would you. Treat `FORWARDED` as evidence that the real arity is
larger than what is read here, and settle it by looking at what the callees do with the register.
## Naming things
The loader's scan names every function it finds `z_un_<address>`, which makes for unreadable
disassembly. The tool names what it can automatically - exported functions get their name from the
NID via PPSSPP's own HLE tables, and every import stub is named after the function it resolves to -
but the rest is up to you.
Accumulated names go in a `.ppsym` file, the same module-relative format the emulator saves from
`hle.module.saveSymbols` and the ImDebugger, and are applied with `--re-syms`. Names compound: once
a function is named, every call site that reaches it reads as that name, so later functions get
progressively cheaper to work out.
Those files are keyed by module name and crc32 (`PSP/SYSTEM/SYMBOLS/<name>_<crc>.ppsym`), so a set
of names only ever attaches to the exact build of the module it was written against. The report's
header line prints the crc to match.
## Notes and caveats
- Modules load at a fixed base (`0x08000000` for kernel modules), and the index prints a `+offset`
column, so addresses can be compared against another tool's view of the same module.
- Some modules - `sysmem.prx` and `loadcore.prx` among them - have `modinfo` pointers that are file
offsets rather than addresses, so the loader's own function scan finds nothing in them. The tool
falls back to scanning the module's text range directly. Those modules are hand-written assembly
without standard prologues, so expect few, large "functions".
- Nothing is executed. The tool brings up the memory map, timing, the HLE tables and the kernel
allocators, then runs the real module loader and stops.
+13
View File
@@ -59,6 +59,7 @@
#include "Core/System.h"
#include "Core/Util/PSARUnpack.h"
#include "Core/Util/PkgUnpack.h"
#include "headless/ReverseEngineer.h"
#include "Core/WebServer.h"
#include "Core/HLE/sceUtility.h"
#include "Core/SaveState.h"
@@ -860,6 +861,18 @@ int main(int argc, const char* argv[]) {
}
g_Config.nandRootDirectory = GetSysDirectory(DIRECTORY_NAND);
coreParameter.nandRoot = g_Config.nandRootDirectory;
// Placed here rather than with the other early-exit subcommands above, because resolving a
// "flash0:/kd/foo.prx" module path needs nandRootDirectory, which is only settled just above.
if (cmdLineOptions.reModule.has_value()) {
ReverseEngineerOptions reOptions;
reOptions.modulePath = cmdLineOptions.reModule.value();
reOptions.outDir = cmdLineOptions.reOut.value_or("re-out");
reOptions.funcFilter = cmdLineOptions.reFunc.value_or("");
reOptions.symsFile = cmdLineOptions.reSyms.value_or("");
reOptions.verbose = testOptions.verbose;
return RunReverseEngineer(reOptions);
}
// Try to find the assets flash0 directory. Often this is from a subdirectory.
// This is needed for our fallback fonts.
+2
View File
@@ -354,6 +354,7 @@
</ClCompile>
<ClCompile Include="..\Windows\W32Util\Misc.cpp" />
<ClCompile Include="Compare.cpp" />
<ClCompile Include="ReverseEngineer.cpp" />
<ClCompile Include="Headless.cpp">
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">NotUsing</PrecompiledHeader>
<PrecompiledHeader Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">NotUsing</PrecompiledHeader>
@@ -416,6 +417,7 @@
<ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Release|x64'">true</ExcludedFromBuild>
</ClInclude>
<ClInclude Include="Compare.h" />
<ClInclude Include="ReverseEngineer.h" />
<ClInclude Include="SDLHeadlessGLGraphicsContext.h">
<ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Debug|Win32'">true</ExcludedFromBuild>
<ExcludedFromBuild Condition="'$(Configuration)|$(Platform)'=='Release|Win32'">true</ExcludedFromBuild>
+2
View File
@@ -3,6 +3,7 @@
<ItemGroup>
<ClCompile Include="Headless.cpp" />
<ClCompile Include="Compare.cpp" />
<ClCompile Include="ReverseEngineer.cpp" />
<ClCompile Include="..\ext\glew\glew.c" />
<ClCompile Include="..\Windows\GPU\WindowsGLContext.cpp">
<Filter>Windows</Filter>
@@ -35,6 +36,7 @@
</ItemGroup>
<ItemGroup>
<ClInclude Include="Compare.h" />
<ClInclude Include="ReverseEngineer.h" />
<ClInclude Include="WindowsHeadlessHost.h">
<Filter>Windows</Filter>
</ClInclude>
+688
View File
@@ -0,0 +1,688 @@
// Copyright (c) 2026- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
// Static reverse-engineering dump for a single PRX, with no game running.
//
// Brings up just enough of the emulator to run the real module loader (decrypt, decompress,
// relocate, resolve imports/exports, scan for functions), then writes a report: an index of
// the module's exports/imports/functions, one annotated disassembly file per function, and a
// call graph.
//
// Deliberately reuses the emulator's own loader rather than parsing PRXes a second time - a
// separate parser would drift from the one that actually runs.
#include <algorithm>
#include <cstdio>
#include <cstring>
#include <map>
#include <set>
#include <string>
#include <vector>
#include "Common/File/FileUtil.h"
#include "Common/File/Path.h"
#include "Common/StringUtils.h"
#include "Core/Config.h"
#include "Core/CoreTiming.h"
#include "Core/Debugger/DebugInterface.h"
#include "Core/Debugger/DisassemblyManager.h"
#include "Core/Debugger/SymbolMap.h"
#include "Core/FileSystems/DirectoryFileSystem.h"
#include "Core/FileSystems/MetaFileSystem.h"
#include "Core/HLE/HLE.h"
#include "Core/HLE/sceKernel.h"
#include "Core/HLE/sceKernelMemory.h"
#include "Core/HLE/sceKernelModule.h"
#include "Core/MIPS/MIPS.h"
#include "Core/MIPS/MIPSDebugInterface.h"
#include "Core/MIPS/MIPSAnalyst.h"
#include "Core/MIPS/MIPSTables.h"
#include "Core/MemMap.h"
#include "Core/System.h"
#include "headless/ReverseEngineer.h"
namespace {
// MIPS register names, in encoding order.
const char *const kRegNames[32] = {
"zero", "at", "v0", "v1", "a0", "a1", "a2", "a3",
"t0", "t1", "t2", "t3", "t4", "t5", "t6", "t7",
"s0", "s1", "s2", "s3", "s4", "s5", "s6", "s7",
"t8", "t9", "k0", "k1", "gp", "sp", "fp", "ra",
};
enum {
REG_V0 = 2,
REG_A0 = 4,
REG_A3 = 7,
REG_SP = 29,
REG_RA = 31,
};
// What one function does with the registers it was handed. We report evidence rather than a
// signature: MIPS callers routinely leave an argument in place for a callee to pick up, so a
// register this function never touches can still be a parameter it is passing on. Deciding the
// real arity means looking at the whole call chain, which is a judgement call for a human, not
// something to guess here.
struct RegEvidence {
bool readBeforeWritten[32] = {};
bool written[32] = {};
// Call sites where an argument register still held whatever the caller left in it.
std::map<int, std::vector<u32>> forwardedAt;
// Call sites where this function set the argument register itself.
std::map<int, std::vector<u32>> setLocallyAt;
bool writesV0 = false;
bool hasCalls = false;
bool hasIndirectCalls = false;
};
struct FuncInfo {
u32 start = 0;
u32 size = 0;
std::string name;
std::set<u32> callees;
std::set<u32> callers;
bool indirectCallees = false;
RegEvidence regs;
};
bool IsJal(u32 op) {
return (op >> 26) == 3;
}
bool IsJalr(u32 op) {
return (op >> 26) == 0 && (op & 0x3f) == 9;
}
u32 JumpTarget(u32 addr, u32 op) {
return (addr & 0xF0000000) | ((op & 0x03FFFFFF) << 2);
}
// Applies one instruction's register reads and writes to the evidence being accumulated.
void ApplyRegEffects(u32 op, RegEvidence *ev) {
const MIPSInfo info = MIPSGetInfo(MIPSOpcode(op));
const int rs = (op >> 21) & 0x1f;
const int rt = (op >> 16) & 0x1f;
const int rd = (op >> 11) & 0x1f;
if (info & IN_RS) {
if (!ev->written[rs]) {
ev->readBeforeWritten[rs] = true;
}
}
if (info & IN_RT) {
if (!ev->written[rt]) {
ev->readBeforeWritten[rt] = true;
}
}
if (info & OUT_RT) {
ev->written[rt] = true;
}
if (info & OUT_RD) {
ev->written[rd] = true;
}
if (info & OUT_RA) {
ev->written[REG_RA] = true;
}
if (((info & OUT_RT) && rt == REG_V0) || ((info & OUT_RD) && rd == REG_V0)) {
ev->writesV0 = true;
}
}
// Walks a function once, collecting the call graph and the register evidence.
//
// Delay slots matter here: the instruction after a jal executes *before* the call, so a
// "move a0, s0" sitting in the delay slot is setting up that call's argument, not the next
// one's. Getting this backwards would report an argument as forwarded when it was set locally.
void AnalyzeFunction(FuncInfo *func) {
RegEvidence &ev = func->regs;
const u32 end = func->start + func->size;
for (u32 addr = func->start; addr < end; addr += 4) {
if (!Memory::IsValidAddress(addr)) {
break;
}
const u32 op = Memory::Read_Instruction(addr).encoding;
const bool jal = IsJal(op);
const bool jalr = IsJalr(op);
if (!jal && !jalr) {
ApplyRegEffects(op, &ev);
continue;
}
// A call. The delay slot runs first.
const u32 delayAddr = addr + 4;
if (delayAddr < end && Memory::IsValidAddress(delayAddr)) {
ApplyRegEffects(Memory::Read_Instruction(delayAddr).encoding, &ev);
}
ev.hasCalls = true;
for (int reg = REG_A0; reg <= REG_A3; reg++) {
if (ev.written[reg]) {
ev.setLocallyAt[reg].push_back(addr);
} else {
ev.forwardedAt[reg].push_back(addr);
}
}
if (jal) {
func->callees.insert(JumpTarget(addr, op));
} else {
ev.hasIndirectCalls = true;
func->indirectCallees = true;
}
ApplyRegEffects(op, &ev);
// v0 is clobbered by the call, and a0-a3 are caller-saved - but for our purposes the
// interesting question is only what the caller left in place, so nothing to reset.
addr += 4; // Skip the delay slot; already accounted for.
}
}
// Names an address formed by a lui/addiu pair, if anything is known about it.
std::string DescribeAddr(u32 addr) {
const std::string label = g_symbolMap->GetLabelString(addr);
if (!label.empty()) {
return " <" + label + ">";
}
return "";
}
std::string SanitizeForFilename(std::string_view name) {
std::string out;
out.reserve(name.size());
for (char c : name) {
if (isalnum((unsigned char)c) || c == '_' || c == '.' || c == '-') {
out.push_back(c);
} else {
out.push_back('_');
}
}
return out;
}
// Brings up the minimum needed to load a module: memory map, timing, HLE tables (so imports
// resolve to named syscalls), the kernel memory allocators and the object pool. Notably this
// does *not* start threads, the GPU or any of the HLE subsystems - nothing is going to run.
bool InitMinimalPSP() {
Memory::g_MemorySize = Memory::RAM_DOUBLE_SIZE;
Memory::g_PSPModel = PSP_MODEL_SLIM;
if (g_symbolMap) {
delete g_symbolMap;
}
g_symbolMap = new SymbolMap();
MIPSAnalyst::Reset();
if (!Memory::Init(Memory::MemMapSetupFlags::Default)) {
fprintf(stderr, "re: memory init failed\n");
return false;
}
mipsr4k.Reset();
CoreTiming::Init(&mipsr4k);
HLEInit();
kernelObjects.Clear();
__KernelMemoryInit();
return true;
}
void ShutdownMinimalPSP() {
__KernelMemoryShutdown();
kernelObjects.Clear();
HLEShutdown();
CoreTiming::Shutdown();
Memory::Shutdown();
delete g_symbolMap;
g_symbolMap = nullptr;
}
// Accepts either a host path or a PSP-style "flash0:/kd/foo.prx", which is resolved against the
// configured NAND directory so firmware modules can be named the way they are on the PSP.
Path ResolveModulePath(const std::string &input) {
if (startsWithNoCase(input, "flash0:/") || startsWithNoCase(input, "flash0:")) {
std::string rest = input.substr(input.find(':') + 1);
while (!rest.empty() && (rest[0] == '/' || rest[0] == '\\')) {
rest = rest.substr(1);
}
return g_Config.nandRootDirectory / "flash0" / rest;
}
return Path(input);
}
void WriteRegEvidence(FILE *f, const FuncInfo &func) {
const RegEvidence &ev = func.regs;
fprintf(f, "; Register evidence (NOT a signature - see below):\n");
for (int reg = REG_A0; reg <= REG_A3; reg++) {
const char *name = kRegNames[reg];
std::string verdict;
if (ev.readBeforeWritten[reg]) {
verdict = "READ before written -> used as a parameter here";
} else if (!ev.forwardedAt.count(reg) && !ev.written[reg]) {
verdict = "never touched";
} else if (!ev.readBeforeWritten[reg] && ev.forwardedAt.count(reg)) {
verdict = "never read, but live across a call -> FORWARDED from our caller";
} else if (ev.written[reg]) {
verdict = "written before any read -> set up locally";
} else {
verdict = "unclear";
}
fprintf(f, "; %-4s %s\n", name, verdict.c_str());
auto fwd = ev.forwardedAt.find(reg);
if (fwd != ev.forwardedAt.end() && !fwd->second.empty()) {
fprintf(f, "; forwarded at:");
for (size_t i = 0; i < fwd->second.size() && i < 8; i++) {
fprintf(f, " %08x", fwd->second[i]);
}
if (fwd->second.size() > 8) {
fprintf(f, " (+%d more)", (int)(fwd->second.size() - 8));
}
fprintf(f, "\n");
}
}
fprintf(f, "; %-4s %s\n", "v0", ev.writesV0 ? "written -> returns a value" : "never written -> returns nothing");
if (ev.hasIndirectCalls) {
fprintf(f, "; note: has indirect calls (jalr) - callee list is incomplete\n");
}
fprintf(f, ";\n");
fprintf(f, "; A register this function never reads can still be a parameter: MIPS code often\n");
fprintf(f, "; leaves an argument untouched for a callee to pick up. Treat 'FORWARDED' as\n");
fprintf(f, "; evidence the real arity is larger than what is read here, and settle it by\n");
fprintf(f, "; looking at what the callees do with it.\n");
}
} // namespace
int RunReverseEngineer(const ReverseEngineerOptions &opts) {
const Path modulePath = ResolveModulePath(opts.modulePath);
if (!File::Exists(modulePath)) {
fprintf(stderr, "re: no such file: %s\n", modulePath.c_str());
return 1;
}
const Path outDir(opts.outDir);
if (!File::CreateFullPath(outDir)) {
fprintf(stderr, "re: couldn't create output directory: %s\n", outDir.c_str());
return 1;
}
// Load every module for real, whatever HLE implementations we may have for it - the whole
// point is to look at Sony's code, not to have it quietly replaced by ours.
g_Config.iDisableHLE = -1;
g_Config.iForceEnableHLE = 0;
SetForceRealModuleLoads(true);
g_Config.bAutoSaveLoadSymbols = false;
if (!InitMinimalPSP()) {
return 1;
}
// Mount the containing directory so the normal file-backed loader path can be used.
const std::string dir = modulePath.GetDirectory();
const std::string filename = modulePath.GetFilename();
auto hostFs = std::make_shared<DirectoryFileSystem>(&pspFileSystem, Path(dir), FileSystemFlags::FLASH);
pspFileSystem.Mount("host0:", hostFs);
std::string error;
const SceUID uid = KernelLoadModule("host0:/" + filename, &error);
if (uid < 0) {
fprintf(stderr, "re: failed to load %s: %s\n", modulePath.c_str(), error.c_str());
ShutdownMinimalPSP();
return 1;
}
u32 kerr = 0;
PSPModule *module = kernelObjects.Get<PSPModule>(uid, kerr);
if (!module) {
fprintf(stderr, "re: loaded module vanished (uid %d)\n", uid);
ShutdownMinimalPSP();
return 1;
}
if (module->isFake) {
fprintf(stderr, "re: module was fake-loaded (HLE stub) rather than really loaded - can't analyze\n");
ShutdownMinimalPSP();
return 1;
}
const std::string moduleName = module->nm.name;
const u32 base = module->memoryBlockAddr;
const u32 blockSize = module->memoryBlockSize;
// The loader's function scan names everything z_un_<addr>. We know better for two whole
// categories: exported functions have a NID the HLE tables can often name, and every import
// stub is a known library function. Naming them here means every call site in the
// disassembly below reads as a name instead of a bare address.
const int moduleIdx = g_symbolMap->GetModuleIndexByName(moduleName);
int namedExports = 0, namedImports = 0;
for (const FuncSymbolExport &exp : module->exportedFuncs) {
const char *known = GetHLEFuncName(exp.moduleName, exp.nid);
const std::string name = known ? known : StringFromFormat("%s_%08x", exp.moduleName, exp.nid);
u32 size = g_symbolMap->GetFunctionSize(exp.symAddr);
if (size == SymbolMap::INVALID_ADDRESS) {
size = 4;
}
g_symbolMap->AddFunction(name.c_str(), exp.symAddr, size, moduleIdx, true);
namedExports++;
}
for (const FuncSymbolImport &imp : module->importedFuncs) {
const char *known = GetHLEFuncName(imp.moduleName, imp.nid);
const std::string name = known ? known : StringFromFormat("%s_%08x", imp.moduleName, imp.nid);
g_symbolMap->AddFunction(name.c_str(), imp.stubAddr, 8, moduleIdx, true);
namedImports++;
}
// Optional pre-existing names, so the disassembly comes out readable instead of a wall of
// z_un_08801234. Uses the same .ppsym format the emulator saves, module-relative. Applied
// after the automatic naming above so a hand-written name always wins.
if (!opts.symsFile.empty()) {
if (moduleIdx < 0) {
fprintf(stderr, "re: warning: module '%s' not in the symbol map, can't apply %s\n",
moduleName.c_str(), opts.symsFile.c_str());
} else if (!g_symbolMap->LoadModuleSymbols(moduleIdx, Path(opts.symsFile))) {
fprintf(stderr, "re: warning: couldn't load symbols from %s\n", opts.symsFile.c_str());
}
}
// A few modules - sysmem.prx and loadcore.prx among them - carry modinfo pointers that are
// file offsets rather than addresses, so the loader's own scan is left with nothing to look
// at and finds no functions. For reverse engineering we would still like the disassembly, and
// we know exactly which range is code, so scan it ourselves.
{
bool anyInModule = false;
for (const SymbolEntry &sym : g_symbolMap->GetAllActiveSymbols(ST_FUNCTION)) {
if (sym.address >= base && sym.address < base + blockSize) {
anyInModule = true;
break;
}
}
if (!anyInModule && blockSize >= 8) {
const u32 textStart = module->nm.text_addr ? (u32)module->nm.text_addr : base;
u32 textEnd = textStart + (u32)module->nm.text_size;
if (textEnd <= textStart || textEnd > base + blockSize) {
textEnd = base + blockSize;
}
if (Memory::IsValid4AlignedRange(textStart, textEnd - textStart)) {
printf("re: loader found no functions, scanning %08x-%08x directly\n", textStart, textEnd);
MIPSAnalyst::ScanForFunctions(textStart, textEnd - 4, true);
}
}
}
// Collect the functions the loader's scan found, restricted to this module.
std::vector<FuncInfo> funcs;
std::map<u32, size_t> funcByAddr;
for (const SymbolEntry &sym : g_symbolMap->GetAllActiveSymbols(ST_FUNCTION)) {
if (sym.address < base || sym.address >= base + blockSize) {
continue;
}
// A module with an odd segment layout can leave a symbol at an address that isn't
// really code; reading an instruction there trips a debug assert deep in MemMap.
if (!Memory::IsValid4AlignedAddress(sym.address) || sym.size == 0) {
continue;
}
FuncInfo f;
f.start = sym.address;
f.size = sym.size;
f.name = sym.name;
funcByAddr[f.start] = funcs.size();
funcs.push_back(f);
}
std::sort(funcs.begin(), funcs.end(), [](const FuncInfo &a, const FuncInfo &b) {
return a.start < b.start;
});
funcByAddr.clear();
for (size_t i = 0; i < funcs.size(); i++) {
funcByAddr[funcs[i].start] = i;
}
for (FuncInfo &f : funcs) {
AnalyzeFunction(&f);
}
// Second pass: invert the call graph.
for (const FuncInfo &f : funcs) {
for (u32 callee : f.callees) {
auto it = funcByAddr.find(callee);
if (it != funcByAddr.end()) {
funcs[it->second].callers.insert(f.start);
}
}
}
auto nameOf = [&](u32 addr) -> std::string {
auto it = funcByAddr.find(addr);
if (it != funcByAddr.end()) {
return funcs[it->second].name;
}
const std::string label = g_symbolMap->GetLabelString(addr);
return label.empty() ? StringFromFormat("%08x", addr) : label;
};
// ---- index ----
const Path indexPath = outDir / (SanitizeForFilename(moduleName) + ".index.md");
FILE *f = File::OpenCFile(indexPath, "w");
if (!f) {
fprintf(stderr, "re: couldn't write %s\n", indexPath.c_str());
ShutdownMinimalPSP();
return 1;
}
fprintf(f, "# %s\n\n", moduleName.c_str());
fprintf(f, "- file: `%s`\n", modulePath.GetFilename().c_str());
fprintf(f, "- crc32: `%08x` (matches `PSP/SYSTEM/SYMBOLS/%s_%08x.ppsym`)\n", module->crc, moduleName.c_str(), module->crc);
fprintf(f, "- attribute: `%04x`%s\n", (u32)module->nm.attribute,
(module->nm.attribute & PSP_MODULE_KERNEL_MODE) ? " (kernel mode)" : "");
fprintf(f, "- version: %d.%d\n", module->nm.version[1], module->nm.version[0]);
fprintf(f, "- loaded at: `%08x`, size `%08x`\n", base, blockSize);
fprintf(f, "- text: `%08x`..`%08x` data: `%x` bss: `%x` gp: `%08x`\n",
(u32)module->nm.text_addr, (u32)module->nm.text_addr + (u32)module->nm.text_size,
(u32)module->nm.data_size, (u32)module->nm.bss_size, (u32)module->nm.gp_value);
fprintf(f, "- entry: `%08x` module_start: `%08x` module_stop: `%08x`\n\n",
(u32)module->nm.entry_addr, (u32)module->nm.module_start_func, (u32)module->nm.module_stop_func);
fprintf(f, "## Segments\n\n| # | address | size |\n|---|---|---|\n");
for (u32 i = 0; i < module->nm.nsegment && i < 4; i++) {
fprintf(f, "| %d | `%08x` | `%x` |\n", i, (u32)module->nm.segmentaddr[i], (u32)module->nm.segmentsize[i]);
}
fprintf(f, "\n## Exports (%d functions, %d variables)\n\n",
(int)module->exportedFuncs.size(), (int)module->exportedVars.size());
fprintf(f, "| library | NID | address | name |\n|---|---|---|---|\n");
for (const FuncSymbolExport &exp : module->exportedFuncs) {
const char *known = GetHLEFuncName(exp.moduleName, exp.nid);
fprintf(f, "| `%s` | `%08x` | `%08x` | %s |\n", exp.moduleName, exp.nid, exp.symAddr,
known ? known : nameOf(exp.symAddr).c_str());
}
for (const VarSymbolExport &exp : module->exportedVars) {
fprintf(f, "| `%s` | `%08x` | `%08x` | *(variable)* |\n", exp.moduleName, exp.nid, exp.symAddr);
}
fprintf(f, "\n## Imports (%d functions, %d variables)\n\n",
(int)module->importedFuncs.size(), (int)module->importedVars.size());
fprintf(f, "| library | NID | stub | name |\n|---|---|---|---|\n");
for (const FuncSymbolImport &imp : module->importedFuncs) {
const char *known = GetHLEFuncName(imp.moduleName, imp.nid);
fprintf(f, "| `%s` | `%08x` | `%08x` | %s |\n", imp.moduleName, imp.nid, imp.stubAddr,
known ? known : "*(unknown NID)*");
}
for (const VarSymbolImport &imp : module->importedVars) {
fprintf(f, "| `%s` | `%08x` | `%08x` | *(variable)* |\n", imp.moduleName, imp.nid, imp.stubAddr);
}
fprintf(f, "\n## Functions (%d)\n\n", (int)funcs.size());
fprintf(f, "| address | +offset | size | callers | callees | v0 | name |\n|---|---|---|---|---|---|---|\n");
for (const FuncInfo &fn : funcs) {
fprintf(f, "| `%08x` | `+%05x` | %d | %d | %d | %s | %s |\n",
fn.start, fn.start - base, fn.size, (int)fn.callers.size(), (int)fn.callees.size(),
fn.regs.writesV0 ? "y" : "-", fn.name.c_str());
}
fclose(f);
// ---- call graph ----
const Path xrefPath = outDir / (SanitizeForFilename(moduleName) + ".xref.json");
f = File::OpenCFile(xrefPath, "w");
if (f) {
fprintf(f, "{\n \"module\": \"%s\",\n \"base\": %u,\n \"functions\": [\n", moduleName.c_str(), base);
for (size_t i = 0; i < funcs.size(); i++) {
const FuncInfo &fn = funcs[i];
fprintf(f, " {\"addr\": \"%08x\", \"name\": \"%s\", \"size\": %d, \"callers\": [",
fn.start, fn.name.c_str(), fn.size);
bool first = true;
for (u32 c : fn.callers) {
fprintf(f, "%s\"%08x\"", first ? "" : ", ", c);
first = false;
}
fprintf(f, "], \"callees\": [");
first = true;
for (u32 c : fn.callees) {
fprintf(f, "%s\"%08x\"", first ? "" : ", ", c);
first = false;
}
fprintf(f, "]}%s\n", i + 1 < funcs.size() ? "," : "");
}
fprintf(f, " ]\n}\n");
fclose(f);
}
// ---- per-function disassembly ----
// Deliberately not DisassemblyManager: its analyze() refuses to do anything unless a game is
// fully booted, and nothing is booted here. DisAsm() is the same formatter its opcode entries
// use, minus the gate.
const Path funcDir = outDir / SanitizeForFilename(moduleName);
if (!File::CreateFullPath(funcDir)) {
fprintf(stderr, "re: couldn't create %s\n", funcDir.c_str());
ShutdownMinimalPSP();
return 1;
}
int written = 0;
for (const FuncInfo &fn : funcs) {
if (!opts.funcFilter.empty()) {
const bool byName = fn.name == opts.funcFilter;
const bool byAddr = StringFromFormat("%08x", fn.start) == opts.funcFilter ||
StringFromFormat("0x%08x", fn.start) == opts.funcFilter;
if (!byName && !byAddr) {
continue;
}
}
const Path path = funcDir / StringFromFormat("%08x_%s.asm", fn.start, SanitizeForFilename(fn.name).c_str());
FILE *out = File::OpenCFile(path, "w");
if (!out) {
continue;
}
fprintf(out, "; %s :: %s\n", moduleName.c_str(), fn.name.c_str());
fprintf(out, "; %08x - %08x (module +%05x, %d bytes)\n",
fn.start, fn.start + fn.size, fn.start - base, fn.size);
fprintf(out, ";\n");
fprintf(out, "; Callers (%d):", (int)fn.callers.size());
if (fn.callers.empty()) {
fprintf(out, " none found (exported, or only called indirectly)");
}
for (u32 c : fn.callers) {
fprintf(out, " %s", nameOf(c).c_str());
}
fprintf(out, "\n; Callees (%d):", (int)fn.callees.size());
for (u32 c : fn.callees) {
fprintf(out, " %s", nameOf(c).c_str());
}
if (fn.indirectCallees) {
fprintf(out, " + indirect");
}
fprintf(out, "\n;\n");
WriteRegEvidence(out, fn);
fprintf(out, "\n");
// Tracks lui-loaded upper halves so an "lui/addiu" or "lui/lw" pair can be reported as
// the address it actually forms. Those pairs are how every global and constant table is
// reached, so without this the interesting operands all read as bare halves.
u32 luiVal[32] = {};
bool luiSet[32] = {};
const u32 end = fn.start + fn.size;
for (u32 addr = fn.start; addr < end; addr += 4) {
if (!Memory::IsValidAddress(addr)) {
break;
}
const std::string label = g_symbolMap->GetLabelString(addr);
if (!label.empty() && addr != fn.start) {
fprintf(out, "\n%s:\n", label.c_str());
}
char text[512];
DisAsm(addr, text, sizeof(text));
// DisAsm separates mnemonic from operands with a tab.
char *tab = strchr(text, '\t');
std::string mnemonic = text;
std::string operands;
if (tab) {
*tab = '\0';
mnemonic = text;
operands = tab + 1;
}
fprintf(out, "%08x %-10s %-30s", addr, mnemonic.c_str(), operands.c_str());
const u32 op = Memory::Read_Instruction(addr).encoding;
const u32 opcode = op >> 26;
const int rs = (op >> 21) & 0x1f;
const int rt = (op >> 16) & 0x1f;
const s32 imm = (s16)(op & 0xffff);
if (IsJal(op)) {
fprintf(out, " ; -> %s", nameOf(JumpTarget(addr, op)).c_str());
} else if (opcode == 0x0f) { // lui
luiVal[rt] = (u32)(op & 0xffff) << 16;
luiSet[rt] = true;
} else if (opcode == 0x09 && luiSet[rs]) { // addiu
const u32 target = luiVal[rs] + imm;
fprintf(out, " ; = %08x%s", target, DescribeAddr(target).c_str());
luiVal[rt] = target;
luiSet[rt] = true;
} else if (opcode >= 0x20 && opcode <= 0x2e && luiSet[rs]) { // load/store
const u32 target = luiVal[rs] + imm;
fprintf(out, " ; @ %08x%s", target, DescribeAddr(target).c_str());
} else {
// Anything else that writes a register invalidates what we thought it held.
const MIPSInfo info = MIPSGetInfo(MIPSOpcode(op));
if (info & OUT_RT) {
luiSet[rt] = false;
}
if (info & OUT_RD) {
luiSet[(op >> 11) & 0x1f] = false;
}
}
fprintf(out, "\n");
}
fclose(out);
written++;
}
printf("re: %s (crc %08x) at %08x, %d bytes\n", moduleName.c_str(), module->crc, base, blockSize);
printf("re: %d exports (%d named), %d imports (%d named), %d functions; wrote %d disassembly file(s)\n",
(int)module->exportedFuncs.size(), namedExports, (int)module->importedFuncs.size(), namedImports,
(int)funcs.size(), written);
printf("re: index at %s\n", indexPath.c_str());
ShutdownMinimalPSP();
return 0;
}
+37
View File
@@ -0,0 +1,37 @@
// Copyright (c) 2026- PPSSPP Project.
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, version 2.0 or later versions.
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License 2.0 for more details.
// A copy of the GPL 2.0 should have been included with the program.
// If not, see http://www.gnu.org/licenses/
// Official git repository and contact information can be found at
// https://github.com/hrydgard/ppsspp and http://www.ppsspp.org/.
#pragma once
#include <string>
struct ReverseEngineerOptions {
// PRX/ELF to load. A host path, or a PSP path like "flash0:/kd/libmp3.prx" (resolved
// against the configured NAND directory).
std::string modulePath;
// Where to write the report. Created if missing.
std::string outDir;
// If set, only this function is disassembled (by name, or "0x08801234").
std::string funcFilter;
// Optional .syms file applied before dumping, so names show up in every caller.
std::string symsFile;
bool verbose = false;
};
// Loads a module standalone (no game), analyzes it, and writes a report. Returns a process
// exit code.
int RunReverseEngineer(const ReverseEngineerOptions &opts);