Skip to content

iOS: support JIT on iOS 26 (TXM/SPTM devices) - #34

Open
Johnzx07 wants to merge 2 commits into
jpd002:masterfrom
Johnzx07:ios26-jit-pr
Open

Johnzx07 wants to merge 2 commits into
jpd002:masterfrom
Johnzx07:ios26-jit-pr

Conversation

@Johnzx07

@Johnzx07 Johnzx07 commented Sep 7, 2026

Copy link
Copy Markdown

Problem

On iOS 26, on TXM/SPTM hardware (A15+ / M2+), the iOS JIT path can no longer produce executable memory. Recompiled blocks fault the moment they are entered, so games stop booting once JIT is enabled. This is the cause of jpd002/Play-#1490.

Probing on an iPhone 15 Pro Max (A17 Pro) running iOS 26.4.2 shows the process has no way to do it itself:

attempt result
mmap(..., MAP_JIT) EPERM — requires the dynamic-codesigning entitlement
mmap(PROT_READ|WRITE|EXEC) maps, reports max=rwx, but current is rw-
mprotect(..., PROT_READ|PROT_EXEC) on the above returns 0 but the page ends up r--, max=rw- — execute is stripped
existing MEMFUNC_USE_MACHVM vm_protect path same failure

CS_DEBUGGED was set in all of these, so having a debugger attached is not by itself sufficient.

Mechanism

The only thing that marks a page executable on these devices is a write performed out of process by an attached debugger: for every 16K page, debugserver writes one byte, and that write grants the page execute permission.

StikDebug / StikJIT implement the debugger half and wait for the app to ask for it through a breakpoint protocol — brk #0xf00d, command in x16, arguments in x0/x1. The app has to be the client; simply having the debugger attached does nothing on its own.

Documented in StikJIT's INTEGRATION.md:

Where TXM/SPTM is present, the debugger flag alone is not enough: each executable memory region must be prepared through the debug connection before the app executes code from it.

Changes

Implements the client half for the iOS arm64 build, in MemoryFunction.cpp:

  • A single 64MB region is requested from the debugger with a NULL address, selecting its fresh-allocation branch. Passing an address the process mapped itself returns the same pointer and appears to succeed, but no memory-write packets are ever sent and the region never becomes executable.
  • The returned region is execute-only, so a writable alias of the same physical pages is created locally with vm_remap + vm_protect.
  • Blocks are sub-allocated from that pair (first-fit free list + bump). m_code stays the executable address; a new GetWritableCode() returns the matching writable address for callers that patch generated code in place.
  • Gated on CS_DEBUGGED — a brk with no debugger attached raises an unhandled SIGTRAP and kills the process.
  • The debugger is deliberately never detached; execute permission is tied to it staying attached.

MemFunc_InitJitArena() lets the app reserve the region early in launch, while the debugger's script is still running. MemFunc_IsJitReady() reports whether an executable region was actually obtained. No-op stubs keep non-TXM Apple targets (including the simulator) linking.

There is a companion change in Play- — BasicBlock's link patching must write through GetWritableCode(), and the iOS UI calls MemFunc_InitJitArena() / MemFunc_IsJitReady(). Happy to open that PR once this is reviewed, or fold it in however you prefer.

Nothing changes on any other platform — macOS, Windows, Linux, Android, Emscripten and the iOS simulator all take the same paths as before.

Testing

Verified on an iPhone 15 Pro Max (A17 Pro), iOS 26.4.2, running under LiveContainer with StikDebug and its universal.js script: games boot and run with JIT. Previously they black-screened or crashed on entering recompiled code.

Credit to StikDebug/StikJIT for the protocol, and to shadPS4's ios_jit_allocator, which documented both pitfalls above (the NULL-address branch and the execute-only region) from on-device wire tracing.

🤖 Generated with Claude Code

