Skip to content

fix(relinker): skip jump tables placed after a function's code - #6618

Closed
Get-fix wants to merge 1 commit into
boykopovar:mainfrom
Get-fix:ps4/relinker-inline-jump-tables
Closed

Get-fix wants to merge 1 commit into
boykopovar:mainfrom
Get-fix:ps4/relinker-inline-jump-tables

Conversation

@Get-fix

@Get-fix Get-fix commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

What

PS4 titles built with Sony's toolchain place a switch statement's jump table in the code segment, after the last instruction of the function that indexes it and inside the function's unwind range:

lea    rcx, [rip + table]
movsxd rax, dword ptr [rcx + rax*4]
add    rax, rcx
jmp    rax
...                 ; rest of the function
table: dd case0 - table, case1 - table, ...

Code analysis decodes each function range from start to end, so it read these tables as instructions. The syscall scan and the call-site resolver sweep the whole executable segment, so they lost their instruction boundaries after each table. The conversion of two PS4 titles stopped on it (Windows, --skip-sce-module). Bloodborne stops there on main 5d9dcc42d. Limbo first stops at a zero-length FDE, which #6619 handles, and then here:

Bloodborne (CUSA00900): FAIL: Cannot decode instruction: Instruction exceeds 15 bytes (offset 0x31df80)
Limbo (CUSA01664):      FAIL: Unsupported Windows guest TLS instruction (bytes: 65 f9) (offset 0x155d70)

The new FindInlineJumpTables decodes one function range. It follows the table address of each RIP-relative lea through 64-bit register moves, and records the address that a movsxd with scale 4 indexes when it lies later in the same function. When decoding reaches the first such address, it checks that every dword from there to the end of the function is an offset, from the nearest table before it, to code before the first table. If so, the rest of the function is tables. Anything else is decoded as before, so code that only looks like a dispatch is still analysed.

  • Code analysis: stops each such function at its first table and keeps the control-flow pass out of the tables. That pass otherwise follows the lea to the table and decodes it.
  • The pipeline: fills the tables with nops in the copies of the code that the syscall scan and the call-site resolver read. The output image is unchanged.

The forms seen in the two titles are covered: the base in the register the lea writes, in a register it was moved to, and in r13, whose SIB form carries an 8-bit displacement of 0. Limbo has 189 functions with such tables. Part of the PS4 work tracked in #3093.

Tested

