Release replacement textures when a client is torn down (#38)

* 1.9

* Release replacement textures when a client is torn down

TextureClient::~TextureClient deleted the per-texture side-state but never
released the replacement IDirect3DTexture9 objects gMod created. That's
fine when the client dies because the device hit refcount 0 (a texture
refs its device, so by then every fake is already released and the maps
are empty). But when gMod is unloaded via FreeLibrary while the game's
device is still alive (late-injected/SetDevice integrations), the maps
are full of textures gMod still owns and they all leak - and ExitInstance
never deleted the clients, so the destructor didn't even run.

- ~TextureClient now releases each replacement it still owns (a fake is
  still owned while it has a partner; an orphaned fake was already
  released and is left alone). No-op in the device-release path.
- Add DestroyAllTextureClients() to delete the per-device clients.
- ExitInstance calls it, gated on a genuine FreeLibrary
  (lpReserved == nullptr) so it's skipped during process termination
  where the device/d3d9 may already be gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Jon <>
Co-authored-by: Marc <m@pyc.ac>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jon
2026-06-08 17:14:10 +01:00
committed by GitHub
parent b34da98139
commit ac39241ff9
5 changed files with 41 additions and 11 deletions
+2 -2
View File
@@ -10,9 +10,9 @@ if(NOT(CMAKE_SIZEOF_VOID_P EQUAL 4))
endif() endif()
set(VERSION_MAJOR 1) set(VERSION_MAJOR 1)
set(VERSION_MINOR 8) set(VERSION_MINOR 9)
set(VERSION_PATCH 0) set(VERSION_PATCH 0)
set(VERSION_TWEAK 2) set(VERSION_TWEAK 0)
set(VERSION_RC "${CMAKE_CURRENT_BINARY_DIR}/version.rc") set(VERSION_RC "${CMAKE_CURRENT_BINARY_DIR}/version.rc")
configure_file("${CMAKE_CURRENT_SOURCE_DIR}/version.rc.in" "${VERSION_RC}" @ONLY) configure_file("${CMAKE_CURRENT_SOURCE_DIR}/version.rc.in" "${VERSION_RC}" @ONLY)
+4
View File
@@ -6,4 +6,8 @@ void InstallD3D9Hooks(IDirect3D9* d3d9, bool is_ex);
void RemoveAllD3D9Hooks(); void RemoveAllD3D9Hooks();
// Delete every per-device TextureClient, releasing its replacement textures. Only safe
// on a real FreeLibrary (device still alive), not during process termination.
void DestroyAllTextureClients();
bool RegisterExistingDevice(IDirect3DDevice9* device); bool RegisterExistingDevice(IDirect3DDevice9* device);
+9 -4
View File
@@ -159,10 +159,15 @@ TextureClient::~TextureClient()
std::lock_guard lk(registry_mutex); std::lock_guard lk(registry_mutex);
shutting_down = true; // the texture Release hook now no-ops for our textures shutting_down = true; // the texture Release hook now no-ops for our textures
// Drop side-state but don't Release the textures: the game already released // Release replacements we still own (still partnered) so they aren't leaked when
// its originals (and our fakes with them), and the device may be gone. // torn down with the device alive (FreeLibrary). Orphaned fakes were already
for (const auto state : fakes | std::views::values) { // released; a device-release teardown leaves the maps empty, so this is a no-op.
delete state; for (const auto fake : fakes | std::views::values) {
if (fake->partner != nullptr) {
fake->partner->partner = nullptr; // detach the original's back-pointer
if (fake->real) fake->real->Release();
}
delete fake;
} }
fakes.clear(); fakes.clear();
for (const auto state : originals | std::views::values) { for (const auto state : originals | std::views::values) {
+16
View File
@@ -366,6 +366,22 @@ void RemoveAllD3D9Hooks()
o_CubeRelease = nullptr; o_CubeRelease = nullptr;
} }
void DestroyAllTextureClients()
{
// Collect under the lock, delete outside it (~TextureClient takes its own locks).
std::vector<TextureClient*> clients;
{
std::lock_guard lk(g_devices_mutex);
for (const auto& entry : g_devices) {
clients.push_back(entry.second);
}
g_devices.clear();
}
for (auto* client : clients) {
delete client;
}
}
// All three read state->real directly; gMod never swaps the underlying resource. // All three read state->real directly; gMod never swaps the underlying resource.
namespace { namespace {
+10 -5
View File
@@ -5,7 +5,7 @@
import TextureClient; import TextureClient;
void ExitInstance(); void ExitInstance(bool is_unloading);
void InitInstance(HINSTANCE hModule); void InitInstance(HINSTANCE hModule);
namespace { namespace {
@@ -179,8 +179,6 @@ HRESULT APIENTRY Direct3DCreate9Ex(UINT SDKVersion, IDirect3D9Ex** ppD3D)
BOOL WINAPI DllMain(HINSTANCE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) BOOL WINAPI DllMain(HINSTANCE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{ {
UNREFERENCED_PARAMETER(lpReserved);
switch (ul_reason_for_call) { switch (ul_reason_for_call) {
case DLL_PROCESS_ATTACH: { case DLL_PROCESS_ATTACH: {
#ifdef _DEBUG #ifdef _DEBUG
@@ -196,7 +194,9 @@ BOOL WINAPI DllMain(HINSTANCE hModule, DWORD ul_reason_for_call, LPVOID lpReserv
break; break;
} }
case DLL_PROCESS_DETACH: { case DLL_PROCESS_DETACH: {
ExitInstance(); // lpReserved == nullptr means FreeLibrary (device still alive); non-null
// means process exit, where d3d9/the device may already be gone.
ExitInstance(lpReserved == nullptr);
break; break;
} }
default: break; default: break;
@@ -314,13 +314,18 @@ extern "C" __declspec(dllexport) int __cdecl GetFiles(wchar_t* buffer, const siz
} }
} }
void ExitInstance() void ExitInstance(bool is_unloading)
{ {
DISABLE_HOOK(GetProcAddress_fn); DISABLE_HOOK(GetProcAddress_fn);
// Revert every D3D9 vtable hook so the original objects are left pristine. // Revert every D3D9 vtable hook so the original objects are left pristine.
RemoveAllD3D9Hooks(); RemoveAllD3D9Hooks();
// On a real unload the device is still alive; release our replacement textures.
if (is_unloading) {
DestroyAllTextureClients();
}
MH_Uninitialize(); MH_Uninitialize();
if (gMod_Loaded_d3d9_Module_Handle) if (gMod_Loaded_d3d9_Module_Handle)