diff --git a/BUILD.md b/BUILD.md index 711d3b6..2068f18 100644 --- a/BUILD.md +++ b/BUILD.md @@ -78,6 +78,18 @@ menu freeze). fgvk therefore also detours the exported `vkCreateDevice`, attache game's view (`exportroute.h`, unit-tested). `fgvk.log` prints `export vkGetDeviceProcAddr(...) inside loader vkCreateDevice (third-party hook) -> game view` when it happens. +Streamline itself becomes a third-party caller under Wine, by accident. There `vulkan-1.dll` is a +forwarder into `winevulkan`, which serves its device dispatch table from the SAME addresses it +exports, so Streamline's table points at the four detours above. `sl.common` then issues +`vkGetSwapchainImagesKHR` from inside DLSS-G's swapchain clone, is handed the game's view, and +re-enters that clone before it has filled its proxy buffers: an empty vector, a NULL fallback and a +faulting read in `sl.dlss_g`, about two seconds into the world. The re-entry guard cannot catch it, +because the game resolves through GIPA and never enters these hooks, so no re-entry scope is ever +pushed. fgvk therefore routes callers that are Streamline itself - `sl.*`, `nvngx_dlssg` and +`NvLowLatencyVk`, matched on the module file name in `slmodule.h`, unit-tested - straight to the +loader on the three swapchain exports. The Khronos loader on Windows keeps lookup and dispatch +apart, so this only reproduces under Wine/Proton. + ## NVIDIA App overrides The NVIDIA App applies BG3's per-game DLSS overrides to fgvk's Streamline instance (the on-screen diff --git a/CMakeLists.txt b/CMakeLists.txt index d03b816..b7346f3 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -18,7 +18,7 @@ target_compile_definitions(fgvk-stack PRIVATE WIN32_LEAN_AND_MEAN NOMINMAX) target_link_libraries(fgvk-stack PRIVATE dbghelp) # Unit tests (host-only, no Vulkan/Streamline dependency). -add_executable(fgvk-tests src/tests/tests_main.cpp src/tests/mvecscale_test.cpp src/tests/exportroute_test.cpp) +add_executable(fgvk-tests src/tests/tests_main.cpp src/tests/mvecscale_test.cpp src/tests/exportroute_test.cpp src/tests/slmodule_test.cpp) target_include_directories(fgvk-tests PRIVATE src) enable_testing() add_test(NAME mvecscale COMMAND fgvk-tests) diff --git a/src/slmodule.h b/src/slmodule.h new file mode 100644 index 0000000..7a24def --- /dev/null +++ b/src/slmodule.h @@ -0,0 +1,18 @@ +#pragma once +#include +namespace fgvk { +// True when a module owns part of Streamline's device dispatch table: the interposer, its +// plugins (sl.common, sl.dlss_g, sl.pcl, sl.reflex), DLSS-G's NGX snippet, and the Reflex +// helper. Those call the vulkan-1 exports expecting the driver; everyone else gets the game's +// view. Matched on the file name, so a full path or a bare name both work. +// Deliberately NOT a bare "nvngx" prefix: OptiScaler ships an nvngx.dll DLSS shim next to the +// exe, and it is a third party whose hooks must keep landing on our wrappers. +inline bool IsStreamlineModule(const char* path){ + if (!path) return false; + const char* base = path; + for (const char* p = path; *p; ++p) if (*p == '\\' || *p == '/') base = p + 1; + return _strnicmp(base, "sl.", 3) == 0 + || _strnicmp(base, "nvngx_dlssg", 11) == 0 + || _strnicmp(base, "NvLowLatencyVk", 14) == 0; +} +} diff --git a/src/tests/slmodule_test.cpp b/src/tests/slmodule_test.cpp new file mode 100644 index 0000000..faccae7 --- /dev/null +++ b/src/tests/slmodule_test.cpp @@ -0,0 +1,31 @@ +// Which callers of the vulkan-1 swapchain exports are Streamline and must reach the driver. +#include "slmodule.h" +#include +static int fails = 0; +static void expectBool(const char* name, bool got, bool want){ + if (got != want){ printf("FAIL %s: got %d want %d\n", name, (int)got, (int)want); fails++; } else printf("ok %s\n", name); +} +int test_slmodule(){ + using fgvk::IsStreamlineModule; + // The interposer and the plugins own the device table that aliases our detours on Wine. + expectBool("sl.interposer -> streamline", IsStreamlineModule("C:\\BG3\\bin\\NativeMods\\Streamline\\sl.interposer.dll"), true); + expectBool("sl.common -> streamline", IsStreamlineModule("sl.common.dll"), true); + expectBool("sl.dlss_g -> streamline", IsStreamlineModule("sl.dlss_g.dll"), true); + // DLSS-G's snippet and the Reflex helper ship in the same folder and call the same exports. + expectBool("nvngx_dlssg -> streamline", IsStreamlineModule("nvngx_dlssg.dll"), true); + expectBool("NvLowLatencyVk -> streamline", IsStreamlineModule("NvLowLatencyVk.dll"), true); + // Third parties keep the game's view, or their overlays draw into buffers nobody presents. + expectBool("the game -> not streamline", IsStreamlineModule("C:\\BG3\\bin\\bg3.exe"), false); + expectBool("Script Extender -> not streamline", IsStreamlineModule("DWrite.dll"), false); + expectBool("OptiScaler proxy -> not streamline", IsStreamlineModule("winmm.dll"), false); + // OptiScaler's DLSS shim is called nvngx.dll; a bare "nvngx" prefix would capture it and put + // its hooks on the driver's present under the pacer thread - the deadlock class already fixed. + expectBool("OptiScaler nvngx shim -> not streamline", IsStreamlineModule("C:\\BG3\\bin\\nvngx.dll"), false); + // Forward slashes and a bare name both resolve to the same file name. + expectBool("forward slashes -> streamline", IsStreamlineModule("Streamline/sl.reflex.dll"), true); + expectBool("case is ignored", IsStreamlineModule("SL.PCL.DLL"), true); + expectBool("null path -> not streamline", IsStreamlineModule(nullptr), false); + // A name that merely contains "sl." is not a Streamline module. + expectBool("substring only -> not streamline", IsStreamlineModule("tools\\.dll"), false); + return fails; +} diff --git a/src/tests/tests_main.cpp b/src/tests/tests_main.cpp index d17847c..ad5fa0e 100644 --- a/src/tests/tests_main.cpp +++ b/src/tests/tests_main.cpp @@ -2,8 +2,9 @@ #include int test_mvecscale(); int test_exportroute(); +int test_slmodule(); int main(){ - int fails = test_mvecscale() + test_exportroute(); + int fails = test_mvecscale() + test_exportroute() + test_slmodule(); printf("%d failure(s)\n", fails); return fails ? 1 : 0; } diff --git a/src/vkhooks.cpp b/src/vkhooks.cpp index 5c45b03..2e68c2e 100644 --- a/src/vkhooks.cpp +++ b/src/vkhooks.cpp @@ -1,11 +1,13 @@ #include "vkhooks.h" #include "exportroute.h" +#include "slmodule.h" #include "log.h" #include "slboot.h" #include "inputs.h" #include "config.h" #include #include +#include #include #include #include @@ -368,17 +370,31 @@ template static F GameView(const char* name, F& cache, VkDevice dev if(!cache && ip_GDPA){ cache = (F)w_GetDeviceProcAddr(dev, name); } return cache; } +// Streamline's own device table aliases these detours on Wine: vulkan-1 there forwards into +// winevulkan, which serves that table from the SAME addresses it exports. sl.common's +// vkGetSwapchainImagesKHR - issued from inside DLSS-G's swapchain clone - therefore arrives +// here, takes the game's view, and re-enters the clone, which reads a proxy vector it has not +// filled yet: an empty vector, a NULL fallback, and a faulting read (sl.dlss_g+0x55C35). +// ForceReal cannot see it - the game resolves through GIPA and never enters these hooks, so no +// re-entry scope is ever pushed. The Khronos loader keeps lookup and dispatch apart, which is +// why this never reproduces on Windows. +static inline bool CallerIsStreamline(void* ra){ + HMODULE m{}; char path[MAX_PATH]{}; + GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS|GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,(LPCSTR)ra,&m); + GetModuleFileNameA(m, path, MAX_PATH); + return IsStreamlineModule(path); +} static VKAPI_ATTR VkResult VKAPI_CALL h_GetSwapchainImagesKHRExport(VkDevice d, VkSwapchainKHR sc, uint32_t* n, VkImage* imgs){ - static PFN_vkGetSwapchainImagesKHR gv{}; PFN_vkGetSwapchainImagesKHR f = ForceReal() ? nullptr : GameView("vkGetSwapchainImagesKHR", gv, d); + static PFN_vkGetSwapchainImagesKHR gv{}; PFN_vkGetSwapchainImagesKHR f = (ForceReal() || CallerIsStreamline(_ReturnAddress())) ? nullptr : GameView("vkGetSwapchainImagesKHR", gv, d); static bool logged=false; if(f && !logged){ logged=true; Log("export vkGetSwapchainImagesKHR from tid=%lu routed to the game's (fake-buffer) view", (unsigned long)GetCurrentThreadId()); } return f ? f(d,sc,n,imgs) : o_GetSwapchainImagesKHR_export(d,sc,n,imgs); } static VKAPI_ATTR VkResult VKAPI_CALL h_AcquireNextImageKHRExport(VkDevice d, VkSwapchainKHR sc, uint64_t t, VkSemaphore s, VkFence fe, uint32_t* idx){ - static PFN_vkAcquireNextImageKHR gv{}; PFN_vkAcquireNextImageKHR f = ForceReal() ? nullptr : GameView("vkAcquireNextImageKHR", gv, d); + static PFN_vkAcquireNextImageKHR gv{}; PFN_vkAcquireNextImageKHR f = (ForceReal() || CallerIsStreamline(_ReturnAddress())) ? nullptr : GameView("vkAcquireNextImageKHR", gv, d); return f ? f(d,sc,t,s,fe,idx) : o_AcquireNextImageKHR_export(d,sc,t,s,fe,idx); } static VKAPI_ATTR VkResult VKAPI_CALL h_QueuePresentKHRExport(VkQueue q, const VkPresentInfoKHR* pi){ - static PFN_vkQueuePresentKHR gv{}; PFN_vkQueuePresentKHR f = (ForceReal() || !gDevice) ? nullptr : GameView("vkQueuePresentKHR", gv, gDevice); + static PFN_vkQueuePresentKHR gv{}; PFN_vkQueuePresentKHR f = (ForceReal() || !gDevice || CallerIsStreamline(_ReturnAddress())) ? nullptr : GameView("vkQueuePresentKHR", gv, gDevice); return f ? f(q,pi) : o_QueuePresentKHR_export(q,pi); }