Skip to content

fix(libkernel): provide default arguments block for DT_INIT on Linux - #6149

Open
Nubi-Prey wants to merge 2 commits into
boykopovar:mainfrom
Nubi-Prey:fix/libkernel-default-module-args
Open

Nubi-Prey wants to merge 2 commits into
boykopovar:mainfrom
Nubi-Prey:fix/libkernel-default-module-args

Conversation

@Nubi-Prey

Copy link
Copy Markdown

What

Provide a fallback 16-byte default arguments block {size: 0x10, version: 0x200, context: nullptr} in __aps5_get_pending_module_args_nid_no_patch() when no pending arguments are set.

On Linux, libraries listed under DT_NEEDED execute their DT_INIT routines at process start before sceKernelLoadStartModule runs. Unity's UnifyPS5Plugin.prx (Cult of the Lamb, PPSA06464) reads argp[0] unconditionally expecting {0x10, 0x200, context} or {0x58, 0x103}, which faulted with a null pointer dereference (exit code 139).

Extend guest_module_args test fixture with --load-default native execution to verify that default arguments are delivered and validated without corruption. Record the divergence in docs/dev/debt/libkernel.md.

Closes #5621.

Tested

  • Linux Ubuntu x86_64: ctest --test-dir build-relinker -R "^guest_module_args$" -V (0.19 s, passed).
  • Relinker test matrix: 165/165 tests passed.
  • Conventions check: python3 tools/check_conventions.py --base upstream/main passed.

Checklist

@simonbbby

Copy link
Copy Markdown
Contributor

Independent Linux check at exact head 950bc7c323a44bac2f83781e57d968cf016e932f (Ubuntu 24.04 / Ryzen 9 9950X / GCC 13.3 / Ninja Release).

The relinker-only suite passes 165/165 (28.88 s), and the real production getter returns the proposed default block in a separate native probe. There is a gap in the new regression, though: it remains green with the runtime fix removed, and also with a null default pointer in its own provider.

Controls, rebuilt and run with ctest -R '^guest_module_args$' -V:

Change from the stated head Result
None pass
Restore only production Module.cpp from this head's parent pass
Provider kEmptyPending{sizeof(kDefaultArgs), nullptr} pass
Provider kEmptyPending{8, &kDefaultArgs} fail: the Python size assertion
Null-pointer provider plus the assertion change below fail: Missing default argument block
Correct provider plus the assertion change below pass

The size mutation confirms the fixture ran and rebuilt. The two false greens have separate causes: guest_module_args_host.prx supplies its own __aps5_get_pending_module_args_nid_no_patch, so it never calls the changed production helper; and RecordArgs checks the payload only if argp == &kDefaultArgs. A null pointer skips that check, while Python asserts the size and initializer result but not the pointer. The fixture's initializer only calls RecordArgs, so it does not dereference the null pointer as the reported Unity initializer does.

This small test-only change catches the null pointer and also checks context:

--- a/core/relinker/elfpatcher/tests/GuestModuleArgsHost.cpp
+++ b/core/relinker/elfpatcher/tests/GuestModuleArgsHost.cpp
@@ -48,9 +48,10 @@
     std::snprintf(line, sizeof(line), "args=%016zx argp=%016zx\n", args,
                   static_cast<std::size_t>(reinterpret_cast<std::uintptr_t>(argp)));
     Write(line);
-    if (args == sizeof(DefaultModuleArgs) && argp == &kDefaultArgs) {
+    if (args == sizeof(DefaultModuleArgs)) {
+        if (argp == nullptr) Fail("Missing default argument block");
         const auto* defaultArgs = static_cast<const DefaultModuleArgs*>(argp);
-        if (defaultArgs->size != 0x10 || defaultArgs->version != 0x200) Fail("Default arguments corruption");
+        if (defaultArgs->size != 0x10 || defaultArgs->version != 0x200 || defaultArgs->context != nullptr) Fail("Default arguments corruption");
     }
 }
 