On iOS 26 with TXM/SPTM hardware (A15+, M2+) the existing iOS path can no
longer produce executable memory, so recompiled blocks fault as soon as
they are entered and games no longer boot with JIT enabled. On-device
probing of an A17 Pro running 26.4.2 shows why:

  * mmap with MAP_JIT fails with EPERM (no dynamic-codesigning entitlement)
  * a region mapped PROT_READ|WRITE|EXEC reports max=rwx, but any mprotect
    that would add execute silently drops it to r-- (max rw-)
  * the existing vm_protect path in MEMFUNC_USE_MACHVM fails the same way

A process cannot grant itself execute permission on these devices at all.
The only mechanism is an out-of-process write performed by an attached
debugger: for each 16K page of a region, debugserver writes a single byte,
and that write is what marks the page executable. StikDebug/StikJIT
implement the debugger half and wait for the application to request it via
a breakpoint protocol (brk #0xf00d, command in x16, arguments in x0/x1).

This implements the client half of that protocol for the iOS arm64 build:

  * One 64MB region is requested from the debugger with a NULL address,
    which selects its "fresh allocation" branch. Passing an address the
    process mapped itself returns the same pointer and looks successful,
    but no memory-write packets are ever sent and the region never becomes
    executable.
  * The returned region is execute-only, so a writable alias of the same
    physical pages is created locally with vm_remap + vm_protect.
  * Blocks are sub-allocated from that pair. m_code remains the executable
    address; the new GetWritableCode() returns the matching writable
    address for callers that patch generated code in place.
  * The request is gated on CS_DEBUGGED, because a brk with no debugger
    attached raises an unhandled SIGTRAP and terminates the process.
  * The debugger is intentionally never detached: execute permission is
    tied to it remaining attached.

Behaviour on every other platform, including macOS and the iOS simulator,
is unchanged. Verified on an iPhone 15 Pro Max (A17 Pro) running iOS
26.4.2: games boot and run with JIT.

Protocol details and the two pitfalls above are documented in
StikDebug/StikJIT's INTEGRATION.md and in shadPS4's ios_jit_allocator,
which traced them on-device.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three things that only surface once the device or the OS changes:

* An unserviced breakpoint doesn't reliably return null - the breakpoint
  encoding can be left behind in x0, so a result like 0x690000e0 was being
  taken for a valid address. Validate the result rather than null-checking
  it.
* The debugger's script can be momentarily busy or suspended, so a single
  failed request was being treated as permanent. Retry briefly first.
* Where W^X isn't enforced the process can still map an executable region
  itself, so fall back to that instead of failing outright.

The debugger request stays the first thing tried on every iOS arm64
device, rather than selecting a path from a TXM check. A check like that
silently goes stale when TXM is enabled on further devices in a point
release, sending the app down a path that can no longer produce
executable memory.

Verified on iOS 26.4.2 and 26.6.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Johnzx07

Copy link
Copy Markdown
Author

Pushed a follow-up commit hardening the region acquisition, and this is now verified on iOS 26.4.2 and 26.6.2 (iPhone 15 Pro Max, A17 Pro, same hardware both times so the OS version was the only variable).

26.6 is worth calling out specifically, because it broke JIT in several other apps. Apple enabled TXM on more devices in that update, so anything that detects TXM and picks a code path from it gets a stale answer after updating and ends up on a path that can't produce executable memory. This implementation deliberately doesn't do that — the debugger request is simply the first thing tried on every iOS arm64 device, with a fallback if it doesn't produce a region — so there's nothing to go stale.

The follow-up also fixes a real correctness bug: an unserviced breakpoint doesn't reliably return null. The breakpoint encoding can be left behind in x0, so values like 0x690000e0 were being accepted as a valid address. The result is now validated rather than null-checked, with a short retry in case the debugger's script is momentarily busy.

Happy to squash these into a single commit if you'd prefer, and to open the companion Play- change (block link patching through GetWritableCode(), plus the two calls from the iOS UI) whenever suits you.

🤖 Generated with Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant