Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Your Name 9a509e11b9 structure a resolved switch instead of stranding its cases
A `bi rN` off a jump table is an IJMP with one successor per case -- IDA
resolves the table and cfg wires every edge -- but the structurer emitted
the `goto *rN` tail and returned, never recursing into those successors.
Each case block was then left unreached: `stop` (a 51-case dispatcher) alone
reported 27 blocks not reached, `inflate` 172, and the module as a whole
several hundred lines of real code the listing dropped to a header note.

The IJMP case now walks its successors like any other multi-way branch --
emit each case to the shared post-dominator follow, then continue there --
so the cases are reached and structured.  An indirect jump with no resolved
successors (a real tail call or an unrecovered pointer) still emits a bare
tail, unchanged.  Each case is added to the label set so the computed jump
reads against its targets, and the tail's comment says `jump table` rather
than `indirect / tail call` when the branch has more than one successor:

    goto *r2;   // jump table
    loc_12FC0:
        stop(0x3C);
    loc_12FC8:
        stop(0x2C);
    ...

Swept all 446 functions of a PS3 appldr: unreached blocks across the module
drop from several hundred to 0; no SSA-verify regressions; all render.  All
pipeline tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-09-19 00:09:45 +01:00
spudec structure a resolved switch instead of stranding its cases 2026-09-19 00:09:45 +01:00
tests never let one name stand for two values that are live at once 2026-09-18 01:07:53 +01:00
LICENSE Add MIT License 2026-09-18 16:24:08 +01:00
README.md print lane-wise arithmetic infix when only one lane can matter 2026-09-17 21:35:18 +01:00
spudec_plugin.py actually use the demangling resolver in the viewer and the plugin 2026-09-17 18:04:15 +01:00

spudec — SPU lifter + SSA IR for IDA Pro 9.x

Stage 1 of an SPU decompiler: IDA's SPU processor module is used purely as a decoder, and everything above it is ours.

procs/spu.py -> lifter -> CFG -> SSA -> opt -> scalarise -> opt
             -> structure -> pseudocode

Place the plugin and the folder under the ida instalation directory / plugins folder

C++ names

Mangled symbols are demangled to the qualified name without its parameter list (IDA's MNG_NODEFINIT), since the real arguments are printed instead, so a call reads as ss::ss_iso_dprintf::put_s(r3, "In main: "). Two functions can demangle to one name -- a constructor emits both a complete-object and a base-object body, differing only in the mangling -- so a name shared by more than one function keeps its address (ss::crypto::crypto_1510). Compiler helpers whose demangled form is not expression-safe (`global constructor keyed to') are sanitised into identifiers.

What the listing promises

The declaration block and the body are kept in agreement: every name the body mentions is declared, and every declared local is assigned somewhere (a register the function only reads is marked // live in instead). That sounds obvious, but three separate things used to break it, and each produced a listing that referred to a value it never showed being computed:

  • A clobber read only by a phi whose own result went nowhere but another call's conservative ABI operand list was printed as rN = <result in rN>. On call-heavy code that was 14% of all output lines.
  • The inlining depth cap printed a bare name for a definition that had already been dropped as a statement, so a deeply nested expression used an undeclared variable. The cap now applies when inlinability is decided, so anything too deep simply stays a statement.
  • A callee-saved register's name is printed by the prologue save (which reads the incoming value) while the epilogue restore prints nothing, so the name was classed as a local that is never assigned rather than as live-in.

tests/test_pipeline.py audits every function it renders for exactly this agreement, so a regression in either direction fails a test.

Lane-wise arithmetic

a rt, ra, rb adds four 32-bit lanes, so it is only fair to print a + b when the reader cannot be misled about which lanes are involved. That holds when scalarisation proved the value lives in the preferred slot, when the recovered type is a vector whose element width matches, or when the type occupies no more than one element -- an address, or anything demand analysis narrowed to a word. Stack-frame arithmetic is all of the third kind, which is why add.w(sp, #0xffffec50:w4) now reads (char *)sp - 0x13B0.

Compares are deliberately excluded from the type-based case: they produce an all-ones mask per lane rather than 0 or 1, so a > b is only fair once scalarisation has proved the preferred slot, not merely made it likely.

Address arithmetic carries an explicit (char *) because C pointer arithmetic scales and the SPU's does not -- sp - 0x13B0 on an unsigned int * would mean 0x4EC0 bytes.

Tests

python tests/test_pipeline.py

Builds IR by hand, so it needs no IDA: covers the phi-argument regression and the string rendering.