Skip to content

Use ucrtbase.dll instead of msvcrt.dll for Proton 11.0 support#63

Open
Pizzabelly wants to merge 3 commits intoFexty12573:masterfrom
Pizzabelly:ucrtbase
Open

Use ucrtbase.dll instead of msvcrt.dll for Proton 11.0 support#63
Pizzabelly wants to merge 3 commits intoFexty12573:masterfrom
Pizzabelly:ucrtbase

Conversation

@Pizzabelly
Copy link
Copy Markdown
Contributor

@Pizzabelly Pizzabelly commented Apr 30, 2026

(Continuing from #62)

I was finally able to test this on Windows and can confirm using winmm.dll for the loader still works the same after these changes.

The situation with ucrtbase.dll is interesting, though. Firstly, I've found a better source for the quirks of "Local deployment" of ucrtbase.dll1, which details that on Windows 10/11 ucrtbase.dll will not be loaded from the exe directory. Which is consistent with what I observed in testing on Windows 11. Vanilla Wine also follows this behavior and will not load a local ucrtbase.dll. Therefore, ucrtbase.dll should be treated effectively as a KnownDLL. Meaning: 1. "CW-Bug-Id: #22048" referenced in my original issue may have nothing to do with the differing registry entries like I theorized and is more likely related to Windows <10 compatability and 2. Using ucrtbase.dll for the loader fully depends on the previously mention Wine patch to stay around in Proton.

This had me wondering if I could get to the root of why trying to use winmm.dll on Proton is different than Windows. It's been mentioned before2 that MonsterHunterWorld.exe has all it's dependent DLLs, which includes winmm.dll, marked as "delay load"3. This in theory means the game won't load winmm.dll until it's used4. Something that could plausibly end up different between Wine and Windows, but I don't think that's the case here.

I'm fairly sure that the reason we're able to use winmm.dll to hook the game so early on Windows is actually because of the Steam for Windows-specific behavior of injecting GameOverlayRenderer64.dll, which has a non-delayed dependency on winmm.dll3. This can be verified on Windows by running the game without Steam, which will result in a very similar crash as trying to use winmm.dll on Wine. Then, if you add that MonsterHunterWorld.exe as a Non-Steam Game, causing Steam to inject GameOverlayRenderer64.dll, using winmm.dll now loads as expected again.

I think this is mostly fine to depend on because as far as I know the only other way we could get the loader loaded early enough would be a separate loader exe, which is definitely less user friendly.

Either way, I was curious how Steam would react to the loader exe approach, so I whipped up a little proof of concept. Surprisingly it actually works on both Windows and Linux. I was kinda expecting Steam to block it in some way. I guess it could be a last resort solution if Proton eventually stops allowing ucrtbase.dll.

// Based on https://github.com/UG40A/wine_injector

#include <stdio.h>
#include <windows.h>

// The architecture of launcher.exe has to match the target exe and dll.
// So use the correct cl.exe. "x64 Native Tools Command Prompt for VS 20XX" vs "x86 Native...".
// If CreateRemoteThread() is failing with code 5 (Access Denied), this is most likley the reason.

int main(int argc, char* argv[])
{
    if (argc < 3) return (printf("Usage: %s <executable> <dll>\n", argv[0]) & 0) | 1;

    HMODULE kernel32 = GetModuleHandle("kernel32.dll");
    LPVOID LoadLibraryAddr = (LPVOID)GetProcAddress(kernel32, "LoadLibraryA");

    STARTUPINFO si = { .cb = sizeof(si) };
    PROCESS_INFORMATION pi = { 0 };
    CreateProcessA(NULL, argv[1], NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);

    SIZE_T arglen = strlen(argv[2]) + 1;
    LPVOID buf = VirtualAllocEx(pi.hProcess, NULL, arglen, MEM_RESERVE | MEM_COMMIT, PAGE_EXECUTE_READWRITE);
    WriteProcessMemory(pi.hProcess, buf, argv[2], arglen, NULL);

    HANDLE hThread = CreateRemoteThread(pi.hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)LoadLibraryAddr, buf, 0, NULL);
    if (!hThread) {
        printf("Failed to CreateRemoteThread(). Code: %ld\n", GetLastError());
    }
    WaitForSingleObject(hThread, INFINITE);
    DWORD dwExitCode;
    if (GetExitCodeThread(hThread, &dwExitCode)) {
        BOOL bSuccess = (dwExitCode != 0) ? TRUE : FALSE;
        if (bSuccess) {
            printf("Injection success\n");
        } else {
            printf("Injection failed\n");
        }
    }
    CloseHandle(hThread);

    VirtualFreeEx(pi.hProcess, buf, 0, MEM_RELEASE);

    ResumeThread(pi.hThread);

    WaitForSingleObject(pi.hProcess, INFINITE);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

    FreeLibrary(kernel32);

    return 0;
}

