Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions BUILD.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
18 changes: 18 additions & 0 deletions src/slmodule.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
#pragma once
#include <cstring>
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;
}
}
31 changes: 31 additions & 0 deletions src/tests/slmodule_test.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Which callers of the vulkan-1 swapchain exports are Streamline and must reach the driver.
#include "slmodule.h"
#include <cstdio>
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;
}
3 changes: 2 additions & 1 deletion src/tests/tests_main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@
#include <cstdio>
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;
}
22 changes: 19 additions & 3 deletions src/vkhooks.cpp
Original file line number Diff line number Diff line change
@@ -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 <windows.h>
#include <detours.h>
#include <intrin.h>
#include <cstring>
#include <cstdio>
#include <atomic>
Expand Down Expand Up @@ -368,17 +370,31 @@ template <typename F> 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);
}

Expand Down