Repository navigation
Conversation
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>
|
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 Happy to squash these into a single commit if you'd prefer, and to open the companion 🤖 Generated with Claude Code |
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:
mmap(..., MAP_JIT)EPERM— requires thedynamic-codesigningentitlementmmap(PROT_READ|WRITE|EXEC)max=rwx, but current isrw-mprotect(..., PROT_READ|PROT_EXEC)on the abover--,max=rw-— execute is strippedMEMFUNC_USE_MACHVMvm_protectpathCS_DEBUGGEDwas 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 inx16, arguments inx0/x1. The app has to be the client; simply having the debugger attached does nothing on its own.Documented in StikJIT's INTEGRATION.md:
Changes
Implements the client half for the iOS arm64 build, in
MemoryFunction.cpp:vm_remap+vm_protect.m_codestays the executable address; a newGetWritableCode()returns the matching writable address for callers that patch generated code in place.CS_DEBUGGED— abrkwith no debugger attached raises an unhandledSIGTRAPand kills the process.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 throughGetWritableCode(), and the iOS UI callsMemFunc_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.jsscript: 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