Windows:

"D:\SteamLibrary\steamapps\common\Monster Hunter World\launcher.exe" %command% winmm.dll

Linux:

export CMD=$(echo -n "%command%" | sed "s/\/MonsterHunterWorld.exe/\/launcher.exe/") && DOTNET_ROOT= WINEDLLOVERRIDES="winmm,dinput8=n,b" eval $CMD MonsterHunterWorld.exe winmm.dll

I had to finagle the Linux launch command cause I'm not super good with bash, it may be possible to simplify.

For extra reference, this is a hacky Wine patch I made to force winmm.dll to load early, which also lets using winmm.dll as the loader work on Proton. It's not a solution though, because it's unrealistic to require patching Wine.

diff --git a/dlls/ntdll/loader.c b/dlls/ntdll/loader.c
index eac888a..6f9a205 100644
--- a/dlls/ntdll/loader.c
+++ b/dlls/ntdll/loader.c
@@ -90,6 +90,7 @@ static const WCHAR system_path[] = L"C:\\windows\\system32;C:\\windows\\system;C
 
 static BOOL is_prefix_bootstrap;  /* are we bootstrapping the prefix? */
 static BOOL imports_fixup_done = FALSE;  /* set once the imports have been fixed up, before attaching them */
+static BOOL emulate_inject_done = TRUE;
 static BOOL process_detaching = FALSE;  /* set on process detach to avoid deadlocks with thread detach */
 static int free_lib_count;   /* recursion depth of LdrUnloadDll calls */
 static LONG path_safe_mode;  /* path mode set by RtlSetSearchPathMode */
