Skip to content

feat: find a lifted program's guest images, load its modules and switch its fibers - #14

Open
oberonlift wants to merge 6 commits into
lifted-libkernelfrom
lifted-modules-fibers
Open

oberonlift wants to merge 6 commits into
lifted-libkernelfrom
lifted-modules-fibers

Conversation

@oberonlift

@oberonlift oberonlift commented Nov 1, 2026 •

Copy link
Copy Markdown
Owner

Open on AnyPS5 as boykopovar#15804; this copy follows it.

ps5lift is a static recompiler for PS5 executables: it lifts a game's x86-64 code to LLVM IR and compiles it ahead of time to aarch64, which runs on Linux against AnyPS5's own libraries with no interpreter or JIT. It runs AnyPS5's PS5-style test programs and a homebrew media player to its home screen; commercial games are not playable yet. Branch ps5lift-preview merges all of its PRs (how to try it); native Apple Silicon support is coming along fast. It is a separate approach from the ARM64 compiler in boykopovar#5021; boykopovar#1835 has an introduction.

This is 8 of 8 PRs that let AnyPS5's libraries run a lifted program; the lifter itself follows them.

x86-64: behaviour unchanged. The native module lifecycle runs through the state machine it now shares with lifted modules.

What

ps5lift's runtime maps a lifted program's executable and modules; the host's dynamic loader never sees them.

  • dlopen resolves the path as the native loader would and takes the lifted image whose path ends with it, or with it and .guest.prx appended, so a system library path still reaches AnyPS5's library when the title ships a module of the same name. It runs the module's lifecycle table through guest calls. Symbols come from that image's export table, and dlopen(NULL), whose handle is the executable's module ID, and the default scope search every image. A name that is not lifted still goes to the native loader; the lifecycle state machine is shared between the two paths.
  • Module queries (sceKernelGetModuleInfoFromAddr, sceKernelGetModuleInfoForUnwind, sceKernelGetModuleList, module info by handle, thread_atexit) find an image through dl_iterate_phdr and identify it by the address of its first loadable segment, so a module's ID matches its load handle. Lifted images are never system images and are never unloaded.
  • libSceFiber gives every fiber its own host stack, switches host stacks on aarch64, and saves and restores the guest registers around each switch through GuestCpu. Resuming a suspended fiber on a thread other than the one that started it throws, until each fiber has a guest state of its own.
  • The libkernel and libSceFiber debt entries record what still differs.
  • CI: the workflow builds every library, and ps5lift/tests/lifted_tests.txt names the 2,288 tests that pass with this PR, up from 101.

boykopovar#5021 also changes some of the same files: DynamicLoader.cpp, ModuleInfo.cpp, Rtld.cpp.

Tested

Compiled for x86-64 Linux before and after this PR (GCC 13, same flags): 147 of the 148 objects it reaches are byte-identical; DynamicLoader.cpp differs by the lifecycle it now shares with lifted modules.

On a DGX Spark (aarch64 Linux, NVIDIA GB10, LLVM 18), with all eight PRs and ps5lift on top: every library builds for a lifted program, 2,288 of AnyPS5's own tests pass in that build on lavapipe, and ps5lift's 69 tests pass, among them its libc, exceptions, kernel, fiber and module-loading fixtures and upstream's libc test programs.

The conventions check passes on this branch.

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: feat(libkernel): run threads for a lifted guest #13
  • Fixes: none
  • Unblocks: none
  • GPUs tested: NVIDIA GB10, driver 580.178.04, Ubuntu 24.04.5 (DGX OS)
  • AI-assisted: yes

🤖 Generated with Claude Code

@oberonlift
oberonlift force-pushed the lifted-modules-fibers branch from 3a49e0b to 466231b Compare November 1, 2026 19:35
@oberonlift
oberonlift force-pushed the lifted-modules-fibers branch from 466231b to 90d3f01 Compare November 1, 2026 22:07
@oberonlift
oberonlift force-pushed the lifted-libkernel branch 2 times, most recently from 61186a9 to 319ad33 Compare November 2, 2026 17:02
@oberonlift
oberonlift force-pushed the lifted-modules-fibers branch from 90d3f01 to 006f153 Compare November 2, 2026 17:02
@oberonlift
oberonlift force-pushed the lifted-modules-fibers branch 2 times, most recently from 3e0a605 to 9d12953 Compare November 2, 2026 22:07
@oberonlift
oberonlift force-pushed the lifted-modules-fibers branch from 9d12953 to 99d78d6 Compare November 3, 2026 16:34
@oberonlift
oberonlift force-pushed the lifted-modules-fibers branch from 99d78d6 to 299adba Compare November 3, 2026 17:20
@oberonlift

Copy link
Copy Markdown
Owner Author

Review findings (unverified)

Code review of 99d78d656f. Since then only the test list in ps5lift/tests/lifted_tests.txt has changed, so the code and line numbers still hold at 299adbabd7. None of these have been built, run or checked by a second reviewer yet. Most severe first.

  1. core/libs/prx/libSceAgcDriver/Execution/src/GuestMemory.cpp:60: walking the images from a host thread creates a guest thread. Every dl_iterate_phdr now goes through GuestCpuIterateImages, which always calls ps5lift_current_state(). On an AGC driver or Vulkan worker thread that has no guest state, for example in PageStates::initialize or a DirectMemory/GuestAllocations image walk, that maps a guest stack and TLS and registers a foreign thread that is never released. If the main image has TLS, the result also reads that thread's fresh FsBase, not a guest thread's.
  2. core/libs/prx/libkernel/Module/src/DynamicLoader.cpp:113: a lifted macOS build does not compile. With ANYPS5_GUEST_LIFTED on Apple, Module declares image twice: the lifted std::optional<uint64_t> and Apple's const void*. dlopen also calls InitializeLiftedGuestModule, which is defined only under __linux__. CMake allows the switch on any platform.
  3. core/libs/prx/libkernel/Module/src/DynamicLoader.cpp:409: dlopen(NULL) doesn't match the executable's module ID. In a lifted build module->native stays nullptr, so ModuleIdForImage never matches it to the main image, and the executable gets a fresh ID. sceKernelGetExecutableModuleHandle or sceKernelGetModuleInfoFromAddr on the title's own code no longer equals the dlopen(NULL) handle, as it does in native builds. native should be LiftedImageAddress(1).
  4. core/libs/prx/libkernel/Module/src/DynamicLoader.cpp:73: LiftedImage matches by file name only. sceKernelLoadStartModule("/system/common/lib/libSceFoo.sprx") becomes dlopen("libs/libSceFoo.prx"), and GuestCpuFindImage("libSceFoo.prx.guest.prx") matches a title's sce_module/libSceFoo.prx.guest.prx. The title's lifted copy runs instead of the HLE library. Two lifted modules with the same base name in different directories collide the same way.
  5. core/libs/prx/libSceFiber/Export.cpp:494: a lifted fiber takes its FP state at run time. Without FIBER_FLAG_SET_FPU_REGISTERS, a lifted fiber with a context starts with the MXCSR and x87 control word its resumer has when it runs. x86 captures them in PrepareInitialStack at initialize/attach time, so a thread that changes rounding or FTZ between sceFiberInitialize and sceFiberRun gets different results.
  6. docs/dev/debt/libkernel.md:159: the new debt entry is out of date. It was written in 9f1429876b, before f582b96d65. Lifted module IDs now match their load handles (dlopen sets module->native to the first PT_LOAD address, and ModuleInfo keys IDs by it), and sceKernelGetModuleList/sceKernelGetModuleInfo no longer throw for lifted images. The entry says the opposite on both counts.
  7. core/libs/prx/libkernel/System/src/Rtld.cpp:44: findGuestImage repeats the "image containing an address, keyed by its first PT_LOAD" walk. The same walk is in ModuleInfo's Contains/FindImage, DynamicLoader's FindLiftedImageAddress and Module.cpp's FindRelinkedImage. The copies already disagree on the bound (Rtld uses < p_memsz, ModuleInfo <= p_memsz). If they drift further, thread_atexit, module IDs and dlopen handles stop agreeing.
  8. core/libs/prx/libSceFiber/Export.cpp:559: lifted builds allocate a host stack of contextSize + 16 MiB. The guest stack is the separate context buffer, so the contextSize part is never used. 256 fibers with 1 MiB contexts reserve 256 MiB more address space than needed. FIBER_HOST_STACK_MARGIN plus the guard page is enough.
  9. core/libs/prx/libSceFiber/Export.cpp:440: GuestCpuSaveFiber, GuestCpuRestoreFiber and GuestCpuStartFiber are declared locally. The PR adds its other GuestCpu entry points to GuestCpu.hpp, which this file includes. Keep the runtime ABI in one header.
  10. core/libs/prx/libkernel/Module/src/DynamicLoader.cpp:416: dlopen_nid_postfix opens } else { in one #ifdef ANYPS5_GUEST_LIFTED and closes it in a later one. Its braces are spread over five preprocessor blocks. In the default scope, dlsym then repeats the four all-image lookups through DefaultScopeSymbol after the dlopen(NULL) module has already done them, so sceKernelDlsym(0, missing) searches every image eight times instead of four. An OpenLiftedModule helper that returns early would remove both problems.

@oberonlift
oberonlift force-pushed the lifted-modules-fibers branch from 5f9283d to be43071 Compare November 5, 2026 19:28
@oberonlift
oberonlift force-pushed the lifted-libkernel branch 2 times, most recently from 44fc211 to bafaa1c Compare November 6, 2026 00:18
@oberonlift
oberonlift force-pushed the lifted-modules-fibers branch 2 times, most recently from 45266c2 to cf0715b Compare November 6, 2026 15:35
oberonlift and others added 6 commits November 6, 2026 15:54
A lifted program carries every guest module it was built with, so dlopen
resolves the path as the native loader would and takes the lifted image
whose path ends with it, or with it and .guest.prx appended, and runs
the module's lifecycle table through guest calls. Symbols come from that
image's export table; dlopen(NULL) and the default scope search every
image. A name that is not lifted still goes to the native loader.

The lifecycle state machine is shared between the native and lifted
paths.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mAddr

A lifted build's guest images are not host shared objects, so the
dladdr1 that sceKernelGetModuleInfoFromAddr asked first never found
them, and sceKernelGetModuleInfoForUnwind, which asks it for a guest
image's unwind tables, threw when a title's unwinder looked up its own
code. Lifted builds now search the lifted program's images directly,
as their dl_iterate_phdr already does, key the module id by the
image's base and treat no lifted image as a system one. The debt notes
record the module calls that still reach images through host link maps.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lifted guest's executable and modules are part of the lifted program:
ps5lift's runtime maps them, and the host's dynamic loader never sees
them. Module queries still asked dladdr1 and link_map about guest
addresses, which cannot succeed, so sceKernelGetModuleList and module info
by handle aborted, and thread_atexit could not reference an image.

In lifted builds an image is found through dl_iterate_phdr, which walks
the runtime's images, and identified by the address of its first loadable
segment, the key sceKernelGetModuleInfoFromAddr already used. A module
dlopen loads from the lifted program records that identity, so its module
ID matches its load handle. Lifted images are never system images and are
never unloaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A lifted build gives every fiber its own host stack, switches host
stacks with an aarch64 version of the switch, and saves and restores
the guest registers around each switch through GuestCpu.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
In a lifted build the frames on a fiber's host stack keep the guest
state of the thread that started the fiber. Resumed on another thread,
the fiber ran on the first thread's state while that thread ran on it
too: a fiber that the second thread handed 5 read 7, the value it was
last given on the first thread, and the program went on.

A fiber now records the thread that starts it, and resuming a
suspended fiber on any other thread throws until each fiber has a
guest state of its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With fibers switched for a lifted guest every library builds on
aarch64, so the workflow builds the libs target, and the 2,187 tests
whose programs link AnyPS5's libraries and pass on lavapipe in well
under the 30 s limit on the runner join lifted_tests.txt, which now
names 2,288.

Co-Authored-By: Claude Opus 5.5 <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.

1 participant