Skip to content

Vector registers in thread contexts; stop JIT pool code-buffer leaks (Persona 5 Royal reaches gameplay) - #556

Merged
125hz merged 1 commit into
willfaust:mainfrom
220111:p5r-thread-context-and-pool
Nov 5, 2026
Merged

125hz merged 1 commit into
willfaust:mainfrom
220111:p5r-thread-context-and-pool

Conversation

@220111

@220111 220111 commented Nov 3, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes a Persona 5 Royal (Steam 1687950) crash about 33 seconds in, and two JIT pool leaks that kept it very slow once it ran.

Crash: zeroed vector registers

The game's protection code suspends another thread, reads its floating-point context into a CONTEXT template inside the image, and jumps to the XMM13 slot. The server's Mach capture read only ARM_THREAD_STATE64, so every cross-thread GetThreadContext returned zeroed vector registers and the game executed zeros.

ios_fill_thread_context() now also reads ARM_NEON_STATE64. Under ARM64EC, FEX keeps guest XMM0-15 in V0-V15, so these are the guest's XMM registers. A thread blocked in a syscall reports the vector registers saved in its syscall frame instead. MADEIRA_CTX_FP=0 restores the old behaviour.

Pool: two code-buffer leaks

These held 256 MB of the 304 MB of live code buffers. Game threads were capped at 16 MB buffers and recompiled about 70% of the code they ran.

  • Refused frees: a buffer FEX released while a thread was still running in it was refused and stayed live for the session, because FEX never releases a buffer twice. It is now freed as retired, so reuse waits for the grace period and the strict thread scan, as for a dead process's buffer. MADEIRA_POOL_CODE_DEFER_FREE=0 restores the refusal.
  • Oversized reuse: crashpad_handler.exe took a free 16 MB buffer for a 16 KB request and a 128 MB buffer for a 16 MB request, and kept them for the session. A free buffer at least 4x the request is now kept back while the budget affords a fresh one, with a fallback to the kept buffer if the fresh one can't be made. MADEIRA_POOL_CODE_KEEP_BIG=0 restores the old policy.

Testing

On an iPhone 17 Pro Max (iOS 27.0.1), with all switches at their defaults:

  • P5R booted, loaded saves, and played at a steady 33.3 ms per frame with a 62% JIT cache hit rate (27-37% before the pool fixes).
  • Is This Seat Taken ran at 60 fps and exited cleanly.

The switches set to 0 were covered only by host tests, not on a device.

Added check-ctx-fp.py, which checks the frame offsets against struct syscall_frame, the dispatcher's register saves, the capture's gating and ordering, and the switch. Updated check-pool-code-exit.py and check-pool-startup-budget.py, which had asserted the old behaviour. 69 of 79 related host tests pass on macOS; the other 10 also fail without this change.

Review notes

  • Deferred free: the thread scan checks current PCs, not return addresses into the old buffer on a thread's stack or FEX's call-return stack. It relies on FEX having released the buffer. ml557 was added after a crash from recycled code, so this deserves a careful look.
  • Syscall frame: the FP override is on by default, while the neighbouring ml716 substitution is opt-in. It replaces only FP state and uses the same validation.
  • Capture limits: inside a Unix call only q8-q15 are saved, so q0-q7 come from the last syscall. A thread stopped in FEX's own code reports host values. YMM upper halves are not reported.
  • Threshold: the 4x rule is a judgement call, not a measured value.
  • Catalog: ConfigCatalog.generated.swift is a full regeneration, so it also adds six entries main was missing and moves four.

🤖 Generated with Claude Code

…(Persona 5 Royal reaches gameplay)

Persona 5 Royal (Steam 1687950) died about 33 seconds in, at the same
address every run: a jump to 0x1489C3B50, an all-zero page, then an
access violation reading address 1. The game's protection code creates a
thread that loads code into XMM13, suspends it, reads its floating-point
context into a CONTEXT template inside the image (rva 0x89C38E0) and jumps
to the XMM13 slot, 0x270 bytes in. The server's Mach capture read only
ARM_THREAD_STATE64, so every cross-thread GetThreadContext returned zeroed
vector registers and the game executed zeros.

ios_fill_thread_context() now also reads ARM_NEON_STATE64. Under ARM64EC,
FEX keeps guest XMM0-15 in V0-V15, the mapping the ARM64EC ABI uses, so
these are the guest's XMM registers. A thread blocked in a syscall reports
the vector registers the dispatcher saved in its syscall frame instead,
with the same validation as the ml716 frame substitution. Known limits:
inside a Unix call only q8-q15 are saved, so q0-q7 come from the last
syscall; a thread stopped in FEX's own code reports host values; YMM upper
halves are not reported. Each is still an improvement on zeros.
MADEIRA_CTX_FP=0 restores the zeroed registers.

Once it ran, the game's threads were held to 16 MB code buffers and
recompiled about 70% of the code they ran (hit rates 27-37%). Two leaks
held 256 MB of the 304 MB of live code buffers:

- A buffer FEX released while a thread was still running in it was
  refused (ml557) and stayed live for the rest of the session, because
  FEX never releases a buffer twice. It is now freed as retired, so reuse
  waits out the grace and the strict thread scan, as for a dead process's
  buffer. MADEIRA_POOL_CODE_DEFER_FREE=0 refuses it as before.
- crashpad_handler.exe, alive for the whole session, took a free 16 MB
  buffer for a 16 KB request and a 128 MB one for a 16 MB request. A free
  buffer four or more times the request is now kept back while the pool's
  budget still affords a fresh one of the size asked; if that fresh one
  cannot be made, the request falls back to the kept buffer.
  MADEIRA_POOL_CODE_KEEP_BIG=0 restores the old policy.

On an iPhone 17 Pro Max (iOS 27.0.1) P5R now reaches gameplay, loads its
intro in about 10 s and saves in about 13 s (previously very slow), and
holds 30 fps with a frame cap; hit rates rose to 46-68%. Is This Seat
Taken still runs at 60 fps and exits cleanly with this build.

tests/host/check-ctx-fp.py checks the frame offsets against struct
syscall_frame, that the syscall dispatcher saves q0-q31, fpcr and fpsr
there, the gating and ordering of the capture, and the switch.
check-pool-code-exit.py and check-pool-startup-budget.py cover the new
pool behaviour and both switches. ConfigCatalog.generated.swift is
regenerated for the three switches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Henry Morin <henry@hmorin.com>
@125hz
125hz merged commit f2fbc1a into willfaust:main Nov 5, 2026
125hz pushed a commit that referenced this pull request Nov 5, 2026
…(Persona 5 Royal reaches gameplay) (#556)

## Summary
125hz added a commit that referenced this pull request Nov 5, 2026
…follow-up)

ios_ctx_fp_enabled() read madeira.cfg and the game's config on every cross-thread context capture, inside the single-threaded server; a program that samples its threads' contexts often put that file I/O on each call. The answer is cached against the server's monotonic clock and re-read after a second, so a changed setting still takes effect without a restart. The comments name the behaviour, not a game.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
125hz added a commit that referenced this pull request Nov 5, 2026
…556 follow-up)

The host check compiles ios_ctx_fp_enabled() on its own; with the answer kept for a second it needs timeout_t, monotonic_time and TICKS_PER_SEC, and each check advances the clock. One check reads twice within the second and expects the kept answer.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants