The reverse-engineering toolchain¶
Everything in Running the tools needs nothing but uv. Everything on this
page is for the other half of the work: reading PowerPC, writing PowerPC, and comparing two
firmware versions. None of it is bundled — it is per-machine, and it is the same whether you
are on the Mac or the Windows box.
Two of these tools are already used by the repository's own analysis:
| tool | what it is for |
|---|---|
clang |
compiles C to PowerPC. The e300 core is plain 32-bit big-endian PowerPC, so an ordinary cross-compile works. |
ld.lld |
links that code at a fixed address, which is what a patch site needs. |
llvm-mc |
assembles or disassembles a single instruction, without a linker. |
llvm-objdump, llvm-objcopy |
read a linked ELF; emit the raw injectable bytes. |
rizin |
rz-diff compares two firmware images — the quickest way to re-derive addresses across a version shift. |
| Ghidra | the decompiler. tools/mkelf.py output imports straight in, symbols and all. |
capstone and unicorn — used by tools/ppcdis.py and tools/ppcemu.py — are not in this
list because the dev extra already installs them.
Installing¶
Homebrew's LLVM is keg-only, so it is not on your PATH:
This repository was set up against llvm@22, where the prefix is
$(brew --prefix llvm@22). If you installed that formula instead of the latest, use it.
The official LLVM build includes clang, clang++, lld, llvm-mc, llvm-objdump
and llvm-objcopy, and installs to C:\Program Files\LLVM\bin, which the installer
puts on PATH. All targets are enabled in the Windows build, so this clang can
target PowerPC out of the box — see the warning below, which is a macOS-only trap.
rizin has no winget package; take the portable rizin-windows-share64-*.zip from
the Rizin releases and put its bin on PATH. Ghidra's
ZIP release needs a JDK 21 or newer.
On macOS, the clang on your PATH cannot do this
/usr/bin/clang is Apple's, and Apple's build has no PowerPC backend. clang
-print-targets on it will not list ppc32, and a --target=powerpc-… compile fails.
Every command below has to use Homebrew LLVM's binary by path — which is why the
examples set $LLVM first. This is the single most common way to waste an afternoon
here. The Windows installer has no equivalent trap.
ld.lld is the exception: Homebrew links it into $(brew --prefix)/bin, so it is on
PATH after brew install lld. The llvm-* tools are not.
Reading the image¶
The repository's own tools read a raw image directly:
uv run tools/unpack.py SMEG_PLUS_UPG/NAV/AppBin/f_BigQuick.bin app_nav.bin
uv run tools/ppcdis.py app_nav.bin abs_symbols_base.txt 0x0230331c 0x02303460
For anything larger than a function or two, wrap it into an ELF and use a real disassembler
or decompiler. tools/mkelf.py attaches the shipped symbol map, so the names come with it:
uv run tools/mkelf.py app_nav.bin abs_symbols_base.txt app_nav.elf
llvm-objdump -d --triple=powerpc app_nav.elf
To decompile rather than read assembly, import app_nav.elf into Ghidra and pick
PowerPC:BE:32:default as the language. There is deliberately no e300 language ID —
Ghidra's PowerPC list has 4xx, e500, e500mc, MPC8270, QUICC and default, and the
e300 is none of the specific ones, which matches the architecture notes
calling it plain PowerPC with no vendor extensions. Import the ELF, not the raw image: the
symbols are the reason to bother.
Ghidra from Claude Code (optional)¶
The repository's .mcp.json points Claude Code at a
pyghidra-mcp server on
http://127.0.0.1:8000/mcp. Nothing is shipped for it, because the ELF is vendor material and
stays outside the repository. To use it, run one server against your own ELF and leave it
running:
export GHIDRA_INSTALL_DIR=/path/to/ghidra # Homebrew: $(brew --prefix ghidra)/libexec
export JAVA_HOME=/path/to/jdk-21 # Ghidra wants 21; newer JDKs may not work
uvx pyghidra-mcp --transport streamable-http --port 8000 \
--project-path ~/ghidra-work/projects --project-name smeg ~/ghidra-work/app_nav.elf
The first start analyses the whole image, which takes a long time; later starts reuse the project.
Run one server, over HTTP, never one per session. Ghidra lets a single process hold a
project. A stdio server configured at user scope is started by every Claude session, and every
copy after the first fails with LockException: Unable to lock project, which Claude Code
reports only as "connection closed". Sessions and the desktop app can all share the one HTTP
server. Without it running, the entry simply fails to connect and nothing else is affected.
Writing PowerPC¶
For a handful of instructions — which is what most entries in patches/*.json are — you do
not need a compiler or a linker. llvm-mc assembles text and prints the encoding, so the
bytes field can be written from source rather than from memory:
$LLVM/llvm-mc --triple=powerpc --show-encoding <<'EOF'
li 3, 1
blr
EOF
# li 3, 1 # encoding: [0x38,0x60,0x00,0x01]
# blr # encoding: [0x4e,0x80,0x00,0x20]
That is the aux-always-available patch, and it is worth noticing why this works: the
encoding 38 60 00 01 4e 80 00 20 is exactly what patches/aux-always-available.json
carries. The patch was hand-assembled, and the compiler agrees with it byte for byte.
For a whole function, compile and link it. All three steps are needed, because the linker is what places the code at the address the patch expects:
LLVM="$(brew --prefix llvm)/bin" # Windows: C:\Program Files\LLVM\bin
LLD="$(brew --prefix lld)/bin" # Windows: in the LLVM bin directory
$LLVM/clang --target=powerpc-unknown-none-eabi -mbig-endian -O2 -ffreestanding \
-c mycode.c -o mycode.o
$LLD/ld.lld -m elf32ppc --image-base=0 -Ttext=0x02247718 \
-e my_function mycode.o -o mycode.elf
$LLVM/llvm-objcopy -O binary --only-section=.text mycode.elf mycode.bin
xxd mycode.bin
-ffreestanding because there is no libc to link against. -e names the entry point.
ld.lld needs an explicit --image-base
Without it, the linker defaults the image base to 0x10000000 and then refuses every
patch address as being below it:
ld.lld: error: section '.text' address (0x2247718) is smaller than image base (0x10000000);
specify --image-base
--image-base=0 is the fix. This is not a mistake in the address.
To check the result before trusting it, disassemble the ELF — which, unlike the flat binary, still carries the load address and the symbol:
Comparing two firmware versions¶
Addresses move between firmware versions — the NAV image from SMEG_5.42.B.R4 is the 5.43
one displaced by a constant 152 bytes — so this comes up every time. rz-diff does it
without unpacking either side into a disassembler:
rizin's PPC assembler is not self-contained
rz-asm -a ppc disassembles, and its separate ppc.as plugin wants a real assembler
behind it via RZ_PPC_AS; without that variable set it prints
Please set 'RZ_PPC_AS' env … and gives you nothing. Use llvm-mc for assembling and
keep rizin for diffing, where it needs no help.
What is verified, and what is not¶
The macOS commands above have been run end to end: a C function compiled to PowerPC, linked
at 0x02247718, emitted as a flat binary and disassembled gives 38 60 00 01 4e 80 00 20 —
li r3,1 ; blr, the same sequence the shipped patch carries.
The Windows instructions are written from the vendors' own installers and have not been run on a Windows machine here. Treat the paths as right and the tools as untested.
Two things are deliberately not claimed:
- Nothing here has been flashed. The toolchain produces the bytes a patch wants; whether a given patch works on a car is a separate question, answered in Hardware verification.
- A routine larger than its patch site is not solved, but the blocker is characterised.
There is no usable code cave in
.text— every large zero-run is.rodata(sqlite3 and utf8proc tables), so live data. A trampoline would have to live past the end of the image, and that is expressible: the container header holds the inflated size at offset0x04and no compressed size (the zlib stream is self-delimiting), so the image can be grown. Whether the loader maps the appended region executable is untested — so fitting the edit into the words already there remains the way that works.