It still needs a regression that calls the real runtime getter. I checked that independently without the full SDL build: compile the actual core/libs/prx/libkernel/Module/src/Module.cpp with -std=c++20 -O2 -ffunction-sections -fdata-sections -Icore/libs, then link a small getter probe with -Wl,--gc-sections to discard unrelated unused runtime functions. The probe casts the getter result to PendingModuleArgs, requires args == 16 and non-null argp, then checks the payload's size 0x10, version 0x200 and null context. The PR production object passes; its parent's production object exits 1 with a missing/null default block. This links the actual compiled helper, rather than a replacement getter.

All temporary source edits were restored, the stock provider rebuilt, and guest_module_args passes again with a clean checkout. No title run, full-runtime load, or claim that this default block matches console behavior.

@jotitadev jotitadev left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

macOS run of the new --load-default path, which has its own darwin branch (RTLD_GLOBAL) that the Linux runs don't cover.

Machine: Apple M2 Max, macOS 27.2, Xcode 27 toolchain, relinker-only Release.
Head: 950bc7c32, merged onto main @ ab9a47684 (clean).

The test only executes natively on macOS when the interpreter is x86_64 (platform.machine() == 'x86_64'). Under ctest on Apple silicon, both the arm64 build and the x86_64 build print native execution skipped, because /usr/bin/python3 starts as arm64. So I ran it by hand from the x86_64 build:

arch -x86_64 /usr/bin/python3 core/relinker/relinker/tests/test_guest_module_args.py <build>/core/relinker/relinker <build>/core/relinker/guest_module_args_host.prx
Change Result (x86_64 under Rosetta, Python 3.9.6)
None pass
Fixture provider GuestModuleArgsHost.cpp restored from main fail: args=0000000000000000 argp=0000000000000000
--load-default with DEFAULT_MODE instead of RTLD_GLOBAL fail: dlopen(...): symbol not found in flat namespace '_RecordArgs'

So the darwin branch works and the RTLD_GLOBAL is required there. As @simonbbby showed on Linux, what the test checks is the fixture's own provider, not the production __aps5_get_pending_module_args_nid_no_patch in Module.cpp. That is the same on macOS: Module.cpp is not part of the relinker-only build.

Full relinker-only suite with the PR: arm64 163/163; x86_64 + Python 3.9.6 159/163, with the same 4 failures as main.

Written with AI assistance (Claude Code). I ran the builds and tests above on my machine.

@Nubi-Prey

Nubi-Prey commented Oct 15, 2026 •

Copy link
Copy Markdown
Author

Thanks @jotitadev and @simonbbby for the thorough independent validation on macOS and Linux!

Carried the suggested mutation control in commit 20e36060f:

  • In GuestModuleArgsHost.cpp: fail with "Missing default argument block" if argp == nullptr when args == sizeof(DefaultModuleArgs), and check context == nullptr.
  • In test_guest_module_args.py: assert that the recorded argp is not null.

Verified locally with a negative mutation control (setting nullptr fails CTest immediately with the expected error) and all 165 CTests and conventions pass on a clean checkout.

Written with AI assistance (Gemini).

This was referenced Oct 15, 2026
@github-actions

github-actions Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

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 4630 docs/dev/debt/libkernel.md
PR 5021 core/libs/prx/libkernel/Module/src/Module.cpp
docs/dev/debt/libkernel.md
PR 6469 docs/dev/debt/libkernel.md
PR 6586 core/relinker/relinker/tests/test_guest_module_args.py
docs/dev/debt/libkernel.md
PR 6740 docs/dev/debt/libkernel.md
PR 6830 docs/dev/debt/libkernel.md
PR 6879 core/libs/prx/libkernel/Module/src/Module.cpp
docs/dev/debt/libkernel.md
PR 6936 docs/dev/debt/libkernel.md
PR 7049 docs/dev/debt/libkernel.md
PR 7088 docs/dev/debt/libkernel.md
PR 7090 docs/dev/debt/libkernel.md
PR 7105 docs/dev/debt/libkernel.md
PR 7121 docs/dev/debt/libkernel.md
PR 7135 docs/dev/debt/libkernel.md
PR 7149 docs/dev/debt/libkernel.md
PR 7192 docs/dev/debt/libkernel.md
PR 7201 core/libs/prx/libkernel/Module/src/Module.cpp
PR 7299 docs/dev/debt/libkernel.md
PR 7447 docs/dev/debt/libkernel.md
PR 7464 docs/dev/debt/libkernel.md
PR 7509 docs/dev/debt/libkernel.md
PR 7511 docs/dev/debt/libkernel.md
PR 7530 docs/dev/debt/libkernel.md
PR 7532 core/relinker/elfpatcher/tests/GuestModuleArgsHost.cpp
docs/dev/debt/libkernel.md
PR 7565 docs/dev/debt/libkernel.md
PR 7605 docs/dev/debt/libkernel.md
PR 7653 docs/dev/debt/libkernel.md
PR 7804 docs/dev/debt/libkernel.md
PR 7853 docs/dev/debt/libkernel.md
PR 7937 docs/dev/debt/libkernel.md
PR 8010 docs/dev/debt/libkernel.md
PR 8344 docs/dev/debt/libkernel.md
PR 8359 docs/dev/debt/libkernel.md
PR 8373 docs/dev/debt/libkernel.md
PR 8408 docs/dev/debt/libkernel.md
PR 8448 docs/dev/debt/libkernel.md
PR 8520 docs/dev/debt/libkernel.md
PR 8533 docs/dev/debt/libkernel.md
PR 8537 docs/dev/debt/libkernel.md
PR 8544 docs/dev/debt/libkernel.md
PR 8547 docs/dev/debt/libkernel.md
PR 8553 docs/dev/debt/libkernel.md
PR 8605 docs/dev/debt/libkernel.md
PR 8641 docs/dev/debt/libkernel.md
PR 8658 docs/dev/debt/libkernel.md
PR 8700 docs/dev/debt/libkernel.md
PR 8725 docs/dev/debt/libkernel.md
PR 8726 docs/dev/debt/libkernel.md
PR 8728 docs/dev/debt/libkernel.md
PR 8742 docs/dev/debt/libkernel.md
PR 8866 docs/dev/debt/libkernel.md
PR 8872 docs/dev/debt/libkernel.md
PR 8890 docs/dev/debt/libkernel.md
PR 8894 docs/dev/debt/libkernel.md
PR 8906 docs/dev/debt/libkernel.md
PR 8910 docs/dev/debt/libkernel.md
PR 8916 docs/dev/debt/libkernel.md
PR 8921 docs/dev/debt/libkernel.md
PR 8977 docs/dev/debt/libkernel.md
PR 8985 docs/dev/debt/libkernel.md
PR 8986 docs/dev/debt/libkernel.md
PR 8988 docs/dev/debt/libkernel.md
PR 9001 docs/dev/debt/libkernel.md
PR 9005 docs/dev/debt/libkernel.md
PR 9059 docs/dev/debt/libkernel.md
PR 9060 docs/dev/debt/libkernel.md
PR 9074 docs/dev/debt/libkernel.md
PR 9095 docs/dev/debt/libkernel.md
PR 9099 docs/dev/debt/libkernel.md
PR 9104 docs/dev/debt/libkernel.md
PR 9105 docs/dev/debt/libkernel.md
PR 9108 docs/dev/debt/libkernel.md
PR 9109 docs/dev/debt/libkernel.md
PR 9123 docs/dev/debt/libkernel.md
PR 9127 docs/dev/debt/libkernel.md
PR 9133 docs/dev/debt/libkernel.md
PR 9134 docs/dev/debt/libkernel.md
PR 9153 docs/dev/debt/libkernel.md
PR 9156 docs/dev/debt/libkernel.md
PR 9160 docs/dev/debt/libkernel.md
PR 9164 docs/dev/debt/libkernel.md
PR 9165 docs/dev/debt/libkernel.md
PR 9167 docs/dev/debt/libkernel.md
PR 9187 docs/dev/debt/libkernel.md
PR 9199 docs/dev/debt/libkernel.md
PR 9218 docs/dev/debt/libkernel.md
PR 9239 docs/dev/debt/libkernel.md
PR 9242 docs/dev/debt/libkernel.md
PR 9256 docs/dev/debt/libkernel.md
PR 9263 core/libs/prx/libkernel/Module/src/Module.cpp
docs/dev/debt/libkernel.md
PR 9265 docs/dev/debt/libkernel.md
PR 9273 docs/dev/debt/libkernel.md
PR 9284 docs/dev/debt/libkernel.md
PR 9293 docs/dev/debt/libkernel.md
PR 9297 docs/dev/debt/libkernel.md
PR 9298 docs/dev/debt/libkernel.md
PR 9302 docs/dev/debt/libkernel.md
PR 9314 docs/dev/debt/libkernel.md
PR 9326 docs/dev/debt/libkernel.md
PR 9359 docs/dev/debt/libkernel.md
PR 9363 docs/dev/debt/libkernel.md
PR 9365 docs/dev/debt/libkernel.md
PR 9378 docs/dev/debt/libkernel.md
PR 9389 docs/dev/debt/libkernel.md
PR 9461 docs/dev/debt/libkernel.md
PR 9477 docs/dev/debt/libkernel.md
PR 9482 docs/dev/debt/libkernel.md
PR 9491 docs/dev/debt/libkernel.md
PR 9499 docs/dev/debt/libkernel.md
PR 9509 docs/dev/debt/libkernel.md
PR 9512 docs/dev/debt/libkernel.md
PR 9515 docs/dev/debt/libkernel.md
PR 9557 docs/dev/debt/libkernel.md
PR 9561 docs/dev/debt/libkernel.md
PR 9566 docs/dev/debt/libkernel.md
PR 9573 docs/dev/debt/libkernel.md
PR 9575 docs/dev/debt/libkernel.md
PR 9578 docs/dev/debt/libkernel.md
PR 9582 docs/dev/debt/libkernel.md
PR 9632 docs/dev/debt/libkernel.md
PR 9669 docs/dev/debt/libkernel.md
PR 9682 docs/dev/debt/libkernel.md
PR 9684 docs/dev/debt/libkernel.md
PR 9693 docs/dev/debt/libkernel.md
PR 9732 docs/dev/debt/libkernel.md
PR 9736 docs/dev/debt/libkernel.md
PR 9794 docs/dev/debt/libkernel.md
PR 9840 docs/dev/debt/libkernel.md
PR 9843 docs/dev/debt/libkernel.md

Provide a fallback 16-byte default arguments block {0x10, 0x200, nullptr} in
__aps5_get_pending_module_args_nid_no_patch() when no pending arguments are set.

On Linux, libraries listed under DT_NEEDED execute their DT_INIT at process start
before sceKernelLoadStartModule runs. Unity's UnifyPS5Plugin.prx (Cult of the Lamb,
PPSA06464) reads argp[0] unconditionally expecting {size: 0x10, version: 0x200, context}
or {0x58, 0x103}, which faulted when argp was null.

Extend guest_module_args relinker test fixture to execute native load verification
without pending arguments and assert the default block parameters. Update
docs/dev/debt/libkernel.md documenting the console divergence.

Closes boykopovar#5621.
Assert that argp is non-null when default module arguments size is
passed in GuestModuleArgsHost.cpp, and check that context is null.
In test_guest_module_args.py, assert that the recorded argp in events
is not null.
@Nubi-Prey
Nubi-Prey force-pushed the fix/libkernel-default-module-args branch from 20e3606 to 0cd9877 Compare October 15, 2026 15:10
@JJenkx

JJenkx commented Oct 15, 2026

Copy link
Copy Markdown
Contributor

Independent Linux title check of exact PR head 0cd9877c6d3224c6207ed58948bc102330350648: Double Dragon Gaiden: Rise of the Dragons (PPSA16018), 01.000.013 is not unblocked by the fallback alone.

Compared main ce991f1ec2eb71a1f30459b4442a0730a0a4861b plus the common #6476 build repair against that same source plus only this PR's two commits, preserving authorship. Full libs and relinker builds pass. The rebuilt guest_module_args CTest passes (0.14 s). A separate ctypes call to the built production __aps5_get_pending_module_args_nid_no_patch confirms a non-null 16-byte default block with size 0x10, version 0x200, null context; this check uses the runtime library, not the relinker test provider.

Both native launches fail before the executable's main entry, in the converted bundled Media/Plugins/NativePluginPS5Example.prx:

Source First debugger fault, relative to the module's executable mapping Observed operation
main + common build repair +0x23a Argument/version checker reads through a null argument pointer.
same + this PR +0x3a9 Version check has passed; module startup reads the vtable through a null context pointer, immediately before a method call.

The first check accepts {size=0x10, version=0x200} and saves the pointer at block +8. The second crash follows its use. Thus supplying a non-null header is insufficient for this plugin: it needs the context as well. I have not established the correct source or lifecycle for that context and have not added a replacement pointer, skipped initialization, or bypassed the call. This is additional title scope, not a rerun of the Cult of the Lamb result.

Environment: Arch Linux 7.2.9-arch1-1, GCC 16.2.1, Ryzen 9 7950X. Both regular runs exit 139; debugger runs confirm the distinct sites above. Same converted executable and guest modules, with verified library paths. No game assets, raw title logs, binaries or screenshots attached. AI-assisted investigation.

@MarcZX10

Copy link
Copy Markdown
Contributor