Windows 11 Pro 26200, WinLibs GCC 15.2.0 r7, Release, BUILD_TESTING=ON, on main 5d9dcc42d:

  • The new sce_inline_jump_tables test converts PS4-layout fixtures for Windows and macOS, each with an unwind-covered function whose table entries begin with bytes no pass can decode (0f 79) or that carry an fs prefix (64). It covers four cases:
    • the dispatch above, the base moved to another register, and the r13 form all convert, with the PLT call site still resolved;
    • a movsxd whose base points 8 bytes before the table is not taken as a table, and the conversion fails as it does on main;
    • a dispatch-like lea/movsxd whose target is code that fails the check is still analysed: its unsupported TLS instruction is still reported.
  • Each of these makes the test fail: no nop fill for the scans, code analysis decoding the tables, tables taken without the check, register moves not followed, the r13 form rejected, the control-flow pass not kept out, table addresses kept in the instruction set.
  • This branch alone, with CI's relinker-only configuration (-DANYPS5_RELINKER_ONLY=ON): 178 of 178 tests pass. The relinker it builds converts Bloodborne. Limbo still stops at its zero-length FDE, which fix(relinker): ignore unwind records that cover no code #6619 handles; with both, Limbo converts.
  • The converted titles do not start yet: AnyPS5's libraries export neither all their imports nor any of the PS4 graphics driver (libSceGnmDriver). The relinker's import audit reports Bloodborne with 180 of 686 imports absent and Limbo with 220 of 547.
  • The OpenOrbis toolchain's 15 runnable samples and the homebrew game Fritz Dash convert and run as before.
  • Full ctest with my open pull requests: 1305 of 1308 pass. agc_driver_gpu_indirect_arguments_misaligned_writer_ahead does not compile with MinGW on main (test(agc): align the indirect arguments block at run time #6452 fixes it), and prx_thread_local_destructors fails as it did on main 92cdc7b62 on this machine without my changes. agc_driver_preparation_scope_settle failed under ctest -j8 and passes when run alone.
  • tools/check_conventions.py passes.

Not tested on Linux or macOS hosts.

Checklist

🤖 Generated with Claude Code

PS4 titles built with Sony's toolchain place a switch statement's jump
table in the code segment, after the last instruction of the function
that indexes it and inside the function's unwind range:

    lea    rcx, [rip + table]
    movsxd rax, dword ptr [rcx + rax*4]
    add    rax, rcx
    jmp    rax

Code analysis decodes each function range from start to end, so it read
the table as instructions; the syscall scan and the call-site resolver
sweep the whole segment and lost their instruction boundaries there.
Limbo (CUSA01664) stopped at "Unsupported Windows guest TLS instruction
(bytes: 65 f9)" and Bloodborne (CUSA00900) at "Instruction exceeds 15
bytes".

FindInlineJumpTables decodes a function range, follows the table
address of each RIP-relative lea through 64-bit register moves, and
records the address a movsxd with scale 4 indexes when it lies later
in the same function. When decoding reaches the first such address and
every dword from there to the end of the function is an offset, from
the nearest table before it, to code before the first table, the rest
of the function is taken as tables. Anything else is decoded as before.
Code analysis stops each function there and keeps the control-flow
pass out of the tables; the pipeline fills them with nops in the copies
the syscall scan and the call-site resolver read.
@MarcZX10

Copy link
Copy Markdown
Contributor

I have open #6595, which carries the detector proposal attached to #3093 and includes synthetic detector/CFG cases. A local merge-tree check against your head reports conflicts in CodeInstructionCollector.cpp and RelinkerPipeline.cpp. The ranges differ: your detector scans inside an FDE range; #6595 also examines a bounded region after the FDE and requires decoded targets plus padding. I asked in #6595 whether we can combine these paths and keep both kinds of coverage. Your retail conversions add useful evidence for the in-FDE case.

@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Some comparisons could not be completed because a pull request conflicts with the base branch. Review these shared files manually before concluding that there is no overlap.

Pull request Shared files not compared
PR 5021 core/relinker/CMakeLists.txt
PR 6044 core/relinker/CMakeLists.txt
PR 6204 core/relinker/CMakeLists.txt
PR 6713 core/relinker/CMakeLists.txt
PR 6879 core/relinker/CMakeLists.txt
PR 7037 core/relinker/relinker/src/analysis/CodeInstructionCollector.cpp
PR 7173 core/relinker/CMakeLists.txt
PR 7531 core/relinker/CMakeLists.txt
PR 7532 core/relinker/CMakeLists.txt
PR 7704 core/relinker/CMakeLists.txt
PR 7837 core/relinker/CMakeLists.txt
PR 8076 core/relinker/relinker/src/analysis/CodeInstructionCollector.cpp
core/relinker/relinker/src/pipeline/RelinkerPipeline.cpp
PR 8118 core/relinker/CMakeLists.txt
PR 8142 core/relinker/CMakeLists.txt
PR 8251 core/relinker/CMakeLists.txt
PR 8664 core/relinker/CMakeLists.txt
PR 8880 core/relinker/CMakeLists.txt
PR 8900 core/relinker/CMakeLists.txt
PR 9000 core/relinker/CMakeLists.txt
PR 9230 core/relinker/relinker/src/pipeline/RelinkerPipeline.cpp
PR 9234 core/relinker/CMakeLists.txt
PR 9238 core/relinker/CMakeLists.txt
PR 9261 core/relinker/CMakeLists.txt
PR 9408 core/relinker/CMakeLists.txt
PR 9414 core/relinker/CMakeLists.txt

@Get-fix

Get-fix commented Oct 15, 2026

Copy link
Copy Markdown
Contributor Author

@MarcZX100 Thanks. I replied on #6595 with what I found running it on the two titles: it leaves a trailing table in each, so neither converts with it yet. I also proposed a way to combine the two detectors there.

AI-assisted: yes (Claude Code).

🤖 Generated with Claude Code

@jotitadev jotitadev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ran this on macOS, since the description lists no macOS host.

Setup: Apple M2 Max, macOS 27.2, Xcode 27 Apple Clang, relinker-only Release (-DANYPS5_RELINKER_ONLY=ON -DBUILD_TESTING=ON). Head bf06a8e5c on main 5d9dcc42d. Built natively for arm64 (Python 3.14), and as x86_64 under Rosetta with /usr/bin/python3 3.9.6.

build main #6618 #6618 + #6619 (a9fa0e8b0)
arm64 173/173 174/174 174/174
x86_64 (Rosetta) + Py 3.9 170/173 171/174 171/174

The 3 x86_64 failures are the same on main (pr_overlap_tools, release_package, linux_fixed_base, Python 3.10-only keyword arguments). sce_inline_jump_tables passes on both builds, including under Python 3.9.

Does the test catch the regression? I ran test_sce_inline_jump_tables.py against the main relinker, case by case. The plain, copy and r13 cases fail for both --windows and --macos with Cannot decode instruction: Unsupported 0F 79 opcode without SSE4A prefix (offset 0x9ff8). The two negative cases (r13-displacement, not-a-table) pass on main, as they should. So the test does catch the regression on the macOS target too.

Reduced layouts vs #6595. On #6595 you suggested a reduced copy of the Limbo and Bloodborne layouts. I tried some reductions on the test's fixture, with --windows and --macos:

  • ret + 2-byte nop before the table;
  • a cmp/ja guard to that ret;
  • 19 entries ending at the FDE end, then an 8-byte nopl outside the FDE;
  • two back-to-back tables of 100 entries after ret, call and two nops;
  • entries starting with 64 fb or 65 fd.

main fails on each one, with the decode error or Unsupported Windows/macOS guest TLS instruction (bytes: 64 fb / 65 fd). #6595 (f25f22630) and #6618 convert all of them. So none of these features alone accounts for the retail stops. Whatever separates the two is something these reductions don't model, for example the real code and targets before the table.

Written with AI assistance (Claude Code). I ran the builds and tests above on my machine.

@savvaniss

Copy link
Copy Markdown
Contributor

Windows data point from a second PS4 title: Hasbro Family Fun Pack (CUSA03312, my own dump) needs this PR for --windows.

Its bundled libSceFios2.prx ends the unwind function at 0xe5d0–0xea98 with a table of signed 32-bit offsets (54 fd ff ff, 59 fd ff ff, 64 fe ff ff, …), followed by NOP padding before the next function at 0xeaa0. Without this PR, the Windows conversion stops on one of its entries:

FAIL: Unsupported Windows guest TLS instruction (bytes: 64 fe ff) (offset 0x12a7c)
Input: ...\sce_module\libSceFios2.prx

0x12a7c is the file offset of VA 0xea7c (code at file 0x4000, VA 0).

Relinker-only Release build, Windows 11 Pro 26300, WinLibs GCC 15.2.0 r7, on main f3059421f with #6619, #5138, #6357, #6684, #6381, #6354, 3b158c1 from #6977 and my gap-handling change for #3093. Adding this PR (bf06a8e5c merged in; one conflict in RelinkerPipeline.cpp, kept both sides) makes the Linux and --windows conversions of the eboot and its 9 modules complete, for both base 01.00 and patch 01.02.

AI-assisted: yes (Claude Code)

@adriadam10 adriadam10 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#6595 is now merged and also detects relative jump tables inside and after a function's unwind range (RelativeJumpTables.cpp: nop-filled scan copies in RelinkerPipeline.cpp, isData hook in the CFG, tables/inTable in CodeInstructionCollector.cpp). This PR now conflicts in CodeInstructionCollector.cpp and RelinkerPipeline.cpp, and keeping both would leave two detectors with two separate data-range mechanisms (inlineData here, tables there).

Could you rebase on current main and:

  • re-run Bloodborne (CUSA00900), Limbo (CUSA01664) and Hasbro (CUSA03312) on it, since your numbers are from 5d9dcc42d, before #6595;
  • if some table is still not handled, extend FindTrailingRelativeJumpTable / FindRelativeJumpTables for that case, with a fixture that fails on current main (the forms here: base moved to another register, r13 with the 8-bit displacement, tables not followed by padding), instead of adding a second detector;
  • if main already covers all of them, close this PR and say so.

@Get-fix

Get-fix commented Oct 21, 2026

Copy link
Copy Markdown
Contributor Author

@adriadam10 I re-ran everything on current main (cecb540, with #6595, #8166 and #8168), with the relinker built from main alone, on Windows 11 with WinLibs GCC 15.2:

So main covers everything this PR was for, and I'm closing it as you suggested. @savvaniss, if Hasbro's libSceFios2.prx table still stops on current main, that case belongs in FindTrailingRelativeJumpTable with a fixture, and I'm happy to help with it.

AI-assisted: yes (Claude Code)

🤖 Generated with Claude Code

@Get-fix Get-fix closed this Oct 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build needs rebase Conflicts with main, rebase needed relinker unblocks title Names a title ID on the Unblocks line

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants