From 3e98703fc8ca943e5c7f5dd494245aa3f8c694b5 Mon Sep 17 00:00:00 2001 From: yripper Date: Tue, 13 Oct 2026 02:20:31 -0300 Subject: [PATCH 1/2] feat(libc): reserve the guest arena on macOS macOS has no /proc/self/maps or MAP_FIXED_NOREPLACE, so the arena never came up and every guest allocation threw. Enumerate host regions with mach_vm_region and reserve free chunks with a fixed mach_vm_map, which fails instead of replacing. This skips dyld, the Rosetta runtime, and ranges the kernel never lets a process map (0x10_0000_0000-0x70_0000_0000). The POSIX commit/protection paths now build on macOS unchanged. Adds standalone guest_arena tests (x86_64 under Rosetta and arm64). --- core/libs/CMakeLists.txt | 1 + core/libs/prx/libc/src/GuestArena.cpp | 68 +++++++++-- core/libs/tests/GuestArenaHost.cpp | 125 +++++++++++++++++++++ core/libs/tests/guest_arena/CMakeLists.txt | 21 ++++ 4 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 core/libs/tests/GuestArenaHost.cpp create mode 100644 core/libs/tests/guest_arena/CMakeLists.txt diff --git a/core/libs/CMakeLists.txt b/core/libs/CMakeLists.txt index 8385138261..6a4c4aa131 100644 --- a/core/libs/CMakeLists.txt +++ b/core/libs/CMakeLists.txt @@ -257,6 +257,7 @@ if(BUILD_TESTING) endif() if(APPLE) add_subdirectory(tests/write_watch) + add_subdirectory(tests/guest_arena) endif() if(WIN32) add_test_executable(windows_write_watch_tests tests/WindowsWriteWatch.cpp) diff --git a/core/libs/prx/libc/src/GuestArena.cpp b/core/libs/prx/libc/src/GuestArena.cpp index ca580fc5bd..008ad0da76 100644 --- a/core/libs/prx/libc/src/GuestArena.cpp +++ b/core/libs/prx/libc/src/GuestArena.cpp @@ -11,6 +11,10 @@ #include #include #endif +#ifdef __APPLE__ +#include +#include +#endif #include #include #include @@ -148,8 +152,8 @@ public: private: Arena() { -#if defined(__linux__) - SeedLinuxUsedRanges(); +#if defined(__linux__) || defined(__APPLE__) + SeedHostUsedRanges(); #elif defined(_WIN32) _writeWatched = std::getenv("APS5_NO_WRITE_WATCH") == nullptr; SYSTEM_INFO system{}; @@ -182,7 +186,8 @@ private: } #if defined(__linux__) - void SeedLinuxUsedRanges() { + static std::vector> HostMappings() { + std::vector> mappings; std::ifstream maps("/proc/self/maps"); if (!maps.is_open()) throw std::runtime_error("guest arena: cannot open /proc/self/maps"); std::string line; @@ -191,6 +196,51 @@ private: std::uintptr_t begin = 0, end = 0; char dash = 0; if (!(fields >> std::hex >> begin >> dash >> end) || dash != '-' || end <= begin) throw std::runtime_error("guest arena: malformed /proc/self/maps line: " + line); + mappings.emplace_back(begin, end); + } + return mappings; + } + + static int ReserveNoReplace(std::uintptr_t address, std::size_t bytes) { + void* const wanted = reinterpret_cast(address); + void* const placed = mmap(wanted, bytes, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED_NOREPLACE, -1, 0); + if (placed == wanted) return 0; + if (placed == MAP_FAILED) return errno; + munmap(placed, bytes); + return EINVAL; + } +#elif defined(__APPLE__) + // macOS has no /proc/self/maps; dyld, the Rosetta runtime and ranges the kernel never lets a process + // map (max protection none, e.g. 0x10_0000_0000-0x70_0000_0000) all sit inside the PS5 application map. + static std::vector> HostMappings() { + std::vector> mappings; + mach_vm_address_t address = ArenaStart; + while (address < ApplicationAreaEnd) { + mach_vm_size_t size = 0; + vm_region_basic_info_data_64_t info{}; + mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64; + mach_port_t object = MACH_PORT_NULL; + const kern_return_t result = mach_vm_region(mach_task_self(), &address, &size, VM_REGION_BASIC_INFO_64, reinterpret_cast(&info), &count, &object); + if (result == KERN_INVALID_ADDRESS) break; + if (result != KERN_SUCCESS) throw std::runtime_error("guest arena: mach_vm_region failed"); + mappings.emplace_back(static_cast(address), static_cast(address + size)); + address += size; + } + return mappings; + } + + // MAP_FIXED_NOREPLACE does not exist on macOS; a fixed mach_vm_map fails instead of replacing. + static int ReserveNoReplace(std::uintptr_t address, std::size_t bytes) { + mach_vm_address_t wanted = address; + const kern_return_t result = mach_vm_map(mach_task_self(), &wanted, bytes, 0, VM_FLAGS_FIXED, MEMORY_OBJECT_NULL, 0, FALSE, VM_PROT_NONE, VM_PROT_ALL, VM_INHERIT_DEFAULT); + if (result == KERN_SUCCESS) return 0; + return result == KERN_NO_SPACE ? EEXIST : ENOMEM; + } +#endif + +#if defined(__linux__) || defined(__APPLE__) + void SeedHostUsedRanges() { + for (const auto& [begin, end] : HostMappings()) { if (end <= ArenaStart || begin >= ApplicationAreaEnd) continue; const auto first = std::max(begin, ArenaStart); const auto last = std::min(end, ApplicationAreaEnd); @@ -208,12 +258,8 @@ private: constexpr std::uintptr_t kChunk = 1ULL << 30; for (std::uintptr_t cursor = from; cursor < to; cursor += kChunk) { const auto end = std::min(cursor + kChunk, to); - void* const wanted = reinterpret_cast(cursor); - void* const placed = mmap(wanted, end - cursor, PROT_NONE, - MAP_PRIVATE | MAP_ANONYMOUS | MAP_NORESERVE | MAP_FIXED_NOREPLACE, -1, 0); - if (placed == wanted) continue; - const int failed = placed == MAP_FAILED ? errno : EINVAL; - if (placed != MAP_FAILED) munmap(placed, end - cursor); + const int failed = ReserveNoReplace(cursor, end - cursor); + if (failed == 0) continue; if (failed != EEXIST) { char message[128]; std::snprintf(message, sizeof(message), "guest arena: cannot reserve 0x%llx+0x%llx", @@ -428,7 +474,7 @@ void GuestArenaForgetSection_nid_postfix(void* section) { } #endif -#if defined(__linux__) +#if defined(__linux__) || defined(__APPLE__) namespace { int PosixProtection(std::uint32_t windowsProtection) { switch (windowsProtection) { @@ -449,7 +495,7 @@ void* PlaceAt(void* pointer, std::size_t bytes, std::uint32_t protection) { } #endif -#if defined(__linux__) +#if defined(__linux__) || defined(__APPLE__) void GuestArenaSetProtection_nid_postfix(std::uintptr_t address, std::size_t bytes, std::uint32_t protection) { if (!Arena::Get().Contains(reinterpret_cast(address), bytes)) throw OutsideArena("set protection", reinterpret_cast(address), bytes); if (mprotect(reinterpret_cast(address), bytes, PosixProtection(protection)) != 0) diff --git a/core/libs/tests/GuestArenaHost.cpp b/core/libs/tests/GuestArenaHost.cpp new file mode 100644 index 0000000000..6378a14180 --- /dev/null +++ b/core/libs/tests/GuestArenaHost.cpp @@ -0,0 +1,125 @@ +#include "prx/libc/include/GuestArena.hpp" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#if !defined(__APPLE__) +#error These tests cover the macOS guest arena reservation +#endif + +using namespace GuestArena; + +namespace { + +constexpr std::uintptr_t ArenaStart = 0x0000000200000000ull; +constexpr std::uintptr_t ApplicationAreaEnd = 0x000000FC00000000ull; +constexpr std::uint32_t ProtectionNoAccess = 0x01; +constexpr std::uint32_t ProtectionReadOnly = 0x02; +constexpr std::uint32_t ProtectionReadWrite = 0x04; + +struct HostRegion { + std::uintptr_t start; + std::uintptr_t end; + vm_prot_t maxProtection; +}; + +void Require(bool condition, const char* message) { + if (!condition) throw std::runtime_error(message); +} + +std::vector RegionsInArenaRange() { + std::vector regions; + mach_vm_address_t address = ArenaStart; + while (address < ApplicationAreaEnd) { + mach_vm_size_t size = 0; + vm_region_basic_info_data_64_t info{}; + mach_msg_type_number_t count = VM_REGION_BASIC_INFO_COUNT_64; + mach_port_t object = MACH_PORT_NULL; + if (mach_vm_region(mach_task_self(), &address, &size, VM_REGION_BASIC_INFO_64, reinterpret_cast(&info), &count, &object) != KERN_SUCCESS) break; + if (address >= ApplicationAreaEnd) break; + regions.push_back({static_cast(address), static_cast(address + size), info.max_protection}); + address += size; + } + return regions; +} + +// The kernel keeps ranges inside the PS5 application map that no process may map (max protection none); +// the arena has to come up around them instead of giving up. +void CheckAvailable() { + Require(GuestArenaAvailable_nid_postfix(), "the guest arena is unavailable on macOS"); + std::uintptr_t base = 0; + std::size_t bytes = 0; + GuestArenaRange_nid_postfix(&base, &bytes); + Require(base == ArenaStart, "the guest arena does not start at the PS5 application map base"); + Require(base + bytes == ApplicationAreaEnd, "the guest arena does not end at the PS5 application map limit"); +} + +void CheckSkipsUnmappable() { + for (const auto& region : RegionsInArenaRange()) { + if (region.maxProtection != VM_PROT_NONE || region.end - region.start < 0x10000) continue; + void* placed = GuestArenaAllocateAtOrAbove_nid_postfix(region.start, 0x10000, 0x10000); + const auto address = reinterpret_cast(placed); + Require(address >= region.end, "an allocation landed inside a range the kernel never lets the process map"); + GuestArenaRelease_nid_postfix(placed, 0x10000); + return; + } + std::puts("no unmappable range inside the arena on this host; nothing to skip"); +} + +void CheckHostRegionsKept() { + std::size_t hostRegions = 0; + for (const auto& region : RegionsInArenaRange()) { + // Reservations made by the arena itself are no-access with full max protection. + if (region.maxProtection == VM_PROT_ALL) continue; + ++hostRegions; + bool rejected = false; + try { + GuestArenaMarkUsed_nid_postfix(reinterpret_cast(region.start), region.end - region.start); + } catch (const std::runtime_error&) { + rejected = true; + } + Require(rejected, "a fixed guest mapping over a host region was accepted"); + } + std::printf("%zu host regions inside the arena range are protected\n", hostRegions); +} + +void CheckCommitAndProtect() { + constexpr std::size_t bytes = 0x10000; + auto* pointer = static_cast(GuestArenaAllocate_nid_postfix(bytes, bytes)); + GuestArenaCommit_nid_postfix(pointer, bytes, ProtectionReadWrite, bytes); + pointer[0] = 0x5a; + pointer[bytes - 1] = 0xa5; + Require(pointer[0] == 0x5a && pointer[bytes - 1] == 0xa5, "committed guest memory does not keep writes"); + GuestArenaSetProtection_nid_postfix(reinterpret_cast(pointer), bytes, ProtectionReadOnly); + Require(pointer[0] == 0x5a, "read-only guest memory lost its contents"); + GuestArenaReset_nid_postfix(pointer, bytes); + GuestArenaSetProtection_nid_postfix(reinterpret_cast(pointer), bytes, ProtectionNoAccess); + GuestArenaRelease_nid_postfix(pointer, bytes); +} + +} + +int main(int argc, char** argv) { + try { + Require(argc == 2, "expected available, unmappable, host or commit"); + const std::string_view mode(argv[1]); + if (mode == "available") CheckAvailable(); + else if (mode == "unmappable") CheckSkipsUnmappable(); + else if (mode == "host") CheckHostRegionsKept(); + else if (mode == "commit") CheckCommitAndProtect(); + else throw std::runtime_error("unknown guest arena test"); + std::printf("guest arena %s passed\n", argv[1]); + return 0; + } catch (const std::exception& error) { + std::fprintf(stderr, "%s\n", error.what()); + return 1; + } +} diff --git a/core/libs/tests/guest_arena/CMakeLists.txt b/core/libs/tests/guest_arena/CMakeLists.txt new file mode 100644 index 0000000000..f84f4e6842 --- /dev/null +++ b/core/libs/tests/guest_arena/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.20) + +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + project(GuestArenaTests LANGUAGES CXX) + enable_testing() + set(ANYPS5_TEST_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/tests") +endif() + +find_package(Threads REQUIRED) +add_executable(guest_arena_tests ../GuestArenaHost.cpp ../../prx/libc/src/GuestArena.cpp ../../prx/libc/src/GuestWriteWatch.cpp) +target_include_directories(guest_arena_tests PRIVATE ../..) +target_compile_features(guest_arena_tests PRIVATE cxx_std_20) +target_link_libraries(guest_arena_tests PRIVATE Threads::Threads) +set_target_properties(guest_arena_tests PROPERTIES + CXX_EXTENSIONS OFF + RUNTIME_OUTPUT_DIRECTORY "$<1:${ANYPS5_TEST_OUTPUT_DIRECTORY}>") + +foreach(mode available unmappable host commit) + add_test(NAME guest_arena_${mode} COMMAND guest_arena_tests ${mode}) + set_tests_properties(guest_arena_${mode} PROPERTIES TIMEOUT 30) +endforeach() From 913adca3aabedfe6bbaabe6cb528225911e636eb Mon Sep 17 00:00:00 2001 From: yripper Date: Tue, 13 Oct 2026 02:20:58 -0300 Subject: [PATCH 2/2] docs(libc): record the macOS guest arena holes as debt Move the explanatory comments into docs/dev/debt/libc.md, as the conventions require. --- core/libs/prx/libc/src/GuestArena.cpp | 3 --- core/libs/tests/GuestArenaHost.cpp | 3 --- docs/dev/debt/libc.md | 1 + 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/core/libs/prx/libc/src/GuestArena.cpp b/core/libs/prx/libc/src/GuestArena.cpp index 008ad0da76..876cf75826 100644 --- a/core/libs/prx/libc/src/GuestArena.cpp +++ b/core/libs/prx/libc/src/GuestArena.cpp @@ -210,8 +210,6 @@ private: return EINVAL; } #elif defined(__APPLE__) - // macOS has no /proc/self/maps; dyld, the Rosetta runtime and ranges the kernel never lets a process - // map (max protection none, e.g. 0x10_0000_0000-0x70_0000_0000) all sit inside the PS5 application map. static std::vector> HostMappings() { std::vector> mappings; mach_vm_address_t address = ArenaStart; @@ -229,7 +227,6 @@ private: return mappings; } - // MAP_FIXED_NOREPLACE does not exist on macOS; a fixed mach_vm_map fails instead of replacing. static int ReserveNoReplace(std::uintptr_t address, std::size_t bytes) { mach_vm_address_t wanted = address; const kern_return_t result = mach_vm_map(mach_task_self(), &wanted, bytes, 0, VM_FLAGS_FIXED, MEMORY_OBJECT_NULL, 0, FALSE, VM_PROT_NONE, VM_PROT_ALL, VM_INHERIT_DEFAULT); diff --git a/core/libs/tests/GuestArenaHost.cpp b/core/libs/tests/GuestArenaHost.cpp index 6378a14180..6a9a907466 100644 --- a/core/libs/tests/GuestArenaHost.cpp +++ b/core/libs/tests/GuestArenaHost.cpp @@ -51,8 +51,6 @@ std::vector RegionsInArenaRange() { return regions; } -// The kernel keeps ranges inside the PS5 application map that no process may map (max protection none); -// the arena has to come up around them instead of giving up. void CheckAvailable() { Require(GuestArenaAvailable_nid_postfix(), "the guest arena is unavailable on macOS"); std::uintptr_t base = 0; @@ -77,7 +75,6 @@ void CheckSkipsUnmappable() { void CheckHostRegionsKept() { std::size_t hostRegions = 0; for (const auto& region : RegionsInArenaRange()) { - // Reservations made by the arena itself are no-access with full max protection. if (region.maxProtection == VM_PROT_ALL) continue; ++hostRegions; bool rejected = false; diff --git a/docs/dev/debt/libc.md b/docs/dev/debt/libc.md index e92d357f7f..dca57c44c6 100644 --- a/docs/dev/debt/libc.md +++ b/docs/dev/debt/libc.md @@ -48,6 +48,7 @@ - a system call that writes into a protected page fails with `EFAULT` instead of faulting. `GuestArena::HostWrite` lifts the protection for the calls it brackets (file reads and socket receives); a host call that writes guest memory without it (`clock_gettime` and the like into a watched page) fails; - code that changes the protection of watched pages, or maps over them, has to call `GuestWriteWatchProtectionChanged`, and code that asks the kernel for a page's protection has to ask `GuestWriteWatchProtection` first, since a watched page is read-only between a collection and the next write. Nothing calls either yet: the macOS memory paths are not in the tree; - the handler is installed when libc loads and passes other faults to the handler installed before it. A handler installed later has to do the same. +- On macOS the [guest arena](../../../core/libs/prx/libc/src/GuestArena.cpp) finds host regions with `mach_vm_region` and reserves the rest with a fixed `mach_vm_map`, which fails instead of replacing, as macOS has no `/proc/self/maps` or `MAP_FIXED_NOREPLACE`. The kernel keeps 0x10_0000_0000-0x70_0000_0000 unmappable for every process (max protection none; macOS 26.5, M1 Pro, arm64 and x86_64 under Rosetta), and dyld and the Rosetta runtime sit near 0x2_0200_0000 and 0x3_0400_0000, so a guest mapping fixed inside those ranges fails where the console places it; mappings without a fixed address are placed around them. - [sceKernelSync](../../../core/libs/prx/libc/src/General.cpp) (libkernel) flushes only the host files and directories recorded through `RecordWrittenPath_nid_no_patch` (the libc, libkernel, save data and app content file functions) and their changed parent directories. A host write in a library that does not record its path is not flushed by `sceKernelSync` - [sceKernelSync](../../../core/libs/prx/libc/src/General.cpp) (libkernel) on Windows skips a recorded file it cannot open for writing (read-only, or held open by a handle that denies write sharing), since `FlushFileBuffers` needs a writable handle. On Linux, it skips a recorded file it cannot open for reading. Such a file's data may not reach the disk before a host crash; the guest reads the same data either way - [`fopen` streams](../../../core/libs/prx/libc/src/FileAndHeap.cpp) (libc) on Windows share delete access, like the descriptors [`sceKernelOpen`](../../../core/libs/prx/libkernel/File/src/Open.cpp) opens, so a file the guest holds open can be removed or renamed. A stream reopened with `freopen` still goes through `_wfreopen`, which does not share delete access. On Windows, opening a directory in a read mode fails with `EACCES`, where FreeBSD opens it; directory streams are not emulated