I checked the independent Double Dragon result in the linked issue thread against this PR's stated fallback. On the exact earlier PR head, the {0x10, 0x200, nullptr} block gets past the plugin's header check, but NativePluginPS5Example.prx then dereferences the null context at +0x3a9 before the executable reaches main (https://github.com/boykopovar/AnyPS5/pull/6149#issuecomment-6201881775).\n\nThat does not disprove the narrower Cult of the Lamb fix, but it shows the default block does not make every needed-module DT_INIT safe. Could you clarify whether Cult of the Lamb was run past this initializer on the current head, and keep the PR's claim scoped to the reproduced case if that remains unverified?

@oneandonlydean oneandonlydean self-assigned this Oct 17, 2026
@oneandonlydean

Copy link
Copy Markdown
Collaborator

Two things before this can go further. The fallback also fires for an explicit sceKernelLoadStartModule with no arguments, since that path stores {0, nullptr} too, so those loads now get the guessed block as well; could you limit it to the DT_NEEDED initializers this PR is about? And on @MarcZX100's question: was Cult of the Lamb run past this initializer on the current head? @JJenkx's Double Dragon run shows the block gets past the header check and then hits the null context, so I'd like the claim scoped to what was reproduced.

@socket1001

socket1001 commented Oct 17, 2026 •

Copy link
Copy Markdown

Suggestion for this PR, with measurements (AI-assisted - note at the end).

Cross-posting this from #5621 because it is about the fallback this PR installs, rather than a per-title finding.

The default block here is {0x10, 0x200} with context = nullptr. On a title that actually consumes that block (PPSA16018, Double Dragon Gaiden: Rise of the Dragons), the field turns out not to be a data pointer - it gets called:

  • the plugin's init reads the value from args[8] (the +8 field, 0x10/0x200 format) into a module global;
  • an accessor inside the module later does rc = g_context(id, &out); return rc ? 0 : out; - so the field is invoked as int (*)(int id, void **out);
  • the caller then dereferences the object written to *out and calls [obj->vtable + 0x10], with rsi pointing at the module's own static table (where the module has just registered its callback pointers);
  • with the field left nullptr, this title faults inside plugin init at module offset +0x3a9 - a call rax with rax = 0, which is the null it was handed.

What I measured locally. Setting that field to a getter of that shape, returning 0 and handing back a statically built object whose [vtable+0x10] is a harmless stub, is enough for this title to get through: plugin init completes and the game runs - Vulkan device, swapchain 2224x1251 with 3 images, then its prologue, character select and mission select on input, into the first stage at 45-60 FPS. Same three commits cherry-picked onto main as of 13d07428a, 127 runtime libraries rebuilt, both this title and an unaffected one re-run as a control.

To be clear: a real loader-side context object is presumably the right answer and this is not a proposal to merge - it is a throwaway probe to demonstrate the shape of the field, and it should not be committed as-is. Two things a proper version would have to do differently are reflected in the code below: no mutable static state (the module may call this from more than one thread), and a real decision about the block size.

// The plugin consumes the default block's context as:
//   g_context = args[8]                       (the {0x10, 0x200} layout)
//   accessor:  rc = g_context(id, &out);  return rc ? 0 : out;
//   caller:    obj = *out;  obj->vtable[0x10](obj, module_static);   // virtual call
// i.e. the field is a getter function, not a data pointer - so a no-op is enough here
// only because the object it hands back is never used for anything this title needs.
//
// Everything here is statically initialised: the getter can be reached from more than one
// thread during module init, so there is deliberately no lazy-init guard and no mutable state.
// The signature of the virtual slot that gets called is not established, hence the loose one.

using ModuleContextNoop = long (*)(void*, void*);

extern "C" long moduleContextNoop(void* /*self*/, void* /*arg*/) { return 0; }

// slot +0x10 = element 2 (8-byte pointers), so every slot is filled rather than guessing
extern "C" constinit ModuleContextNoop moduleContextVtable[8] = {
    moduleContextNoop, moduleContextNoop, moduleContextNoop, moduleContextNoop,
    moduleContextNoop, moduleContextNoop, moduleContextNoop, moduleContextNoop,
};

struct ModuleContextObject { ModuleContextNoop const* vtable; };  // *(obj) -> vtable

extern "C" constinit ModuleContextObject moduleContextObject{moduleContextVtable};

extern "C" int moduleContextGetter(int /*id*/, void** out) {
    if (out != nullptr) *out = &moduleContextObject;
    return 0;
}

alignas(16) const SceKernelDefaultModuleArgs kDefaultModuleArgs{
    0x10, 0x200, reinterpret_cast<const void*>(&moduleContextGetter)};

The id argument's meaning is not established - the plugin passes a value of its choosing and the getter above ignores it. Whether the returned object's other methods matter beyond this title is untested.

One size caveat while picking a default: the block above is the 16-byte {0x10, 0x200} shape, and this plugin also carries code for the {0x48, 0x103} shape, which reads the same field from a 0x48-byte block (offset 0x40). It selects between the two from the version field, so a consistent 16-byte block does not reach the larger path here - but anything handed the 16-byte default while taking that path would read past the end of it. Not something I could trigger, just worth knowing.

Disassembly, register state and the thread-level evidence behind the shape claim are in my comment on #5621; happy to test anything more specific you would like tried.

Note: written by an AI agent (Hermes, running on the reporter's own machine) while testing the emulator locally. The builds, runs and measurements are real and reproducible; the prose is the agent's. Not affiliated with the project.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

docs fix error Closes an issue (Fixes #N) libkernel needs rebase Conflicts with main, rebase needed relinker unblocks title Names a title ID on the Unblocks line

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Linux: Cult of the Lamb (PPSA06464) faults at process start in UnifyPS5Plugin.prx, a needed library started with a null arguments block

7 participants