@@ -2374,7 +2375,7 @@ static NTSTATUS build_module( LPCWSTR load_path, const UNICODE_STRING *nt_name,
     basename_len = wcslen(basename);
     if (basename_len >= 4 && !wcscmp(basename + basename_len - 4, L".dll")) basename_len -= 4;
 
-    if (use_lsteamclient() && ((is_steamclient32 = !RtlCompareUnicodeStrings(basename, basename_len, L"steamclient", 11, TRUE)) ||
+    if ((use_lsteamclient() && emulate_inject_done) && ((is_steamclient32 = !RtlCompareUnicodeStrings(basename, basename_len, L"steamclient", 11, TRUE)) ||
          !RtlCompareUnicodeStrings(basename, basename_len, L"steamclient64", 13, TRUE) ||
          !RtlCompareUnicodeStrings(basename, basename_len, L"gameoverlayrenderer", 19, TRUE) ||
          !RtlCompareUnicodeStrings(basename, basename_len, L"gameoverlayrenderer64", 21, TRUE)) &&
@@ -4628,6 +4629,8 @@ void loader_init( CONTEXT *context, void **entry )
 {
     static int attach_done;
     NTSTATUS status;
+    HMODULE gameoverlayrenderer64 = NULL;
+    UNICODE_STRING gameoverlayrenderer64_us;
     ULONG_PTR cookie, port = 0;
     WINE_MODREF *wm;
 
@@ -4783,6 +4786,18 @@ void loader_init( CONTEXT *context, void **entry )
         }
         release_address_space();
         if (wm->ldr.TlsIndex == -1) call_tls_callbacks( wm->ldr.DllBase, DLL_PROCESS_ATTACH );
+        emulate_inject_done = FALSE;
+        // Forcing the actual GameOverlayRenderer64.dll breaks input, so use winmm.dll as a test.
+        RtlCreateUnicodeStringFromAsciiz( &gameoverlayrenderer64_us, "winmm.dll" );
+        if (LdrLoadDll( default_load_path, 0, &gameoverlayrenderer64_us, &gameoverlayrenderer64 ) != STATUS_SUCCESS)
+        {
+            ERR( "Failed to force load GameOverlayRenderer64.dll\n" );
+        }
+        else
+        {
+            WARN( "Force loaded GameOverlayRenderer64.dll\n" );
+        }
+        emulate_inject_done = TRUE;
         if (wm->ldr.ActivationContext) RtlDeactivateActivationContext( 0, cookie );
 
         NtQueryInformationProcess( GetCurrentProcess(), ProcessDebugPort, &port, sizeof(port), NULL );

Footnotes

  1. https://learn.microsoft.com/en-us/cpp/windows/universal-crt-deployment?view=msvc-160#local-deployment

  2. https://github.com/Fexty12573/SharpPluginLoader/issues/44#issuecomment-2320324317

  3. deps 2
  4. https://learn.microsoft.com/en-us/cpp/build/reference/linker-support-for-delay-loaded-dlls?view=msvc-170

@Andoryuuta
Copy link
Copy Markdown
Collaborator

If I recall correctly, I switched this to use winmm instead of dinput8 to be able to implement the hooks/callbacks that happen before and after the CRT initializes in the game process (but prior to the game's main).

It might be worth looking trying to figure out if anyone ever actually used those, or if they are just adding useless complexity / should be ripped out in favor of going back to a reliable dinput8 proxy.

(Just throwing this out there, since you are looking at this all anyways).

@Pizzabelly
Copy link
Copy Markdown
Contributor Author

I know I've had to move patches out of OnLoad() and into something earlier like Initialize() or OnPreMain(), but I don't think I've ever needed something pre-CRT initialize. Having Initialize()'s position depend on the delayed-load DLL order seems confusing, though.

Having to worry about load order from within the loader code also sounds limiting. Already I think using dinput8 specifically would break #60 based on the log order (dinput8 attached after D3D init). Although, it might be possible to work around by using a DirectX DLL as a proxy.

There's an argument for picking the earliest loading non-KnownDLL and forgoing the CRT init hooks. The hooking in Preloader.cpp doesn't seem overly complex, though. I also like that it effectively provides an assert that we loaded before the game. Otherwise, the difference in Proton and Windows behavior may have been even more confusing. The thing about it I'm wondering is if GetSystemTimeAsFileTime() being called pre-CRT init is defined in the MHW exe or in the MSVCRT DLLs. If it's in the exe that seems more robust. I think it should be added to the comments around that code.

Copy link
Copy Markdown
Owner

@Fexty12573 Fexty12573 left a comment

Choose a reason for hiding this comment

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

Thanks for your work and for the detailed report! Looks good overall

Comment thread make-package.py
parser.add_argument("-h", "--help", action="help", help="Show this message")
parser.add_argument("-s", "--skip-build", help="Skip building the solution", action="store_true")
parser.add_argument("-c", "--config", help="The configuration to build", default="Release", choices=["Release", "Debug"], type=str.capitalize)
parser.add_argument("-m", "--msbuild-in-path", help="Set if msbuild is in the PATH environment variable", action="store_true")
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Add this back please (or some other way to build with VS2026 instead of 2022).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I kept the checking PATH by default but put it before searching the install directories. This should cover every case except wanting to use the VS 2026 MSBuild from a VS 2022 dev shell and vise versa, I can't think of any reason for doing that though.

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.

3 participants