From f2d99e523b89db0849c87e6f81cf3e4e5ee86a24 Mon Sep 17 00:00:00 2001 From: Johnzx07 Date: Mon, 7 Sep 2026 10:31:19 -0400 Subject: [PATCH 1/2] iOS: support JIT on iOS 26 (TXM/SPTM devices) 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 --- include/MemoryFunction.h | 1 + src/MemoryFunction.cpp | 247 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 247 insertions(+), 1 deletion(-) diff --git a/include/MemoryFunction.h b/include/MemoryFunction.h index fca4a0f2..45a4b31b 100644 --- a/include/MemoryFunction.h +++ b/include/MemoryFunction.h @@ -24,6 +24,7 @@ class CMemoryFunction void operator()(void*); void* GetCode() const; + void* GetWritableCode() const; size_t GetSize() const; void BeginModify(); diff --git a/src/MemoryFunction.cpp b/src/MemoryFunction.cpp index 73a3dabf..d9083e4a 100644 --- a/src/MemoryFunction.cpp +++ b/src/MemoryFunction.cpp @@ -22,6 +22,15 @@ #if TARGET_CPU_ARM64 #define MEMFUNC_MMAP_REQUIRES_JIT_WRITE_PROTECT #endif + #elif TARGET_OS_IPHONE && TARGET_CPU_ARM64 + // iOS 26 on TXM/SPTM hardware (A15+): the process itself can never make a + // page executable - MAP_JIT returns EPERM without dynamic-codesigning, and + // mprotect silently strips PROT_EXEC. Only a write performed *through an + // attached debug connection* marks a page as JIT-executable. StikDebug's + // universal.js implements that debugger side; the app has to ask for it by + // trapping with brk #0xf00d (x16 = command). See StikJIT INTEGRATION.md. + #define MEMFUNC_USE_MMAP + #define MEMFUNC_IOS26_JIT_PROTOCOL #else #define MEMFUNC_USE_MACHVM #if TARGET_OS_IPHONE @@ -43,6 +52,194 @@ #elif defined(MEMFUNC_USE_MMAP) #include #include +#if defined(MEMFUNC_IOS26_JIT_PROTOCOL) +#include +#include +#include +#include +#include +#include +#include + +// --- iOS 26 TXM JIT protocol (StikDebug / StikJIT "universal" script) --------- +// On TXM/SPTM hardware (A15+) a process can never grant itself PROT_EXEC. The +// only mechanism is an out-of-process write performed by an attached debugger: +// for each 16K page of a region, debugserver writes one byte, and that write is +// what grants the page execute permission. StikDebug's universal.js implements +// that side; the app requests it with brk #0xf00d (command in x16, args x0/x1). +// +// Two details are load-bearing and easy to get wrong: +// * The region address passed in x0 MUST be null. That selects the debugger's +// "fresh allocation" branch (it allocates via GDB-remote _M,rx and then +// prepares it). Passing an address we allocated ourselves returns success but +// silently never grants execute permission. +// * The region handed back is execute-only - writing to it faults. A second, +// writable alias of the same physical pages is made locally with vm_remap. +// +// A brk with no debugger attached raises an unhandled SIGTRAP that kills the +// process, so every call is gated on CS_DEBUGGED. We never send JIT26Detach: +// execute permission is tied to the debugger staying attached. +extern "C" int csops(pid_t pid, unsigned int ops, void* useraddr, size_t usersize); + +static bool MemFunc_IsDebuggerAttached() +{ + uint32_t flags = 0; + if(csops(getpid(), 0 /*CS_OPS_STATUS*/, &flags, sizeof(flags)) != 0) return false; + return (flags & 0x10000000u) != 0; //CS_DEBUGGED +} + +__attribute__((noinline, optnone, naked)) +static void* JIT26PrepareRegion(void* address, size_t length) +{ + __asm__ volatile( + "mov x16, #1\n" + "brk #0xf00d\n" + "ret\n"); +} + +// One dual-mapped arena for the whole session: the executable side is obtained +// once from the debugger, the writable side is a local alias of it, and every +// block is sub-allocated out of the pair. +static const size_t MEMFUNC_JIT_ARENA_SIZE = 64 * 1024 * 1024; + +namespace +{ + struct MEMFUNC_FREE_CHUNK + { + size_t offset; + size_t size; + }; +} + +static uint8* g_jitArenaRx = nullptr; +static uint8* g_jitArenaRw = nullptr; +static size_t g_jitArenaBump = 0; +static bool g_jitArenaReady = false; +static bool g_jitArenaTried = false; +static std::mutex g_jitArenaMutex; +static std::vector g_jitArenaFree; +static char g_jitStatus[256] = "jit: not initialized"; + +static void MemFunc_ArenaInitLocked() +{ + if(g_jitArenaTried) return; + g_jitArenaTried = true; + + if(!MemFunc_IsDebuggerAttached()) + { + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL no debugger (CS_DEBUGGED=0)"); + return; + } + + //x0 must be null: this is the debugger's fresh-allocation branch. + void* rx = JIT26PrepareRegion(nullptr, MEMFUNC_JIT_ARENA_SIZE); + if(rx == nullptr) + { + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL PrepareRegion(null,%zuMB)=NULL", + static_cast(MEMFUNC_JIT_ARENA_SIZE >> 20)); + return; + } + + //The returned region is execute-only; alias it for writing. + vm_address_t rw = 0; + vm_prot_t curProt = VM_PROT_NONE; + vm_prot_t maxProt = VM_PROT_NONE; + kern_return_t kr = vm_remap(mach_task_self(), &rw, static_cast(MEMFUNC_JIT_ARENA_SIZE), + 0, VM_FLAGS_ANYWHERE, mach_task_self(), + reinterpret_cast(rx), FALSE, + &curProt, &maxProt, VM_INHERIT_NONE); + if(kr != KERN_SUCCESS) + { + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL vm_remap kr=%d rx=%p", static_cast(kr), rx); + return; + } + kr = vm_protect(mach_task_self(), rw, static_cast(MEMFUNC_JIT_ARENA_SIZE), FALSE, + VM_PROT_READ | VM_PROT_WRITE); + if(kr != KERN_SUCCESS) + { + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL vm_protect kr=%d", static_cast(kr)); + vm_deallocate(mach_task_self(), rw, static_cast(MEMFUNC_JIT_ARENA_SIZE)); + return; + } + + g_jitArenaRx = static_cast(rx); + g_jitArenaRw = reinterpret_cast(rw); + g_jitArenaReady = true; + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: OK %zuMB rx=%p rw=%p", + static_cast(MEMFUNC_JIT_ARENA_SIZE >> 20), + static_cast(g_jitArenaRx), static_cast(g_jitArenaRw)); +} + +//Returns the EXECUTABLE address of a fresh block, or null when unavailable. +static void* MemFunc_ArenaAlloc(size_t size) +{ + std::lock_guard lock(g_jitArenaMutex); + MemFunc_ArenaInitLocked(); + if(!g_jitArenaReady) return nullptr; + size_t aligned = (size + (BLOCK_ALIGN - 1)) & ~static_cast(BLOCK_ALIGN - 1); + for(size_t i = 0; i < g_jitArenaFree.size(); i++) + { + if(g_jitArenaFree[i].size >= aligned) + { + size_t offset = g_jitArenaFree[i].offset; + if(g_jitArenaFree[i].size >= aligned + BLOCK_ALIGN) + { + g_jitArenaFree[i].offset += aligned; + g_jitArenaFree[i].size -= aligned; + } + else + { + g_jitArenaFree.erase(g_jitArenaFree.begin() + i); + } + return g_jitArenaRx + offset; + } + } + if((g_jitArenaBump + aligned) > MEMFUNC_JIT_ARENA_SIZE) return nullptr; + size_t offset = g_jitArenaBump; + g_jitArenaBump += aligned; + return g_jitArenaRx + offset; +} + +static bool MemFunc_ArenaOwns(void* ptr) +{ + return g_jitArenaReady && (ptr >= g_jitArenaRx) && (ptr < (g_jitArenaRx + MEMFUNC_JIT_ARENA_SIZE)); +} + +//Maps an executable arena address to its writable alias. +static void* MemFunc_ArenaToWritable(void* ptr) +{ + if(!MemFunc_ArenaOwns(ptr)) return ptr; + return g_jitArenaRw + (static_cast(ptr) - g_jitArenaRx); +} + +static void MemFunc_ArenaFree(void* ptr, size_t size) +{ + std::lock_guard lock(g_jitArenaMutex); + size_t aligned = (size + (BLOCK_ALIGN - 1)) & ~static_cast(BLOCK_ALIGN - 1); + g_jitArenaFree.push_back({static_cast(static_cast(ptr) - g_jitArenaRx), aligned}); +} + +//Called by the app during launch, while the JIT script is still attached. +extern "C" void MemFunc_InitJitArena() +{ + std::lock_guard lock(g_jitArenaMutex); + MemFunc_ArenaInitLocked(); +} + +extern "C" const char* MemFunc_GetJitStatus() +{ + return g_jitStatus; +} + +//True once an executable JIT region has actually been obtained. This is the +//authoritative "is JIT usable" signal on iOS 26 - process-level flags like a +//debugger being attached do not imply an executable region was granted. +extern "C" bool MemFunc_IsJitReady() +{ + std::lock_guard lock(g_jitArenaMutex); + return g_jitArenaReady; +} +#endif #elif defined(MEMFUNC_USE_WASM) EM_JS_DEPS(WasmMemoryFunction, "$addFunction,$removeFunction"); EM_JS(int, WasmCreateFunction, (emscripten::EM_VAL moduleHandle), @@ -119,12 +316,29 @@ CMemoryFunction::CMemoryFunction(const void* code, size_t size) additionalMapFlags = MEMFUNC_MMAP_ADDITIONAL_FLAGS; #endif m_size = size; - m_code = mmap(nullptr, size, PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | additionalMapFlags, -1, 0); +#ifdef MEMFUNC_IOS26_JIT_PROTOCOL + // m_code is the EXECUTABLE address (sub-allocated from the debugger-prepared + // arena); the code itself is written through its writable alias. If the arena + // is unavailable we fall back to a plain mapping so the app still runs (it + // just won't be able to execute, which the caller reports via the status). + m_code = MemFunc_ArenaAlloc(size); + if(m_code == nullptr) + { + m_code = mmap(nullptr, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + assert(m_code != MAP_FAILED); + } +#else + m_code = mmap(nullptr, size, PROT_READ | PROT_WRITE | PROT_EXEC, MAP_PRIVATE | MAP_ANONYMOUS | additionalMapFlags, -1, 0); assert(m_code != MAP_FAILED); +#endif #ifdef MEMFUNC_MMAP_REQUIRES_JIT_WRITE_PROTECT pthread_jit_write_protect_np(false); #endif +#ifdef MEMFUNC_IOS26_JIT_PROTOCOL + memcpy(MemFunc_ArenaToWritable(m_code), code, size); +#else memcpy(m_code, code, size); +#endif #ifdef MEMFUNC_MMAP_REQUIRES_JIT_WRITE_PROTECT pthread_jit_write_protect_np(true); #endif @@ -164,7 +378,19 @@ void CMemoryFunction::Reset() #elif defined(MEMFUNC_USE_MACHVM) vm_deallocate(mach_task_self(), reinterpret_cast(m_code), m_size); #elif defined(MEMFUNC_USE_MMAP) + #ifdef MEMFUNC_IOS26_JIT_PROTOCOL + // Arena memory must never be unmapped - it can't be blessed again. + if(MemFunc_ArenaOwns(m_code)) + { + MemFunc_ArenaFree(m_code, m_size); + } + else + { + munmap(m_code, m_size); + } + #else munmap(m_code, m_size); + #endif #elif defined(MEMFUNC_USE_WASM) WasmDeleteFunction(reinterpret_cast(m_code)); #endif @@ -204,6 +430,18 @@ void* CMemoryFunction::GetCode() const return m_code; } +//Address to write generated code through. Same as GetCode() everywhere except +//iOS 26, where the executable mapping is not writable and a separate alias of +//the same physical pages must be used. +void* CMemoryFunction::GetWritableCode() const +{ +#ifdef MEMFUNC_IOS26_JIT_PROTOCOL + return MemFunc_ArenaToWritable(m_code); +#else + return m_code; +#endif +} + size_t CMemoryFunction::GetSize() const { return m_size; @@ -242,3 +480,10 @@ CMemoryFunction CMemoryFunction::CreateInstance() return CMemoryFunction(GetCode(), GetSize()); #endif } + +#if defined(__APPLE__) && TARGET_OS_IPHONE && !defined(MEMFUNC_IOS26_JIT_PROTOCOL) +//iOS targets that don't use the TXM JIT protocol still link against these. +extern "C" void MemFunc_InitJitArena() {} +extern "C" const char* MemFunc_GetJitStatus() { return "jit: not applicable"; } +extern "C" bool MemFunc_IsJitReady() { return false; } +#endif From 23b29221fd2437dfb918b108a32af571be0a86ab Mon Sep 17 00:00:00 2001 From: Johnzx07 Date: Fri, 11 Sep 2026 18:02:32 -0400 Subject: [PATCH 2/2] iOS: harden JIT region acquisition 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 --- src/MemoryFunction.cpp | 65 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 56 insertions(+), 9 deletions(-) diff --git a/src/MemoryFunction.cpp b/src/MemoryFunction.cpp index d9083e4a..99bf0b10 100644 --- a/src/MemoryFunction.cpp +++ b/src/MemoryFunction.cpp @@ -120,22 +120,69 @@ static std::mutex g_jitArenaMutex; static std::vector g_jitArenaFree; static char g_jitStatus[256] = "jit: not initialized"; +//An unserviced request doesn't necessarily return null: the breakpoint encoding +//can be left behind in x0, so the result has to be validated before it's used as +//an address. +static bool MemFunc_IsUsableJitRegion(void* ptr) +{ + uintptr_t value = reinterpret_cast(ptr); + if(value == 0) return false; + if(value == static_cast(-1)) return false; + //Leftovers from an unserviced breakpoint (0x69 is the legacy brk immediate). + if(value == 0x690000e0ull) return false; + if(value == 0xcccccccc690000e0ull) return false; + //Anything the debugger hands back is page aligned. + if((value & 0x3FFFull) != 0) return false; + return true; +} + static void MemFunc_ArenaInitLocked() { if(g_jitArenaTried) return; g_jitArenaTried = true; - if(!MemFunc_IsDebuggerAttached()) + void* rx = nullptr; + const char* source = "none"; + + //Preferred path: ask the attached debugger for an executable region. This is + //the only thing that can work where TXM is enforced, and it works fine where + //it isn't, so we never branch on a TXM check of our own. Such a check goes + //stale as soon as Apple enables TXM on more devices in a point release, which + //sends the app down a path that can no longer produce executable memory. + if(MemFunc_IsDebuggerAttached()) { - snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL no debugger (CS_DEBUGGED=0)"); - return; + //The script can be momentarily busy or suspended, so don't give up on the + //first miss. + for(unsigned int attempt = 0; attempt < 3; attempt++) + { + void* candidate = JIT26PrepareRegion(nullptr, MEMFUNC_JIT_ARENA_SIZE); + if(MemFunc_IsUsableJitRegion(candidate)) + { + rx = candidate; + source = "debugger"; + break; + } + usleep(50 * 1000); + } + } + + //Where nothing is enforcing W^X the process can still map an executable + //region itself, so fall back to that rather than failing outright. + if(rx == nullptr) + { + void* mapped = mmap(nullptr, MEMFUNC_JIT_ARENA_SIZE, PROT_READ | PROT_EXEC, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if(mapped != MAP_FAILED) + { + rx = mapped; + source = "mmap"; + } } - //x0 must be null: this is the debugger's fresh-allocation branch. - void* rx = JIT26PrepareRegion(nullptr, MEMFUNC_JIT_ARENA_SIZE); if(rx == nullptr) { - snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL PrepareRegion(null,%zuMB)=NULL", + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL no region (dbg=%d, %zuMB)", + MemFunc_IsDebuggerAttached() ? 1 : 0, static_cast(MEMFUNC_JIT_ARENA_SIZE >> 20)); return; } @@ -150,7 +197,7 @@ static void MemFunc_ArenaInitLocked() &curProt, &maxProt, VM_INHERIT_NONE); if(kr != KERN_SUCCESS) { - snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL vm_remap kr=%d rx=%p", static_cast(kr), rx); + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: FAIL vm_remap kr=%d (%s) rx=%p", static_cast(kr), source, rx); return; } kr = vm_protect(mach_task_self(), rw, static_cast(MEMFUNC_JIT_ARENA_SIZE), FALSE, @@ -165,8 +212,8 @@ static void MemFunc_ArenaInitLocked() g_jitArenaRx = static_cast(rx); g_jitArenaRw = reinterpret_cast(rw); g_jitArenaReady = true; - snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: OK %zuMB rx=%p rw=%p", - static_cast(MEMFUNC_JIT_ARENA_SIZE >> 20), + snprintf(g_jitStatus, sizeof(g_jitStatus), "jit: OK via %s %zuMB rx=%p rw=%p", + source, static_cast(MEMFUNC_JIT_ARENA_SIZE >> 20), static_cast(g_jitArenaRx), static_cast(g_jitArenaRw)); }