Skip to content

fix(libkernel): report the precommitted stack of an adopted thread on Windows - #5918

Merged
adriadam10 merged 1 commit into
boykopovar:mainfrom
karimibrahim07:fix/libkernel-adopted-stack
Oct 18, 2026
Merged

adriadam10 merged 1 commit into
boykopovar:mainfrom
karimibrahim07:fix/libkernel-adopted-stack

Conversation

@karimibrahim07

@karimibrahim07 karimibrahim07 commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

What

Java 25 LAN / DNS (PPSA99869, #5501) runs on main since #5643, but its Java VM prints on screen:

[3.544s][warning][os,thread] Attempt to allocate stack guard pages failed.

HotSpot (the PS5 port in mateo-cogeanu/ps5-java25-homebrew, patch 0019-ps5-preserve-native-reservations) commits the guard pages at the bottom of the main thread's stack with mprotect(bottom, 48 KiB, PROT_READ | PROT_WRITE), the bottom coming from pthread_attr_get_np. Its trace shows the call failing:

ENTER: Main stack guard pages
FAIL: Native RW memory commit addr=0x400000 bytes=49152 mode=3 errno=22

The main thread is adopted (scePthreadSelf), and on Windows SetStackFromHost reports its whole host reservation, as sceKernelIsStack does for a host thread before adoption. A relinked executable reserves 0x210000 bytes for that stack and commits the top 0x200000 (WindowsPeWriter.cpp), so the lowest 64 KiB of the reported stack hold only reserved pages and Windows' guard pages, and the host mprotect in DoMprotect's guest stack branch fails there (EINVAL).

Threads AnyPS5 creates already report only their committed stack (999a723a5). HostStackLimits now does the same for a host thread when its stack is laid out that way: if everything above the bottom allocation granule is committed read-write, the stack starts there. Any other host stack keeps the whole range, so a stack that is not precommitted, like a test executable's 4 KiB commit, does not shrink. The top of the stack does not move, so collectors that scan from the top (Unity IL2CPP, #761) are unaffected. The committed-pages walk is shared with StartNativeThread and throws when VirtualQuery fails.

The technical-debt entry records that host threads whose stack is not precommitted, and host threads on Linux, still report lower pages that mprotect cannot change.

Tested

Windows 11 Pro (26300), WinLibs GCC 15.2.0 r7, Release, on main f343489.

  • guest_thread_self links with the relinker's stack sizes (--stack=0x210000,0x200000) and checks, on the main thread and on a default-size host thread (through sceKernelIsStack before adoption and scePthreadAttrGet after), that the reported stack is committed read-write throughout and that its lowest page can be made read-write, inaccessible and read-write again. A host thread that commits only 64 KiB must report exactly GetCurrentThreadStackLimits. The test fails without the change (0xc0000409), with the trim applied only at adoption, and with the trim applied regardless of what is committed; it passes with the change (3 runs).
  • Full ctest (--parallel 4 --timeout 120): 1191 of 1192 pass from either shell. From cmd, check_main_sync fails because the file name it prints is not decoded as UTF-8; from Git Bash, windows_trace_imports fails because Defender deletes its generated executable in Git Bash's temporary directory. Each passes in the other shell, and this PR touches neither.
  • python tools/check_conventions.py --base origin/main passes; tools/pr_overlap.py --branch reports no overlap with open pull requests.
  • Java 25 LAN / DNS PPSA99869 01.000.000 (PPSA99869-lan-dns-pass-01.000.000.zip, SHA-256 3cfeed2f…11cc, as in Java 25 LAN / DNS (PPSA99869) #5501), relinked with --windows --to-intel --trap-sdk-syscall-stubs --registry:
    • before, on main with feat(libkernel): implement mincore #5643: FAIL: Native RW memory commit addr=0x400000 bytes=49152 mode=3 errno=22 and the warning above
    • with this PR: DONE: Native RW memory commit addr=0x410000 bytes=49152 mode=3 errno=0, no warning, JNI_CreateJavaVM returned 0, and DestroyJavaVM code 0. The LAN echo fails as before without its helper on a Mac; the DNS stage passed on main with feat(libkernel): implement mincore #5643 and was not rerun with this change.
    • At DestroyJavaVM, HotSpot now also removes the main thread's guard pages, and that fails as it already does for the VM's other threads: Attempt to deallocate stack guard pages failed (0x0000000000410000-0x000000000041c000). HotSpot's BSD os::pd_uncommit_memory maps fresh pages there with mmap(MAP_FIXED | MAP_NORESERVE | MAP_ANONYMOUS, PROT_NONE), which mmap_nid_postfix does not accept for these pages, so those 48 KiB stay inaccessible after the VM is destroyed. That is a separate fix.

Linux not tested; its path is unchanged.

Checklist

  • Based on current main; no other open PR implements the same functions
  • One topic per PR; follow-ups go in a new PR
  • No comments in code except technical debt
  • Unimplemented paths throw (NotImplemented_nid_no_patch); silent stubs are listed under Silent stubs in their library's technical debt file
  • No notes, investigation .md files or images added to the repository (attach them to this PR)
  • New third-party code is a submodule built from source
  • Generic behaviour, not specific to one title
  • Depends on: none
  • Unblocks: PPSA99869 Java 25 LAN / DNS (PPSA99869) #5501; PPSA01556 Resident Evil Village (PPSA01556) #5169, the stack-protect stop (tested by @EagleBearerYS)
  • GPUs tested: n/a
  • AI-assisted: yes

@CnRJay

CnRJay commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

This overlaps in effect with #5703, which is about the same Windows host-stack layout from the other side. #5703 makes DoMprotect commit the reserved pages of a non-arena range before protecting them. That alone would probably let HotSpot's mprotect(bottom, 48 KiB, RW) succeed at the bottom main reports now. This PR instead moves the reported bottom up to the committed part.

The difference a title can see is the reported stack bottom itself. Here it moves up by up to 64 KiB, so a collector or guard-page logic that uses pthread_attr_get_np's range sees a smaller stack. With #5703 the range is unchanged and the pages are committed on demand. As you note, the top is unchanged, so top-down scanners are fine. It would help the maintainers if you and @EagleBearerYS agreed whether both are needed, or which model to keep.

@EagleBearerYS

Copy link
Copy Markdown
Contributor

Our case on Resident Evil Village (PPSA01556), to make the comparison concrete. The protect that stops is on thread 23012's own stack. A local trace of DoMprotect shows the range is exactly the stack AnyPS5 reports for that thread:

[stack-protect] thread 23012 (stack 0x176b40000-0x176d50000): guest-stack protect 0x176b40000+0x210000 prot 0xc2, guest stack 0x176b40000-0x176d50000, own stack, reserved bytes committed 0xd000

It's a default-size host thread, not one AnyPS5 creates: its lowest 0xd000 bytes were still only reserved, and threads AnyPS5 creates are precommitted since 999a723. So this PR's rule should apply to it, and Village would then protect only committed pages. We'll boot Village with this PR in place of #5703 and post whether it passes that stop. @karimibrahim07, could you try PPSA99869 with #5703 in place of this PR?

On fidelity: on FreeBSD a thread's reported stack is all mapped, so a title can read or protect any part of it. On Windows the bottom of a host stack is only reserved, so neither holds there today. This PR reports only the committed part; #5703 keeps the reported range and commits what a protect needs. If one of them alone covers both titles, keep that one. If each covers only its own, keep both.

@karimibrahim07

Copy link
Copy Markdown
Contributor Author

@EagleBearerYS here is PPSA99869 with #5703 in place of this PR, on main 55549e7, with the same setup as in the description:

@CnRJay, the part only this PR changes is what the reported range contains. On a relinked executable's main thread and on default-size host threads, the lowest 64 KiB of the reported stack are 0xd000 reserved bytes and 0x3000 bytes of Windows guard pages. With #5703 alone they stay that way until something protects them, so reading the reserved part faults. With this PR, the stack that pthread_attr_get_np and sceKernelIsStack report for those threads is committed read-write throughout. That's also the case on FreeBSD, and for threads AnyPS5 creates since 999a723. In the other direction, this PR leaves the whole range for a host thread whose stack isn't precommitted, like the STACK_SIZE_PARAM_IS_A_RESERVATION thread in #5703's test, and only #5703 makes a protect work there.

The two PRs don't conflict and cover different cases, so I'd keep both. If the maintainers would rather take one, #5703 alone fixes this title. If both land, I'll update this PR's debt line, because mprotect would then commit those lower pages on Windows.

@EagleBearerYS

Copy link
Copy Markdown
Contributor

Thanks for running it both ways. Agreed, they don't conflict and cover different cases:

So both can land. Our Village boot with this PR in place of #5703 is still queued; I'll post whether it gets past that stop.

@EagleBearerYS

Copy link
Copy Markdown
Contributor

The Village boot: Resident Evil Village (PPSA01556) with this PR in place of #5703 gets past that stop.

Setup:

What changed:

  • The protect that stopped it: this was a protect of a thread's whole stack reserve, 0x16ace0000+0x210000 in an earlier run. It now covers only the committed part, [memory] protect 000000fcdeca0000+0x200000 prot=0xc2, and it succeeds.
  • No stop: no "mprotect of uncommitted memory outside the guest arena" and no fatal error in 196 s. Without either PR it stopped 37-93 s in.

So each PR alone covers both titles, PPSA99869 and Village.

@karimibrahim07
karimibrahim07 force-pushed the fix/libkernel-adopted-stack branch from 2696360 to 3ee93b0 Compare October 14, 2026 20:52
This was referenced Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Other open pull requests touch the same code. Merging one will break the other, or both implement the same thing. If this one builds on them, list them in Depends on.

Pull request Conflicting files (lines on main) Same exports Same new files
PR 7149 core/libs/prx/libkernel/Pthread/src/Thread.cpp L173-174, L676-683
PR 7192 docs/dev/debt/libkernel.md L137-138

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 3986 core/libs/prx/libkernel/Pthread/src/Thread.cpp
PR 4972 core/libs/prx/libkernel/CMakeLists.txt
PR 5021 core/libs/prx/libkernel/CMakeLists.txt
core/libs/prx/libkernel/Pthread/src/Thread.cpp
docs/dev/debt/libkernel.md
PR 5999 docs/dev/debt/libkernel.md
PR 6044 core/libs/prx/libkernel/CMakeLists.txt
core/libs/tests/GuestThreadSelf.cpp
PR 6222 core/libs/prx/libkernel/CMakeLists.txt
core/libs/prx/libkernel/Pthread/src/Thread.cpp
docs/dev/debt/libkernel.md
PR 6510 docs/dev/debt/libkernel.md
PR 6534 core/libs/prx/libkernel/Pthread/src/Thread.cpp
docs/dev/debt/libkernel.md
PR 6879 core/libs/prx/libkernel/Pthread/src/Thread.cpp
PR 6908 core/libs/prx/libkernel/CMakeLists.txt
docs/dev/debt/libkernel.md
PR 6977 core/libs/prx/libkernel/CMakeLists.txt
docs/dev/debt/libkernel.md
PR 7088 docs/dev/debt/libkernel.md
PR 7201 core/libs/prx/libkernel/CMakeLists.txt
core/libs/prx/libkernel/Pthread/src/Thread.cpp
PR 7299 core/libs/prx/libkernel/CMakeLists.txt
docs/dev/debt/libkernel.md

@dogukanatlihan

Copy link
Copy Markdown
Contributor

I reproduced the remaining DestroyJavaVM guard-cleanup warnings using the public LAN_DNS_Probe-v01.000.000 release, PPSA99869. A local libkernel patch removes both warnings while preserving fresh-anonymous-memory semantics.

The release's os::pd_uncommit_memory calls mmap(addr, size, 0, 0x1012, -1, 0). Current fixed-anonymous handling requires GuestArena ownership, so it rejects the native Windows stack ranges. This is separate from the stack-bound reporting change here. The pinned BSD OpenJDK implementation and the release's reservation-preservation patch require replacement with fresh PROT_NONE pages; returning success or changing protection alone would retain old contents.

The patch permits fixed anonymous replacement within one owned native Windows stack, holding the stack ownership lock through validation and replacement. It preserves the reservation, rejects foreign/cross-boundary/native PAGE_GUARD ranges before mutation, and decommits/recommits the target pages. It also preserves protection tracking on both sides of partial replacements so thread cleanup restores them correctly. Only POSIX fixed anonymous mmap opts into this path; other mapping APIs keep their previous behavior. Arena host-allocation holes are excluded from arena ownership. Current-stack replacements must end at least 64 KiB below active host frames; peer-stack callers must coordinate with the peer. No additional mmap flags are enabled.

Windows 11 / WinLibs GCC 15.2.0 results:

  • Unchanged libkernel plus the final regression fails at its first valid fixed stack replacement. The preceding invalid-request and native-guard preservation assertions pass.
  • Patched full Release build succeeds; all 34 selected memory/thread tests pass, no skips. The regression verifies exact addresses, every byte of zero-fill, adjacent-page canaries, PROT_NONE, repeated RW replacement, invalid calls, PAGE_GUARD preservation and partial-range cleanup in a counted pthread-key destructor before native stack release. A synchronized peer-thread case exercises the live-stack registry path. Restoring the old interval-erasure behavior makes the destructor check fail, so cleanup is independently covered.
  • Public homebrew: initialize runtime, create VM, destroy VM. Only libkernel.prx differs across 174 runtime files. Both evidence runs use silent SDL audio and controller-file input; LAN/DNS rows are not run.

Baseline:

JNI_CreateJavaVM returned 0
STAGE: Calling DestroyJavaVM
[88.425s][warning][os,thread] Attempt to deallocate stack guard pages failed (0x0000000000400000-0x000000000040c000).
[88.425s][warning][os,thread] Attempt to deallocate stack guard pages failed (0x0000000000fd0000-0x0000000000fdc000).
STAGE: DestroyJavaVM complete

Patched:

JNI_CreateJavaVM returned 0
STAGE: Calling DestroyJavaVM
STAGE: DestroyJavaVM complete

The patch is based on main 44df21632125dbe3113a19173e7ebb879da53003. Builds reused fa9365ca74acae7c594618b0a857365774cc6f45 plus the existing #6452 AGC test-alignment fix; all six affected source files have identical base blobs at these commits, and the built patched copies match the prepared sources by SHA-256. The code conventions checker passes. Linux GCC syntax checks pass for the changed translation units/test, but Linux runtime, host commit-failure injection, concurrent teardown stress and PS5 hardware behavior were not tested. A failed recommit returns an error and leaves the reservation intact; thread cleanup attempts to recommit it.

Release ZIP SHA-256: 3cfeed2f321334e742fa93070e6983bed201332f7837d907af2db52d547c11cc. The app remains open after VM destruction; the harness terminates it after recording completion. No application-exit or performance claim is intended.

Patch against main
diff --git a/core/libs/prx/libkernel/DirectMemory/DirectMemory.cpp b/core/libs/prx/libkernel/DirectMemory/DirectMemory.cpp
index 4dbce761d..ca0423e49 100644
--- a/core/libs/prx/libkernel/DirectMemory/DirectMemory.cpp
+++ b/core/libs/prx/libkernel/DirectMemory/DirectMemory.cpp
@@ -794,15 +794,15 @@ void* MapPlaced(void* addr, size_t len, int prot, int flags, size_t alignment) {
     return aligned;
 }
 
-void EraseProtections(std::uintptr_t start, std::uintptr_t end) {
-    auto it = g_protections.lower_bound(start);
-    if (it != g_protections.begin() && std::prev(it)->second.end > start) --it;
-    while (it != g_protections.end() && it->first < end) {
+void EraseProtections(std::uintptr_t start, std::uintptr_t end, std::map<std::uintptr_t, ProtectedRange>& protections = g_protections) {
+    auto it = protections.lower_bound(start);
+    if (it != protections.begin() && std::prev(it)->second.end > start) --it;
+    while (it != protections.end() && it->first < end) {
         const auto rangeStart = it->first;
         const auto range = it->second;
-        it = g_protections.erase(it);
-        if (rangeStart < start) g_protections.emplace(rangeStart, ProtectedRange{start, range.prot});
-        if (range.end > end) it = g_protections.emplace(end, ProtectedRange{range.end, range.prot}).first;
+        it = protections.erase(it);
+        if (rangeStart < start) protections.emplace(rangeStart, ProtectedRange{start, range.prot});
+        if (range.end > end) it = protections.emplace(end, ProtectedRange{range.end, range.prot}).first;
     }
 }
 
@@ -937,9 +937,56 @@ int DoMapDirect(void** addr, size_t len, int prot, int flags, int64_t physStart,
     return 0;
 }
 
-int DoMapAnon(void** addr, size_t len, int prot, int flags, size_t alignment, bool exclusive) {
+int DoMapAnon(void** addr, size_t len, int prot, int flags, size_t alignment, bool exclusive, [[maybe_unused]] bool allowStackReplacement) {
     ValidateOutput(addr);
     if (len == 0 || (len & (PS5_PAGE_SIZE - 1)) != 0) return SCE_KERNEL_ERROR_EINVAL;
+#ifdef _WIN32
+    if (allowStackReplacement && (flags & GuestMapFixedFlag) != 0 && !KernelArena::Get().Owns(*addr, len)) {
+        constexpr int GuestMapNoOverwrite = 0x80;
+        if ((prot & ~7) != 0 || (flags & ~(GuestMapFixedFlag | GuestMapNoOverwrite)) != 0)
+            return SCE_KERNEL_ERROR_EINVAL;
+        ValidateRange(*addr, len, ValidateAlignment(alignment));
+        const auto start = reinterpret_cast<std::uintptr_t>(*addr);
+        int result = SCE_KERNEL_ERROR_EINVAL;
+        ReplaceGuestThreadStack(start, len, (prot & 3) == 3, [&] {
+            if (exclusive || (flags & GuestMapNoOverwrite) != 0) {
+                result = SCE_KERNEL_ERROR_ENOMEM;
+                return false;
+            }
+            void* allocation = nullptr;
+            for (auto cursor = start; cursor < start + len;) {
+                MEMORY_BASIC_INFORMATION memory{};
+                if (VirtualQuery(reinterpret_cast<void*>(cursor), &memory, sizeof(memory)) != sizeof(memory) ||
+                    (memory.State != MEM_COMMIT && memory.State != MEM_RESERVE) || memory.Type != MEM_PRIVATE ||
+                    (memory.Protect & PAGE_GUARD) != 0 ||
+                    memory.RegionSize == 0 || reinterpret_cast<std::uintptr_t>(memory.BaseAddress) > cursor ||
+                    memory.RegionSize > std::numeric_limits<std::uintptr_t>::max() - reinterpret_cast<std::uintptr_t>(memory.BaseAddress))
+                    return false;
+                if (allocation == nullptr) allocation = memory.AllocationBase;
+                if (memory.AllocationBase != allocation) return false;
+                const auto next = reinterpret_cast<std::uintptr_t>(memory.BaseAddress) + memory.RegionSize;
+                if (next <= cursor) return false;
+                cursor = std::min(start + len, next);
+            }
+            std::lock_guard lock(g_protectionLock);
+            auto replacement = g_protections;
+            EraseProtections(start, start + len, replacement);
+            const auto recorded = replacement.emplace(start, ProtectedRange{start + len, prot}).first;
+            result = SCE_KERNEL_ERROR_ENOMEM;
+            if (!VirtualFree(*addr, len, MEM_DECOMMIT)) return false;
+            if (VirtualAlloc(*addr, len, MEM_COMMIT, WinProtFromPosix(LinuxProtFromSce(prot))) != *addr) {
+                const auto error = GetLastError();
+                recorded->second.prot = 0;
+                g_protections.swap(replacement);
+                throw std::system_error(static_cast<int>(error), std::system_category(), "Guest stack replacement commit failed");
+            }
+            g_protections.swap(replacement);
+            result = 0;
+            return true;
+        });
+        return result;
+    }
+#endif
     GuestAllocations::Mutation mutation;
     if (exclusive && mutation.Overlaps(*addr, len)) return SCE_KERNEL_ERROR_ENOMEM;
     flags = PlaceHintInReservation(*addr, len, flags);
diff --git a/core/libs/prx/libkernel/DirectMemory/DirectMemory.hpp b/core/libs/prx/libkernel/DirectMemory/DirectMemory.hpp
index a17307030..65028ac7a 100644
--- a/core/libs/prx/libkernel/DirectMemory/DirectMemory.hpp
+++ b/core/libs/prx/libkernel/DirectMemory/DirectMemory.hpp
@@ -35,7 +35,7 @@ void DirectMemoryRetype(int64_t start, size_t len, int memoryType);
 bool DirectMemoryLargestFree(int64_t searchStart, int64_t searchEnd, size_t alignment, int64_t* startOut, size_t* sizeOut);
 size_t DirectMemoryFreeRun(uint64_t offset, uint64_t limit);
 int DoMapDirect(void** addr, size_t len, int prot, int flags, int64_t physStart, size_t alignment);
-int DoMapAnon(void** addr, size_t len, int prot, int flags, size_t alignment = PS5_PAGE_SIZE, bool exclusive = false);
+int DoMapAnon(void** addr, size_t len, int prot, int flags, size_t alignment = PS5_PAGE_SIZE, bool exclusive = false, bool allowStackReplacement = false);
 int DoMapFileBacked_nid_no_patch(void** addr, size_t len, int prot, int flags, int descriptor, std::uint64_t offset, size_t alignment);
 int DoMapShared(void** addr, size_t len, int prot, HostSharedMemory object);
 int DoMprotect(const void* addr, size_t len, int prot);
@@ -49,4 +49,4 @@ void ForgetJitSharedMemory(int descriptor);
 void DuplicateJitSharedMemory(int from, int to);
 void GuestPageTableUsage(std::uint64_t granularity, std::uint64_t* cpu_entries, std::uint64_t* gpu_entries);
 
-#endif
\ No newline at end of file
+#endif
diff --git a/core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp b/core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp
index 6f2c99760..d1426843f 100644
--- a/core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp
+++ b/core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp
@@ -109,15 +109,17 @@ void* APS5_VABI mmap_nid_postfix(void* address, std::size_t length, int protecti
         if (descriptor != -1 || offset != 0) return failed(GuestInvalid);
         const auto start = reinterpret_cast<std::uintptr_t>(address);
         if (fixedAnonymous && ((start & (PS5_PAGE_SIZE - 1)) != 0 ||
-            rounded > std::numeric_limits<std::uintptr_t>::max() - start ||
-            !GuestArena::GuestArenaContains_nid_postfix(address, rounded))) return failed(GuestInvalid);
+            rounded > std::numeric_limits<std::uintptr_t>::max() - start)) return failed(GuestInvalid);
+#ifndef _WIN32
+        if (fixedAnonymous && !GuestArena::GuestArenaContains_nid_postfix(address, rounded)) return failed(GuestInvalid);
+#endif
         const bool exclusive = fixedAnonymous && (mappingFlags & GuestMapExclusive) != 0;
         const int placement = fixedAnonymous ? GuestMapFixed | (exclusive ? GuestMapNoOverwrite : 0) : 0;
         void* mapped = fixedAnonymous ? address : nullptr;
         const auto alignment = !fixedAnonymous && (flags & GuestAlignedSuper) != 0 ? SuperpageSize : PS5_PAGE_SIZE;
         try {
-            const auto result = DoMapAnon(&mapped, rounded, protection, placement, alignment, exclusive);
-            if (result != 0) return failed(GuestNoMemory);
+            const auto result = DoMapAnon(&mapped, rounded, protection, placement, alignment, exclusive, fixedAnonymous);
+            if (result != 0) return failed(result == SCE_KERNEL_ERROR_EINVAL ? GuestInvalid : GuestNoMemory);
             return mapped;
         } catch (const std::invalid_argument&) {
             return failed(fixedAnonymous ? GuestInvalid : GuestNoMemory);
diff --git a/core/libs/prx/libkernel/Pthread/include/Pthread.hpp b/core/libs/prx/libkernel/Pthread/include/Pthread.hpp
index 1252760c5..562fb3005 100644
--- a/core/libs/prx/libkernel/Pthread/include/Pthread.hpp
+++ b/core/libs/prx/libkernel/Pthread/include/Pthread.hpp
@@ -10,6 +10,7 @@
 #include <cstddef>
 #include <cstdint>
 #include <ctime>
+#include <functional>
 #include <mutex>
 #include <string>
 #include <thread>
@@ -155,6 +156,9 @@ struct PthreadPrivate {
 bool GuestThreadStack(std::uintptr_t address, std::uintptr_t* start, std::uintptr_t* end);
 bool GuestThreadStackHostRangeOverlaps(std::uintptr_t start, std::uintptr_t end);
 void NoteGuestStackProtection(std::uintptr_t start, std::size_t bytes, bool readWrite);
+#ifdef _WIN32
+bool ReplaceGuestThreadStack(std::uintptr_t start, std::size_t bytes, bool readWrite, const std::function<bool()>& replace);
+#endif
 int GuestThreadCpuClockId(const PthreadPrivate* thread);
 bool GuestThreadCpuNanos(int cpuClockThread, std::uint64_t* nanos);
 void SetLiveGuestThreadsAffinity(KernelCpumask mask);
diff --git a/core/libs/prx/libkernel/Pthread/src/Thread.cpp b/core/libs/prx/libkernel/Pthread/src/Thread.cpp
index b7e180b1c..be2088ef2 100644
--- a/core/libs/prx/libkernel/Pthread/src/Thread.cpp
+++ b/core/libs/prx/libkernel/Pthread/src/Thread.cpp
@@ -223,20 +223,65 @@ static void RegisterStack(PthreadPrivate* self) {
 static void RestoreStackPages(std::uintptr_t start, std::size_t bytes) noexcept {
 #ifdef _WIN32
     DWORD previous = 0;
-    const bool restored = VirtualProtect(reinterpret_cast<void*>(start), bytes, PAGE_READWRITE, &previous) != 0;
+    const bool restored = VirtualAlloc(reinterpret_cast<void*>(start), bytes, MEM_COMMIT, PAGE_READWRITE) != nullptr &&
+        VirtualProtect(reinterpret_cast<void*>(start), bytes, PAGE_READWRITE, &previous) != 0;
 #else
     const bool restored = ::mprotect(reinterpret_cast<void*>(start), bytes, PROT_READ | PROT_WRITE) == 0;
 #endif
     if (!restored) std::fprintf(stderr, "[thread] cannot restore the protection of guest stack pages 0x%llx+0x%zx at thread exit\n", static_cast<unsigned long long>(start), bytes);
 }
 
+static void ReplaceStackProtection(std::map<std::uintptr_t, std::size_t>& ranges, std::uintptr_t start, std::size_t bytes, bool readWrite) {
+    const auto end = start + bytes;
+    auto it = ranges.upper_bound(start);
+    if (it != ranges.begin() && std::prev(it)->first + std::prev(it)->second > start) --it;
+    while (it != ranges.end() && it->first < end) {
+        const auto previousStart = it->first;
+        const auto previousEnd = previousStart + it->second;
+        it = ranges.erase(it);
+        if (previousStart < start) ranges.emplace(previousStart, start - previousStart);
+        if (previousEnd > end) {
+            ranges.emplace(end, previousEnd - end);
+            break;
+        }
+    }
+    if (!readWrite) ranges[start] = bytes;
+}
+
 void NoteGuestStackProtection(std::uintptr_t start, std::size_t bytes, bool readWrite) {
     std::lock_guard lock(stackLock);
-    auto it = protectedStackPages.upper_bound(start);
-    if (it != protectedStackPages.begin() && std::prev(it)->first + std::prev(it)->second > start) --it;
-    while (it != protectedStackPages.end() && it->first < start + bytes) it = protectedStackPages.erase(it);
-    if (!readWrite) protectedStackPages[start] = bytes;
+    auto replacement = protectedStackPages;
+    ReplaceStackProtection(replacement, start, bytes, readWrite);
+    protectedStackPages.swap(replacement);
+}
+
+#ifdef _WIN32
+bool ReplaceGuestThreadStack(std::uintptr_t start, std::size_t bytes, bool readWrite, const std::function<bool()>& replace) {
+    if (bytes == 0 || bytes > std::numeric_limits<std::uintptr_t>::max() - start) return false;
+    std::uintptr_t low = 0;
+    std::uintptr_t high = 0;
+    const bool current = CurrentStack(&low, &high) && start >= low && start + bytes <= high;
+    constexpr std::uintptr_t HostFrameReserve = 64 * 1024;
+    const auto frame = reinterpret_cast<std::uintptr_t>(&low);
+    if (current && (frame < HostFrameReserve || start + bytes > frame - HostFrameReserve)) return false;
+    std::lock_guard lock(stackLock);
+    if (!current && std::none_of(liveStacks.begin(), liveStacks.end(), [&](const auto& entry) {
+            return start >= entry.second.first && start + bytes <= entry.second.second;
+        })) return false;
+    auto replacement = protectedStackPages;
+    ReplaceStackProtection(replacement, start, bytes, readWrite);
+    auto failure = protectedStackPages;
+    ReplaceStackProtection(failure, start, bytes, false);
+    try {
+        if (!replace()) return false;
+    } catch (...) {
+        protectedStackPages.swap(failure);
+        throw;
+    }
+    protectedStackPages.swap(replacement);
+    return true;
 }
+#endif
 
 static void UnregisterStack(PthreadPrivate* self) {
     std::lock_guard lock(stackLock);
diff --git a/core/libs/tests/GuestKernelStackProtect.cpp b/core/libs/tests/GuestKernelStackProtect.cpp
index 8df5bb638..f136070ac 100644
--- a/core/libs/tests/GuestKernelStackProtect.cpp
+++ b/core/libs/tests/GuestKernelStackProtect.cpp
@@ -1,5 +1,6 @@
 #include "SceTypes.hpp"
 #include <atomic>
+#include <cstdio>
 #include <cstdint>
 #include <cstdlib>
 #ifdef _WIN32
@@ -8,15 +9,19 @@
 #endif
 #include <windows.h>
 #else
-#include <cstdio>
 #include <sys/mman.h>
 #endif
 
 extern "C" {
 int APS5_VABI scePthreadCreate(Pthread* thread, const PthreadAttr* attr, PthreadEntry entry, void* arg, const char* name);
 int APS5_VABI scePthreadJoin(Pthread thread, void** retval);
+int APS5_VABI scePthreadKeyCreate(PthreadKey* key, pthread_key_destructor_func_t destructor);
+int APS5_VABI scePthreadKeyDelete(PthreadKey key);
+int APS5_VABI scePthreadSetspecific(PthreadKey key, void* value);
 int APS5_VABI sceKernelIsStack(void* addr, void** start, void** end);
+void* APS5_VABI mmap_nid_postfix(void* address, std::size_t length, int protection, int flags, int descriptor, std::int64_t offset) noexcept;
 int APS5_VABI mprotect_nid_postfix(void* address, std::size_t length, int protection) noexcept;
+int* APS5_VABI __error_nid_postfix();
 }
 
 static constexpr int SCE_OK = 0;
@@ -73,6 +78,134 @@ static void UnmapHostPages(void* pages) {
 #endif
 }
 
+#ifdef _WIN32
+static void CheckFixedStackRemap(std::uintptr_t low, void* start) {
+    constexpr int FixedPrivateAnonymous = 0x1012;
+    auto* const before = reinterpret_cast<volatile unsigned char*>(low);
+    auto* const page = reinterpret_cast<volatile unsigned char*>(low + Page);
+    auto* const after = reinterpret_cast<volatile unsigned char*>(low + 2 * Page);
+    auto* const address = const_cast<unsigned char*>(page);
+    for (std::size_t index = 0; index < Page; ++index) {
+        before[index] = 0x35;
+        page[index] = 0xa7;
+        after[index] = 0x59;
+    }
+    const auto checkBytes = [&](unsigned char expected) {
+        for (std::size_t index = 0; index < Page; ++index) {
+            Require(before[index] == 0x35);
+            Require(page[index] == expected);
+            Require(after[index] == 0x59);
+        }
+    };
+    const auto reject = [&](void* candidate, std::size_t bytes, int flags, int descriptor, std::int64_t offset, int error) {
+        *__error_nid_postfix() = 0;
+        Require(mmap_nid_postfix(candidate, bytes, 0, flags, descriptor, offset) == reinterpret_cast<void*>(static_cast<std::uintptr_t>(-1)));
+        if (error != 0) Require(*__error_nid_postfix() == error);
+        checkBytes(0xa7);
+    };
+    reject(address + 1, Page, FixedPrivateAnonymous, -1, 0, 22);
+    reject(address, Page, FixedPrivateAnonymous, 0, 0, 22);
+    reject(address, Page, FixedPrivateAnonymous, -1, Page, 22);
+    reject(address, Page, FixedPrivateAnonymous | 0x4000, -1, 0, 0);
+    int active = 0;
+    const auto activePage = reinterpret_cast<std::uintptr_t>(&active) & ~(Page - 1);
+    void* activeStart = nullptr;
+    void* activeEnd = nullptr;
+    Require(sceKernelIsStack(&active, &activeStart, &activeEnd) == SCE_OK);
+    Require(activePage >= reinterpret_cast<std::uintptr_t>(activeStart));
+    Require(activePage + Page <= reinterpret_cast<std::uintptr_t>(activeEnd));
+    const auto activeBefore = QueryPage(activePage).raw;
+    const auto activeEndBefore = QueryPage(activePage + Page - 1).raw;
+    reject(reinterpret_cast<void*>(activePage), Page, FixedPrivateAnonymous, -1, 0, 22);
+    Require(QueryPage(activePage).raw == activeBefore);
+    Require(QueryPage(activePage + Page - 1).raw == activeEndBefore);
+    const auto boundary = reinterpret_cast<std::uintptr_t>(start) & ~(Page - 1);
+    Require(boundary >= Page);
+    const auto outside = boundary - Page;
+    const auto outsideBefore = QueryPage(outside).raw;
+    reject(reinterpret_cast<void*>(outside), 2 * Page, FixedPrivateAnonymous, -1, 0, 22);
+    Require(QueryPage(outside).raw == outsideBefore);
+
+    void* const host = MapHostPages();
+    Require(host != nullptr);
+    auto* const hostBytes = static_cast<volatile unsigned char*>(host);
+    hostBytes[0] = 0x63;
+    hostBytes[HostBytes - 1] = 0x94;
+    const auto hostBefore = QueryPage(reinterpret_cast<std::uintptr_t>(host)).raw;
+    reject(host, Page, FixedPrivateAnonymous, -1, 0, 22);
+    Require(QueryPage(reinterpret_cast<std::uintptr_t>(host)).raw == hostBefore);
+    Require(hostBytes[0] == 0x63 && hostBytes[HostBytes - 1] == 0x94);
+    UnmapHostPages(host);
+
+    SYSTEM_INFO system{};
+    GetSystemInfo(&system);
+    const auto hostPage = static_cast<std::size_t>(system.dwPageSize);
+    Require(hostPage != 0 && 2 * hostPage <= Page);
+    auto* const guarded = address + hostPage;
+    DWORD originalProtection = 0;
+    Require(VirtualProtect(guarded, hostPage, PAGE_READWRITE | PAGE_GUARD, &originalProtection) != 0);
+    Require(originalProtection == PAGE_READWRITE);
+    *__error_nid_postfix() = 0;
+    Require(mmap_nid_postfix(address, Page, 0, FixedPrivateAnonymous, -1, 0) == reinterpret_cast<void*>(static_cast<std::uintptr_t>(-1)));
+    Require(*__error_nid_postfix() == 22);
+    MEMORY_BASIC_INFORMATION guardedMemory{};
+    Require(VirtualQuery(guarded, &guardedMemory, sizeof(guardedMemory)) == sizeof(guardedMemory));
+    Require(guardedMemory.State == MEM_COMMIT && guardedMemory.Protect == (PAGE_READWRITE | PAGE_GUARD));
+    DWORD replacedProtection = 0;
+    Require(VirtualProtect(guarded, hostPage, originalProtection, &replacedProtection) != 0);
+    Require(replacedProtection == (PAGE_READWRITE | PAGE_GUARD));
+    checkBytes(0xa7);
+
+    Require(mmap_nid_postfix(address, Page, 0, FixedPrivateAnonymous, -1, 0) == address);
+    MEMORY_BASIC_INFORMATION memory{};
+    Require(VirtualQuery(address, &memory, sizeof(memory)) == sizeof(memory));
+    Require(memory.State == MEM_COMMIT && memory.Protect == PAGE_NOACCESS);
+    Require(reinterpret_cast<std::uintptr_t>(memory.BaseAddress) + memory.RegionSize >= low + 2 * Page);
+    Require(mprotect_nid_postfix(address, Page, 3) == 0);
+    checkBytes(0);
+    for (std::size_t index = 0; index < Page; ++index) page[index] = 0xc8;
+    Require(mmap_nid_postfix(address, Page, 3, FixedPrivateAnonymous, -1, 0) == address);
+    Require(QueryPage(low + Page).writable);
+    checkBytes(0);
+    page[0] = 0x76;
+    page[Page - 1] = 0x82;
+    Require(page[0] == 0x76 && page[Page - 1] == 0x82);
+}
+
+struct PeerStack {
+    std::atomic<int> phase{0};
+    void* start = nullptr;
+    std::uintptr_t low = 0;
+};
+
+static void* APS5_VABI WaitForPeerRemap(void* arg) {
+    auto& context = *static_cast<PeerStack*>(arg);
+    int local = 0;
+    void* end = nullptr;
+    Require(sceKernelIsStack(&local, &context.start, &end) == SCE_OK);
+    context.low = (reinterpret_cast<std::uintptr_t>(context.start) + Page - 1) & ~(Page - 1);
+    Require(context.low + GuardBytes + Page < reinterpret_cast<std::uintptr_t>(&local));
+    Require(mprotect_nid_postfix(reinterpret_cast<void*>(context.low), GuardBytes, 3) == 0);
+    context.phase.store(1);
+    context.phase.notify_one();
+    context.phase.wait(1);
+    Require(context.phase.load() == 2);
+    return nullptr;
+}
+
+static void CheckPeerStackRemap() {
+    PeerStack context;
+    Pthread thread = nullptr;
+    Require(scePthreadCreate(&thread, nullptr, WaitForPeerRemap, &context, nullptr) == SCE_OK);
+    context.phase.wait(0);
+    Require(context.phase.load() == 1);
+    CheckFixedStackRemap(context.low, context.start);
+    context.phase.store(2);
+    context.phase.notify_one();
+    Require(scePthreadJoin(thread, nullptr) == SCE_OK);
+}
+#endif
+
 static void* APS5_VABI Worker(void* arg) {
     auto& passed = *static_cast<std::atomic<bool>*>(arg);
     int local = 0;
@@ -119,6 +252,9 @@ static void* APS5_VABI Worker(void* arg) {
     Require(QueryPage(low).writable);
     static_cast<volatile unsigned char*>(start)[0] = 4;
     Require(mprotect_nid_postfix(start, stackBytes, 3) == 0);
+#ifdef _WIN32
+    CheckFixedStackRemap(low, start);
+#endif
     void* host = MapHostPages();
     Require(host != nullptr);
     Require(mprotect_nid_postfix(host, HostBytes, 1) == -1);
@@ -129,31 +265,68 @@ static void* APS5_VABI Worker(void* arg) {
     return nullptr;
 }
 
-static std::atomic<std::uintptr_t> leftGuard{0};
+enum class GuardMode { Protect, Remap, Split };
 
-static void* APS5_VABI LeaveGuard(void*) {
-    int local = 0;
-    void* start = nullptr;
-    void* end = nullptr;
-    Require(sceKernelIsStack(&local, &start, &end) == SCE_OK);
-    const auto low = (reinterpret_cast<std::uintptr_t>(start) + Page - 1) & ~(Page - 1);
-    Require(mprotect_nid_postfix(reinterpret_cast<void*>(low), GuardBytes, 0) == 0);
-    leftGuard.store(low);
-    return nullptr;
+struct GuardContext {
+    GuardMode mode;
+    PthreadKey key = -1;
+    std::uintptr_t low = 0;
+    std::atomic<unsigned> calls{0};
+};
+
+static void APS5_VABI CheckRestoredGuard(void* arg) {
+    auto& context = *static_cast<GuardContext*>(arg);
+    Require(context.low != 0);
+    auto* const bytes = reinterpret_cast<volatile unsigned char*>(context.low);
+    for (std::uintptr_t offset = 0; offset < GuardBytes; offset += Page) {
+        Require(QueryPage(context.low + offset).writable);
+        bytes[offset] = 4;
+        bytes[offset + Page - 1] = 5;
+        Require(bytes[offset] == 4 && bytes[offset + Page - 1] == 5);
+    }
+    context.calls.fetch_add(1);
 }
 
-static void* APS5_VABI WriteLeftGuard(void*) {
+static void* APS5_VABI LeaveGuard(void* arg) {
+    auto& context = *static_cast<GuardContext*>(arg);
     int local = 0;
     void* start = nullptr;
     void* end = nullptr;
     Require(sceKernelIsStack(&local, &start, &end) == SCE_OK);
-    const auto guard = leftGuard.load();
-    if (guard >= reinterpret_cast<std::uintptr_t>(start) && guard + GuardBytes <= reinterpret_cast<std::uintptr_t>(&local)) {
-        reinterpret_cast<volatile unsigned char*>(guard)[0] = 4;
+    const auto low = (reinterpret_cast<std::uintptr_t>(start) + Page - 1) & ~(Page - 1);
+    Require(low + GuardBytes + Page < reinterpret_cast<std::uintptr_t>(&local));
+    context.low = low;
+    Require(scePthreadSetspecific(context.key, &context) == SCE_OK);
+    const auto mode = context.mode;
+    if (mode == GuardMode::Remap) {
+        Require(mmap_nid_postfix(reinterpret_cast<void*>(low), Page, 0, 0x1012, -1, 0) == reinterpret_cast<void*>(low));
+    } else {
+        Require(mprotect_nid_postfix(reinterpret_cast<void*>(low), GuardBytes, 0) == 0);
+        if (mode == GuardMode::Split) {
+            const auto leftBefore = QueryPage(low).raw;
+            const auto rightBefore = QueryPage(low + 2 * Page).raw;
+            const auto rightEndBefore = QueryPage(low + 3 * Page).raw;
+            auto* const middle = reinterpret_cast<void*>(low + Page);
+            Require(mmap_nid_postfix(middle, Page, 3, 0x1012, -1, 0) == middle);
+            Require(QueryPage(low + Page).writable);
+            Require(QueryPage(low).raw == leftBefore);
+            Require(QueryPage(low + 2 * Page).raw == rightBefore);
+            Require(QueryPage(low + 3 * Page).raw == rightEndBefore);
+        }
     }
     return nullptr;
 }
 
+static void CheckGuardCleanup(GuardMode mode) {
+    GuardContext context{mode};
+    Require(scePthreadKeyCreate(&context.key, reinterpret_cast<pthread_key_destructor_func_t>(CheckRestoredGuard)) == SCE_OK);
+    Pthread thread = nullptr;
+    Require(scePthreadCreate(&thread, nullptr, LeaveGuard, &context, nullptr) == SCE_OK);
+    Require(scePthreadJoin(thread, nullptr) == SCE_OK);
+    Require(context.calls.load() == 1);
+    Require(scePthreadKeyDelete(context.key) == SCE_OK);
+}
+
 #ifdef _WIN32
 static void ProtectWholeStack() {
     int local = 0;
@@ -191,16 +364,10 @@ int main() {
     Require(scePthreadCreate(&thread, nullptr, Worker, &passed, nullptr) == SCE_OK);
     Require(scePthreadJoin(thread, nullptr) == SCE_OK);
     Require(passed.load());
-    Require(scePthreadCreate(&thread, nullptr, LeaveGuard, nullptr, nullptr) == SCE_OK);
-    Require(scePthreadJoin(thread, nullptr) == SCE_OK);
-    for (std::uintptr_t offset = 0; offset < GuardBytes; offset += Page) {
-        const auto left = QueryPage(leftGuard.load() + offset);
-#ifdef __linux__
-        Require(!left.mapped || left.writable || left.raw == 1);
-#else
-        Require(!left.mapped || left.writable);
+    CheckGuardCleanup(GuardMode::Protect);
+#ifdef _WIN32
+    CheckPeerStackRemap();
+    CheckGuardCleanup(GuardMode::Remap);
+    CheckGuardCleanup(GuardMode::Split);
 #endif
-    }
-    Require(scePthreadCreate(&thread, nullptr, WriteLeftGuard, nullptr, nullptr) == SCE_OK);
-    Require(scePthreadJoin(thread, nullptr) == SCE_OK);
 }
diff --git a/docs/dev/debt/libkernel.md b/docs/dev/debt/libkernel.md
index 924649e64..82801640f 100644
--- a/docs/dev/debt/libkernel.md
+++ b/docs/dev/debt/libkernel.md
@@ -60,7 +60,7 @@
 - [sceKernelMapFlexibleMemory / sceKernelMapNamedFlexibleMemoryInternal](../../../core/libs/prx/libkernel/DirectMemory/Export.cpp) (libkernel) - flag 0x8000 is accepted and ignored; its meaning is not established. KytyPS5 (`GUEST_MAP_UNKNOWN_8000`) and sharpemu accept it without reading it. Every known caller is an SDK 9 `libc.prx` heap, which passes it with read/write protection and uses the mapping as an older `libc.prx` uses `sceKernelMapNamedFlexibleMemory` with flags 0: through the Internal variant in Astro Bot PPSA21564, through `sceKernelMapFlexibleMemory` in Animal Well PPSA22520 01.000.011
 - [sceKernelMtypeprotect](../../../core/libs/prx/libkernel/DirectMemory/DirectMemory.cpp) (libkernel) - sets the memory type of the direct mappings in the page-rounded range and of the direct memory behind them, as shadPS4 and prosper do (KytyPS5 ignores the type); the type is not validated, flexible pages in the range only get the protection, and a later map of the retyped direct memory reports the type it was allocated with. `sceKernelBatchMap2` operation 4 takes the same path with the entry type
 - [Map hints inside a reservation](../../../core/libs/prx/libkernel/DirectMemory/DirectMemory.cpp) (libkernel) - `sceKernelMapDirectMemory` and the flexible and anonymous maps place a mapping without `MAP_FIXED` at its hint address when the whole range lies in a range still reserved by `sceKernelReserveVirtualRange`, as if `MAP_FIXED` were set. Taken from PPSA01474, which reserves 64 GiB and then maps its heaps inside it with hints and no `MAP_FIXED`, and uses the hint addresses afterwards; not measured on the console. Hints outside a reservation, or over a range that is partly mapped, stay hints
-- [Fixed anonymous mmap](../../../core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp) (libkernel) - `MAP_PRIVATE | MAP_ANON | MAP_FIXED` replaces the requested range through the existing guest mapping machinery and zeroes the new memory. `MAP_EXCL` is checked under the allocation-registry mutation lock, including virtual reservations, and returns `ENOMEM` without changing an occupied range; without `MAP_FIXED` it is `EINVAL`. Lengths are rounded to 16 KiB, while fixed addresses must be aligned and the complete rounded range must lie inside the guest arena. This follows FreeBSD 11.4 [argument validation and rounding](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/vm/vm_mmap.c#L226-L299), [fixed replacement](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/vm/vm_map.c#L1455-L1464), and [collision errno](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/vm/vm_mmap.c#L1604-L1613). The console behaviour has not been measured. AnyPS5 starts the guest arena at 0x200000000, so the fixed path rejects the low 0x400000 address requested by PICO-8 PPSA99808. The console address-space layout is unmeasured; executable placement outside the host arena is tracked in #5217. Static catalog disassembly finds `0x1012` in PPSA98001/PPSA99808 and `0x5012` in PPSA99808; these titles were not run.
+- [Fixed anonymous mmap](../../../core/libs/prx/libkernel/DirectMemory/PosixMemory.cpp) (libkernel) - `MAP_PRIVATE | MAP_ANON | MAP_FIXED` replaces the requested range through the existing guest mapping machinery and zeroes the new memory. `MAP_EXCL` is checked under the allocation-registry mutation lock, including virtual reservations, and returns `ENOMEM` without changing an occupied range; without `MAP_FIXED` it is `EINVAL`. Lengths are rounded to 16 KiB and fixed addresses must be aligned. The complete rounded range must lie inside the guest arena or, for POSIX fixed anonymous `mmap` on Windows, one current or registered native thread stack; other mapping APIs do not opt into stack replacement. Native stack replacement keeps the reservation, decommits and recommits fresh pages, and rejects foreign allocations, ranges spanning reservations and host `PAGE_GUARD` pages before mutation. Current-stack ranges must end at least 64 KiB below the active host frame; callers replacing another thread's stack must coordinate with that thread. If recommitting fails, the call returns `ENOMEM` with the range still reserved; thread cleanup attempts to recommit it before restoring write access. This follows FreeBSD 11.4 [argument validation and rounding](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/vm/vm_mmap.c#L226-L299), [fixed replacement](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/vm/vm_map.c#L1455-L1464), and [collision errno](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/vm/vm_mmap.c#L1604-L1613). The console behaviour has not been measured. AnyPS5 starts the guest arena at 0x200000000, so the fixed path rejects the low 0x400000 address requested by PICO-8 PPSA99808. The console address-space layout is unmeasured; executable placement outside the host arena is tracked in #5217. Static catalog disassembly finds `0x1012` in PPSA98001/PPSA99808 and `0x5012` in PPSA99808; these titles were not run.
 - [Mapping hints with no room above them](../../../core/libs/prx/libkernel/DirectMemory/DirectMemory.cpp) (libkernel) - a mapping without `MAP_FIXED` whose hint lies above the guest address space, or has no free range at or above it, is placed from the bottom of the guest arena, as FreeBSD 11.4's [`vm_mmap_object`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/vm/vm_mmap.c#L1569) retries through [`vm_map_find_min`](https://github.com/freebsd/freebsd-src/blob/8c639945a44588f6cfdddfb057bb5aad88735da0/sys/vm/vm_map.c#L1555-L1584) from its minimum address. FreeBSD's minimum is the end of the largest possible data segment rather than the start of the arena, and the console's address-space layout is not measured. PS5CEMU-HAR (PPSA99360) gives `sceKernelReserveVirtualRange` a hint near its own code, which is a host address here because the relinked executable lies outside the guest arena, so its JIT gets memory more than 2 GiB from its code and does not start
 - [_sceKernelRtldThreadAtexitIncrement, _sceKernelRtldThreadAtexitDecrement](../../../core/libs/prx/libkernel/System/src/Rtld.cpp) (libkernel) - the argument is the `dso` address a title's `libc.prx` passes to `__cxa_thread_atexit` (PPSA14632), which fails when Increment returns nonzero. As in the [fpPS4](https://github.com/red-prig/fpPS4) reimplementation, Increment takes a reference on the loaded image containing the address and Decrement releases one; the variants without the leading underscore are assumed to match
 - [tcgetattr, tcsetattr](../../../core/libs/prx/libkernel/File/src/Terminal.cpp) (libkernel) - every file and pipe descriptor is taken as not a terminal (`ENOTTY`), and a socket gets `EADDRNOTAVAIL` for `AF_INET` and `EOPNOTSUPP` otherwise, as FreeBSD's `in_control` and `pru_control_notsupp` answer a termios ioctl; what descriptors 0 to 2 of a title are on the console, and whether any of them is a terminal there, is unknown

@MarcZX10

Copy link
Copy Markdown
Contributor

For coordination: I opened #6893 with the guard-size attribute-query fix. It shares Pthread/src/Thread.cpp and libkernel/CMakeLists.txt with this PR, but changes different behavior: #5918 adjusts reported stack bounds for adopted Windows threads; #6893 stores and reports the creation guard-size attribute. I tested the combined trees on current main 66e290f1c; both changes apply cleanly, with no merge conflict or dependency between the PRs. Let me know if you see a runtime interaction that needs a specific order.

@adriadam10 adriadam10 self-assigned this Oct 16, 2026
@adriadam10

Copy link
Copy Markdown
Collaborator

Checked the diff, the shared committed-pages walk with StartNativeThread, and the debt entry (still true now that #5703 is on main). guest_thread_self and the libkernel/libs ctest scope pass on Linux with this merged onto current main (the new code is _WIN32 only); the Windows behaviour rests on your run and @EagleBearerYS's Village boot, and the full Windows build is dispatched before merging. Merging.

@adriadam10

Copy link
Copy Markdown
Collaborator

Checked the diff, the shared committed-pages walk with StartNativeThread, and the debt entry. guest_thread_self and the libkernel/libs ctest scope pass on Linux with this merged onto current main (the new code is _WIN32 only); the Windows behaviour rests on your run and @EagleBearerYS's Village boot, and the full Windows build is dispatched before merging. Merging.

@adriadam10

Copy link
Copy Markdown
Collaborator

Re-checked against current main: same diff, no conflicts, guest_thread_self and the libkernel/libs ctest scope pass on Linux (the new code is _WIN32 only). The Windows behaviour rests on your run and @EagleBearerYS's Village boot; the full Windows build is dispatched before merging. Merging.

@karimibrahim07

Copy link
Copy Markdown
Contributor Author

@MarcZX100 I checked the interaction on Windows and found nothing that needs an order. I merged this PR and #6893 (ece11f830) together onto main 16c607a93, along with my other open PRs. Windows 11 Pro (26300), WinLibs GCC 15.2.0 r7, Release.

  • guest_thread_self, guest_pthread_guard_size and guest_kernel_stack_protect pass, --repeat until-fail:2.
  • Java 25 JVM Startup (PPSA99865), which creates JVM threads with guard pages and runs on the adopted main thread: all four rows pass (JNI_CreateJavaVM returned 0, Hello25 returned normally, DestroyJavaVM complete). There is no failure to protect guard pages. The three Attempt to deallocate stack guard pages failed warnings at teardown are the ones main prints too, and fix(libkernel): replace anonymous Windows stack mappings #6534 addresses them.

The two changes touch different state: #6893 stores and reports the attribute's guard size, and this PR moves the reported bottom of an adopted host stack. Neither reads what the other writes.

… Windows

An adopted thread (the main thread, or a host thread that calls
scePthreadSelf) reported its whole Windows stack reservation, and so
did sceKernelIsStack on a host thread before adoption. A relinked
executable reserves 0x210000 bytes for its main thread and commits the
top 0x200000, so the lowest 64 KiB of the reported stack hold only
reserved pages and Windows' guard pages. mprotect fails there, so
HotSpot in Java 25 LAN / DNS (PPSA99869) could not create the main
thread's stack guard pages ("Attempt to allocate stack guard pages
failed").

When everything above the bottom allocation granule is committed
read-write, the host stack is now reported from there, as created
guest threads report only their committed stack. Other host stacks
keep the whole range. The committed-pages walk is shared with
StartNativeThread and throws when VirtualQuery fails.
@adriadam10
adriadam10 force-pushed the fix/libkernel-adopted-stack branch from afa4740 to 267cb56 Compare October 18, 2026 14:35
@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 18, 2026
@adriadam10
adriadam10 merged commit 2da44a1 into boykopovar:main Oct 18, 2026
9 checks passed
adriadam10 pushed a commit that referenced this pull request Oct 19, 2026
…protect entries

The entry now sits with the other sceKernelMprotect entries for
DirectMemory.cpp, before the Windows guest module image protection one,
instead of after the setcontext entry, where #5918 adds its own.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants