Skip to content

fix(relinker): run a guest DT_INIT of 0 as the module initializer - #6684

Merged
oneandonlydean merged 4 commits into
boykopovar:mainfrom
SandstromPL:fix/relinker-guest-zero-init
Oct 20, 2026
Merged

oneandonlydean merged 4 commits into
boykopovar:mainfrom
SandstromPL:fix/relinker-guest-zero-init

Conversation

@SandstromPL

@SandstromPL SandstromPL commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

What

Until Dawn (CUSA02636) bundles libc.prx and libSceFios2.prx. SuperCatElvis reported stop 7 of the title's conversion on #3093, in libc.prx:

Guest PREINIT_ARRAY has no module initializer

They then confirmed that both modules have DT_INIT (tag 12) present with value 0. Only libc.prx has a non-empty DT_PREINIT_ARRAY (0xc2040, one entry).

The guest reader stores DT_INIT as a plain address, and every user of it reads 0 as "no initializer" (GuestImageReader.cpp L263-L265 and L299, and since #6106 GuestModuleBuilder.cpp L313). In these modules 0 is the start of the text segment, ahead of the PLT at 0xc8, and no unwind function starts before the PLT. The loaders below call base + DT_INIT there. On main, past the earlier stops:

  • a module shaped like libc.prx fails with the message above, because the reader rejects a preinit array without an initializer;
  • one shaped like libSceFios2.prx converts, but its initializer never runs. The Linux output gets no DT_INIT stub, and the Windows and macOS startups skip it. Nothing reports this.

What loaders do with a DT_INIT of 0:

GuestImage now has HasInit, set when tag 12 is present. The reader, the writers and the module builder test it instead of Init != 0:

  • The reader checks that the initializer is in an executable segment, and counts it as the module initializer for the preinit check.
  • The Linux output emits its DT_INIT stub, which passes the module arguments, for an initializer at 0.
  • The Linux module builder gives such a module the libkernel.prx dependency that fix(relinker): scope host dependencies to required guest modules #6106 adds for a module with an initializer, since the stub imports the module-argument getter and setter from it.
  • The macOS output, the Windows startup and the deferred Windows .ginit entry record it. Its RVA is 0x10000, so their own "RVA 0 means none" stays valid.
  • The code analysis of the Windows and macOS outputs takes a DT_INIT of 0 as an entry point when address 0 is code, so a TLS access in the initializer is rewritten. The same analysis scans the executable, so an executable's DT_INIT of 0 is now an entry point there too.

A module whose DT_INIT is 0 while address 0 is not in an executable segment now fails with Unmapped or inaccessible guest address 0. main ignored that DT_INIT.

Not changed:

Tested

Ubuntu 24.04, GCC 13.3, CMake 3.28, Ninja, Release relinker-only build (-DANYPS5_RELINKER_ONLY=ON -DBUILD_TESTING=ON), on main 324e0e4, which this branch merges.

The new guest_zero_initializer test (relinker/tests/test_guest_zero_initializer.py) converts a synthetic module whose text segment maps file offset 0x1000 at address 0, with DT_INIT 0. The initializer at 0 reads fs:[0], calls the host's RecordArgs and returns a value. There are two shapes, one with a one-entry preinit array (like libc.prx) and one without (like libSceFios2.prx). For each:

  • Linux: the output imports the module-argument getter and setter, lists libkernel.prx and has a DT_INIT stub. On this x86-64 host the converted module loads, records the pending arguments, then records the initializer's return value.
  • macOS: the output binds the getter and setter, and no fs: load is left.
  • Windows: the startup calls the module at RVA 0x10000, and no fs: load is left. A --module-dir module's .ginit entry is nonzero.

A third shape puts the text segment at 0x1000, so 0 is unmapped. It fails with Unmapped or inaccessible guest address 0 for all three targets.

On main the test fails with Guest PREINIT_ARRAY has no module initializer. Reverting one part at a time also fails it:

  • the reader: the stop-7 message;
  • the Linux writer: no getter import;
  • the module builder: the Linux module lists only libc.prx;
  • the macOS writer: no getter bind;
  • the Windows startup line: no call at RVA 0x10000; the .ginit line: entry 0;
  • the code analysis entry point: the fs: load stays in the macOS and Windows modules.

For the builder line I also ran a module with no dependencies and DT_INIT 0 under an executable that imports nothing from libkernel.prx, with the host library as libs/libkernel.prx. Without the line, the executable stops with a symbol lookup error, and the same module from --module-dir fails to load with undefined symbol: __aps5_get_pending_module_args_nid_no_patch. With it, both load and the initializer's result is recorded.

Full relinker-only ctest: 209/209 (208 on main, plus this test). The new test passed 5 runs out of 5.

Release ASan build (-DANYPS5_ENABLE_ASAN=ON, ASAN_OPTIONS=detect_leaks=0, as in #7147): 209/209, and the new test passed 5 runs out of 5. Its Linux run loads the host library into Python with ctypes, so it now has add_asan_test_options(guest_zero_initializer verify_asan_link_order=0) like the other tests that do. Without that line it fails with ASan runtime does not come first in initial library list.

On a title: savvaniss ran a leave-one-out on Hasbro Family Fun Pack (CUSA03312) (comment, full table on #6977). On main f305942 (before the revert) with #7660, #6619, #5138, #6381, 3b158c1 from #6977, #6354, #6357, this PR and #6618 merged, the Linux and --windows conversions of the eboot and its 9 bundled modules complete. Without this PR, both stop in libc.prx with Guest PREINIT_ARRAY has no module initializer.

On Until Dawn (CUSA02636): SuperCatElvis ran this PR on Windows 11 (RTX 3090 Ti, WinLibs GCC 15.2.0), on main of 2026-10-19 with #6595, #6618, #6977 and their own local fixes merged:

  • libc.prx no longer stops with Guest PREINIT_ARRAY has no module initializer.
  • libSceFios2.prx's initializer runs: its SceFiosIO and SceFiosCallback threads come up, and file I/O works.

With their other local fixes the title boots, creates and reads its save, and reaches its own loading screen. It then stops at a GCN v_cmpx that the recompiler doesn't implement yet.

Merges:

Why the preinit array is left to the initializer. SuperCatElvis disassembled Until Dawn's libc.prx from 0 up to the PLT at 0xc8 (same comment). It is a plain module initializer taking (rdi, rsi, rdx):

  1. It walks the preinit array (0xc2040 to 0xc2048, one slot) and calls each non-null entry.
  2. It walks the init array backwards from 0xc2020, skipping nulls and stopping at the -1 sentinel, and calls each entry.
  3. If rdx is non-null, it tail-calls it with (rdi, rsi). Otherwise, if the qword at 0xbc890 is nonzero, it tail-jumps to 0x600; else it returns 0.

So the module runs its own preinit entry, like the Wind Waker Recompiled (PPSA99309) libc.prx that milescoviello traced on #7636, where #6112 ran that entry a second time. That is why this PR only makes a DT_INIT of 0 count as the initializer and runs nothing else.

The code from 0 to 0xc8 has no fs: access. So for this module, the code-analysis entry point at 0 is not what makes it work. It covers an initializer at 0 that does use TLS, which the test checks.

Checklist

🤖 Generated with Claude Code

The guest reader stored DT_INIT as a plain address, and every user of
it read 0 as "no initializer". The libc.prx and libSceFios2.prx that
Until Dawn (CUSA02636) bundles have DT_INIT present with value 0, the
start of their text segment. So libc.prx, which also has a preinit
array, failed with "Guest PREINIT_ARRAY has no module initializer",
and libSceFios2.prx's initializer never ran.

GuestImage now records whether the tag is present (HasInit). The
reader, the Linux, macOS and Windows writers and the code analysis use
it instead of Init != 0, so an initializer at 0 is mapped, started and
analysed like any other.

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

Copy link
Copy Markdown
Contributor

Independent Linux validation at PR head ab9d21afcfbc4b0ebeba3305b06d8d763b0e6c76: configured a clean Release relinker-only build with GCC 12.2 / Ninja, built relinker and guest_module_args_host, then ran ctest --test-dir /tmp/build-review-6684-pr -R "^guest_zero_initializer$" --output-on-failure: 1/1 passed. git diff --check also passes. I initially tried to run the Python test with the relinker executable as its host-module argument, which failed because that parameter needs the dedicated shared guest_module_args_host.prx; after building the correct target, the registered test passes. CI also passes Ubuntu, Windows and macOS relinker jobs.

JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 16, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
@github-actions

github-actions Bot commented Oct 16, 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 6977 core/relinker/relinker/tests/test_guest_zero_initializer.py

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 5021 core/relinker/CMakeLists.txt
PR 6044 core/relinker/CMakeLists.txt
PR 6618 core/relinker/CMakeLists.txt
core/relinker/relinker/src/analysis/CodeInstructionCollector.cpp
PR 6977 core/relinker/CMakeLists.txt
core/relinker/elfpatcher/src/linux/LinuxGuestModuleWriter.cpp
core/relinker/elfpatcher/src/macos/MacOsGuestModuleWriter.cpp
core/relinker/elfpatcher/src/windows/WindowsGuestModuleWriter.cpp
core/relinker/relinker/include/relinker/guest/GuestImage.hpp
core/relinker/relinker/src/analysis/CodeInstructionCollector.cpp
core/relinker/relinker/src/guest/GuestImageReader.cpp
core/relinker/relinker/src/guest/GuestModuleBuilder.cpp
core/relinker/relinker/tests/test_guest_zero_initializer.py
PR 7996 core/relinker/CMakeLists.txt
core/relinker/relinker/src/analysis/CodeInstructionCollector.cpp
PR 8076 core/relinker/relinker/src/analysis/CodeInstructionCollector.cpp
core/relinker/relinker/src/guest/GuestModuleBuilder.cpp

@savvaniss

Copy link
Copy Markdown
Contributor

Second-title data point: Hasbro Family Fun Pack (CUSA03312, my own dump) needs this PR, on Linux and Windows.

Relinker-only Release build, Windows 11 Pro 26300, WinLibs GCC 15.2.0 r7. Starting from main f3059421f, I merged, in this order: #7660, #6619 (a9fa0e8b0), #5138 (5f5641732), #6381 (6f8d9a09d), 3b158c1 from #6977, #6354 (81561867f), #6357 (32fe3aa8b), #6684 (ab9d21afc) and #6618 (bf06a8e5c). With that stack, the Linux and --windows conversions of the eboot and its 9 bundled modules complete. I then rebuilt the relinker once per member with only that member left out. Without this PR, both targets stop in libc.prx:

FAIL: ...\sce_module\libc.prx: Guest PREINIT_ARRAY has no module initializer

The other members each stop at their own step, so every one of them is needed for this title; the whole table is on #6977.

AI-assisted: yes (Claude Code)

@github-actions github-actions Bot removed the needs rebase Conflicts with main, rebase needed label Oct 18, 2026
@MarcZX10

Copy link
Copy Markdown
Contributor

Thanks for the detailed leave-one-out run. It isolates this PR as necessary for Hasbro Family Fun Pack (CUSA03312) in the cumulative Linux/Windows conversion stack. Without #6684, both Linux and Windows conversions stop in libc.prx with “Guest PREINIT_ARRAY has no module initializer.” I tried to add the setup and result to the PR's Tested section, but GitHub denied the body edit with an admin-rights error; the full evidence remains here in your comment.

SandstromPL and others added 3 commits October 19, 2026 07:33
… option

boykopovar#7147 added add_asan_test_options so the CPU-side tests pass in an
ANYPS5_ENABLE_ASAN build, and gave the relinker tests that load the
instrumented guest_module_args_host.prx into Python
verify_asan_link_order=0. guest_zero_initializer's Linux run loads the
same host library with ctypes.CDLL in a Python subprocess, so under ASan
it failed with "ASan runtime does not come first in initial library
list". It now runs with the same option.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
boykopovar#6106 made a Linux guest module with an initializer depend on
libkernel.prx, which exports the module-argument getter and setter that
its DT_INIT stub imports. That check still tested Init != 0, so a module
whose DT_INIT is 0 got the stub but not the dependency. With no other
libkernel.prx loaded, the executable then stopped with a symbol lookup
error, and a --module-dir module failed to load with "undefined symbol:
__aps5_get_pending_module_args_nid_no_patch". The check now uses HasInit,
like the writers.

guest_zero_initializer now checks that the Linux module lists
libkernel.prx, and its native load provides one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 19, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 19, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
@SuperCatElvis

Copy link
Copy Markdown
Contributor

Tested on Until Dawn (CUSA02636, 1.03 eboot) on Windows 11: this PR works, and with it plus a few others the game now reaches its own loading screen.

Setup: Windows 11, i9-12900KF, RTX 3090 Ti, MinGW (WinLibs GCC 15.2.0), cmake 4.3.1, ninja 1.13.2. Branch: main (as of 2026-10-19) with #6684, #6595 (+ #6618) and #6977 merged, plus the local fixes listed below.

This PR (#6684)

  • libc.prx no longer stops with Guest PREINIT_ARRAY has no module initializer.
  • libSceFios2.prx's initializer runs: its SceFiosIO and SceFiosCallback threads come up and file I/O works.

To answer the question in the description, I disassembled libc.prx from 0 up to the PLT at 0xc8. It is a plain module initializer taking (rdi, rsi, rdx):

  1. It walks the preinit array (0xc2040..0xc2048, one slot), calling each non-null entry.
  2. It walks the init array backwards from 0xc2020, skipping nulls and stopping at the -1 sentinel, calling each entry.
  3. If rdx is non-null it tail-calls it with (rdi, rsi). Otherwise, if the qword at 0xbc890 is non-zero, it tail-jumps to 0x600, else it returns 0.

There is no fs: access anywhere in 0x0–0xc8.

What else Until Dawn needed after #6684 / #6595 / #6977

The patches are in the attached until-dawn-fixes.patch.txt (12 commits, git am on top of main with the three PRs merged). In order of where the game stopped:

  1. Relinker, Windows: imported TLS relocations. Stopped with Imported TLS relocation 16. The executable imports a TLS variable from the bundled libc.prx. R_X86_64_DTPMOD64 / DTPOFF64 (16/17) against a named symbol are now passed through to the Windows binder and bound through .tlsrefs.

  2. Relinker, Windows: PS4 library names. 73 imports were unresolved because the title asks for libSceGnmDriver_padebug, libSceSaveData, libSceSaveDataDialog and libSceVideoRecording. These now also load libSceGnmDriver, libSceSaveData.native, libSceSaveDataDialog.native and libSceVideoRecordingP.

  3. feat: run PS4 games on the AGC driver — Limbo (CUSA01664) in game (integration branch) #6977 merged on current main. Conflicts resolved in Event.cpp, TextureDetiler.cpp, TextureFormat tests and PipelineJournal.cpp (struct sizes changed, format bumped to 5). Duplicate VideoOut cursor stubs removed.

  4. libSceGnmDriver. Added:

    • GetTheTessellationFactorRingBufferBaseAddress. The console address is in the arena's system-reserved hole, so the title couldn't map it; the ring now goes at the highest free 2 MiB-aligned guest address and AGC is told through sceAgcDriverSetTFRing.
    • DrawInitDefaultHardwareState200, the Update{Ps,Vs,Gs,Hs}Shader writers, and the embedded VS/PS shaders.
    • Compute queues: MapComputeQueue[WithPriority], UnmapComputeQueue, and DingDong submitting to AGC queues 0x20+.
    • RequestFlipAndSubmitDone, SetGsRingSizes, and the validation/trace/mip-stats tool queries.

    PS4 packets are now lowered for AGC:

    • 7-dword RELEASE_MEM is widened to 8.
    • EOP/RELEASE_MEM data select 4 (perf counter) becomes 3 (GPU clock). Until Dawn's first frame used it, and AGC rejected it as "GDS".
    • 3-dword PS4 SET_PREDICATION is rewritten to the 4-dword layout. Occlusion/primcount predication is cleared, so predicated draws always run.
    • EVENT_WRITE ZPASS_DONE with an address becomes PIXEL_PIPE_STAT_DUMP.
  5. Start-up services. Named the 46 imports that were only stubbed by NID, and implemented the ones called during start-up as signed-out / not-connected answers:

    • sceContentExportInit (PS4 param block: only malloc/free are checked, because the words after are garbage).
    • CompanionUtil, NpParty init/handler, Camera (not attached).
    • GameLiveStreaming status/settings, sceNpGetAccount* by online id.
    • sceVideoOutGetDeviceCapabilityInfo_, sceAppContentGetEntitlementKey.
    • SaveData backup check/restore/umount-with-backup.
    • sceKernelPrintBacktraceWithModuleInfo.
  6. libkernel: sceKernelAddReadEvent on a sceNetSocket socket. This threw non-socket descriptor not implemented. Readiness checks and equeue waits now resolve libSceNet sockets through KernelFindSocketNative, as poll/select already do.

  7. libkernel: PS4 direct memory size. The game maps all remaining direct memory as one pool at 0x1015a00000 (fixed, no-overwrite), then puts its lump-loader pool at 0x1200000000. With the PS5 size (13.5 GiB) the first pool was 0x2e90d0000 bytes and ran over 0x1200000000, so the lump loader's fixed no-overwrite map got ENOMEM and the game trapped. PS4 titles (marked when libSceGnmDriver loads) now get 5248 MiB: the base console's 4800 MiB plus 448 MiB of room for what AnyPS5 maps before the entry point. The pool is now 0xd10d0000 bytes and the lump loader maps fine. I also added a log line naming the mapping that blocks a fixed no-overwrite map, which is what found this.

Where it is now

The game boots, creates and reads its save (GAMESAVE), loads Wwise and its asset lumps, and renders the loading screen (skull hourglass, bottom right) for a frame or two. Then it stops in the GCN shader recompiler:

[videoout] presentation failed: GCN VOPC v_cmpx (writes both VCC and EXEC) opcode 212 at program counter 16 is not implemented
terminate called after throwing an instance of 'ShaderRecompiler::UnsupportedInstructionError'
  what():  GCN VOPC v_cmpx (writes both VCC and EXEC) opcode 212 at program counter 16 is not implemented

That looks like the next real gap: GCN v_cmpx_* in VOPC form. Opcode 212 = 0xd4, which I read as v_cmpx_gt_u32 in the SI/CI table.

Still known gaps (they throw if hit):

  • sceGnmLogicalCuIndexToPhysicalCuIndex.
  • sceSaveDataMount of another title's save.
  • The online pieces (NpWebApi send, in-game messages, word filter, invitations, party create, video recording).

Full logs are available if useful.

until-dawn-fixes.patch.txt

@SandstromPL

Copy link
Copy Markdown
Contributor Author

@SuperCatElvis thank you, that's exactly what this needed, and great progress on the title. I've added your run to the description and replaced the open question with your disassembly.

It settles the preinit question: the code at 0 walks the preinit array itself, like the Wind Waker Recompiled libc.prx on #7636. So the relinker must not run that entry as well, and this PR only makes a DT_INIT of 0 count as the initializer. The missing fs: access means the code-analysis entry point at 0 isn't what makes this module work. It's there for an initializer at 0 that does use TLS, which the test covers.

One note on item 6 of your patch, in case you upstream it: HQasper's #8057, opened today, changes the same function (AddDescriptorEvent in Equeue.cpp) so that sceKernelAddReadEvent accepts pipe read ends. The socket case would need to sit next to that.

AI-assisted: yes (Claude Code)

@oneandonlydean oneandonlydean self-assigned this Oct 20, 2026
JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 20, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
@oneandonlydean
oneandonlydean merged commit 2fee39f into boykopovar:main Oct 20, 2026
15 of 16 checks passed
JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 20, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 21, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 21, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 21, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 22, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
JJenkx added a commit to JJenkx/AnyPS5 that referenced this pull request Oct 23, 2026
Retain DT_INIT presence when emitting the deferred Linux lifecycle slot. Check zero-valued initializers with and without preinit, native argument forwarding and return status, and the absent-tag case. Addresses the interaction with PR boykopovar#6684 identified in review.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

build relinker unblocks title Names a title ID on the Unblocks line

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants