"""patch_purge_v5_test.py [--revert] EXPERIMENTAL: Override RenderSurface and RenderTexture's PurgeResource vtable slot (slot 2 = +0x08) with thunks that call the existing Destroy() method on each class, then return true so the purge loop properly marks the resource as cleaned up. Background: GraphicsResource::PurgeResource is virtual (vtable slot 2). The base implementation is the no-op stub at 0x004154a0 (mov al, 1; ret). D3D-specialized subclasses (RenderTextureD3D, RenderSurfaceD3D, CSurface, ImgTex) properly override slot 2 to free their GPU resources. But the BASE RenderSurface and RenderTexture inherit the no-op stub. GraphicsResource::PurgeOldResources iterates resources, calls vtable[2], and if it returns true, marks them as "purged" with a flag (+0x08 on the resource) so they're never retried. The no-op returns true → mark as purged → permanent leak. Verified by: * comparative dump diff (Time/Nyckel low-leak vs Larsson/Jerry high-leak): GraphicsResource vtable is the top-ranked residual leak owner with 367 instances in heavy-looter Larsson vs 0 in low-activity Time. * subclass scan: RenderSurface (vtable 0x0079a67c) and RenderTexture (vtable 0x0079c198) have slot 2 = 0x004154a0 (no-op); six other GraphicsResource subclasses override. * decompile: PurgeOldResources at EoR 0x00446dc0 calls vtable[2] and sets resource->+0x02 = 1 on success. Fix: Replace slot 2 in each leaking vtable with a thunk that calls RenderSurface::Destroy (EoR 0x00444540) / RenderTexture::Destroy (EoR 0x0044c4f0). These methods exist and do idempotent cleanup (delete heavy heap allocations, null pointers, reset state). They don't tear down the C++ object - perfect for PurgeResource. Risks: * If a leaked RenderSurface is in some half-destroyed state where fields +0x64 / +0x114 are dangling (not NULL but point to freed memory), Destroy will double-free. Believed unlikely since Destroy is idempotent and checks for NULL before delete, and the purge loop's eligibility check (+0x6 != currentFrame) only runs purge on resources not in use this frame. * Same shape as v4 (thunk injection + vtable rewrite). v4 has run without crashes on 3 clients for hours. Thunks (10 bytes each): Thunk A — RenderSurface PurgeResource: B8 40 45 44 00 mov eax, 0x00444540 ; RenderSurface::Destroy FF D0 call eax ; thiscall - this in ecx B0 01 mov al, 1 ; return true C3 ret Thunk B — RenderTexture PurgeResource: B8 F0 C4 44 00 mov eax, 0x0044c4f0 ; RenderTexture::Destroy FF D0 call eax B0 01 mov al, 1 C3 ret Vtable slots patched: RenderSurface @ 0x0079a67c + 0x08 = 0x0079a684 → thunk A addr RenderTexture @ 0x0079c198 + 0x08 = 0x0079c1a0 → thunk B addr Both currently point to 0x004154a0 (no-op stub). """ import argparse import ctypes import ctypes.wintypes as wt import sys # Targets NOOP_STUB_VA = 0x004154a0 RENDERSURFACE_DESTROY_VA = 0x00444540 RENDERTEXTURE_DESTROY_VA = 0x0044c4f0 RENDERSURFACE_VTABLE_VA = 0x0079a67c RENDERTEXTURE_VTABLE_VA = 0x0079c198 SLOT_PURGERESOURCE = 0x08 RS_SLOT_VA = RENDERSURFACE_VTABLE_VA + SLOT_PURGERESOURCE # 0x0079a684 RT_SLOT_VA = RENDERTEXTURE_VTABLE_VA + SLOT_PURGERESOURCE # 0x0079c1a0 def make_thunk(target_va: int) -> bytes: return bytes([ 0xB8, target_va & 0xff, (target_va >> 8) & 0xff, (target_va >> 16) & 0xff, (target_va >> 24) & 0xff, # mov eax, target_va 0xFF, 0xD0, # call eax 0xB0, 0x01, # mov al, 1 0xC3, # ret ]) THUNK_RS = make_thunk(RENDERSURFACE_DESTROY_VA) THUNK_RT = make_thunk(RENDERTEXTURE_DESTROY_VA) assert len(THUNK_RS) == 10 assert len(THUNK_RT) == 10 PROCESS_VM_READ = 0x0010 PROCESS_VM_WRITE = 0x0020 PROCESS_VM_OPERATION = 0x0008 PROCESS_QUERY_INFORMATION = 0x0400 MEM_COMMIT_RESERVE = 0x00001000 | 0x00002000 PAGE_EXECUTE_READWRITE = 0x40 PAGE_READWRITE = 0x04 k32 = ctypes.windll.kernel32 OpenProcess = k32.OpenProcess OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD] OpenProcess.restype = wt.HANDLE CloseHandle = k32.CloseHandle CloseHandle.argtypes = [wt.HANDLE] CloseHandle.restype = wt.BOOL VirtualAllocEx = k32.VirtualAllocEx VirtualAllocEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.c_size_t, wt.DWORD, wt.DWORD] VirtualAllocEx.restype = wt.LPVOID WriteProcessMemory = k32.WriteProcessMemory WriteProcessMemory.argtypes = [wt.HANDLE, wt.LPVOID, wt.LPCVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)] WriteProcessMemory.restype = wt.BOOL ReadProcessMemory = k32.ReadProcessMemory ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)] ReadProcessMemory.restype = wt.BOOL VirtualProtectEx = k32.VirtualProtectEx VirtualProtectEx.argtypes = [wt.HANDLE, wt.LPVOID, ctypes.c_size_t, wt.DWORD, ctypes.POINTER(wt.DWORD)] VirtualProtectEx.restype = wt.BOOL def read_dword(h, addr): out = ctypes.c_uint(0) sz = ctypes.c_size_t(0) if not ReadProcessMemory(h, addr, ctypes.byref(out), 4, ctypes.byref(sz)): raise OSError(f"ReadProcessMemory 0x{addr:08x} failed err={ctypes.get_last_error()}") return out.value def write_dword(h, addr, value): """Write a DWORD into a (typically read-only) vtable region by flipping protection RW, writing, then restoring.""" old_prot = wt.DWORD(0) if not VirtualProtectEx(h, addr, 4, PAGE_READWRITE, ctypes.byref(old_prot)): raise OSError(f"VirtualProtectEx RW 0x{addr:08x} failed err={ctypes.get_last_error()}") v = ctypes.c_uint(value) sz = ctypes.c_size_t(0) ok = WriteProcessMemory(h, addr, ctypes.byref(v), 4, ctypes.byref(sz)) err = ctypes.get_last_error() if not ok else 0 restored = wt.DWORD(0) VirtualProtectEx(h, addr, 4, old_prot.value, ctypes.byref(restored)) if not ok: raise OSError(f"WriteProcessMemory 0x{addr:08x} failed err={err}") def main(): ap = argparse.ArgumentParser() ap.add_argument("pid", type=int) ap.add_argument("--revert", action="store_true", help="restore the no-op stub at both vtable slots") args = ap.parse_args() h = OpenProcess( PROCESS_VM_READ | PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_QUERY_INFORMATION, False, args.pid, ) if not h: print(f"OpenProcess({args.pid}) err={ctypes.get_last_error()}") sys.exit(2) rs_cur = read_dword(h, RS_SLOT_VA) rt_cur = read_dword(h, RT_SLOT_VA) print(f"PID {args.pid}") print(f" RenderSurface vtable slot @ 0x{RS_SLOT_VA:08x} = 0x{rs_cur:08x}") print(f" RenderTexture vtable slot @ 0x{RT_SLOT_VA:08x} = 0x{rt_cur:08x}") if args.revert: if rs_cur == NOOP_STUB_VA and rt_cur == NOOP_STUB_VA: print(" both already no-op — nothing to revert") CloseHandle(h); return if rs_cur != NOOP_STUB_VA: write_dword(h, RS_SLOT_VA, NOOP_STUB_VA) print(f" RenderSurface slot reverted to 0x{NOOP_STUB_VA:08x}") if rt_cur != NOOP_STUB_VA: write_dword(h, RT_SLOT_VA, NOOP_STUB_VA) print(f" RenderTexture slot reverted to 0x{NOOP_STUB_VA:08x}") CloseHandle(h); return if rs_cur != NOOP_STUB_VA or rt_cur != NOOP_STUB_VA: print(f" UNEXPECTED — one or both slots not the no-op stub " f"(wanted 0x{NOOP_STUB_VA:08x}). Refusing to patch.") CloseHandle(h); sys.exit(3) page = VirtualAllocEx(h, None, 0x80, MEM_COMMIT_RESERVE, PAGE_EXECUTE_READWRITE) if not page: print(f"VirtualAllocEx failed err={ctypes.get_last_error()}") CloseHandle(h); sys.exit(4) print(f" thunk page allocated @ 0x{page:08x}") thunk_rs_addr = page thunk_rt_addr = page + 0x20 # padded for alignment + safety sz = ctypes.c_size_t(0) if not WriteProcessMemory(h, thunk_rs_addr, THUNK_RS, len(THUNK_RS), ctypes.byref(sz)): print(f"write THUNK_RS failed err={ctypes.get_last_error()}"); sys.exit(5) if not WriteProcessMemory(h, thunk_rt_addr, THUNK_RT, len(THUNK_RT), ctypes.byref(sz)): print(f"write THUNK_RT failed err={ctypes.get_last_error()}"); sys.exit(6) print(f" THUNK_RS @ 0x{thunk_rs_addr:08x}: {THUNK_RS.hex()}") print(f" THUNK_RT @ 0x{thunk_rt_addr:08x}: {THUNK_RT.hex()}") write_dword(h, RS_SLOT_VA, thunk_rs_addr) write_dword(h, RT_SLOT_VA, thunk_rt_addr) rs_after = read_dword(h, RS_SLOT_VA) rt_after = read_dword(h, RT_SLOT_VA) print(f" RenderSurface slot now 0x{rs_after:08x} {'OK' if rs_after == thunk_rs_addr else 'MISMATCH'}") print(f" RenderTexture slot now 0x{rt_after:08x} {'OK' if rt_after == thunk_rt_addr else 'MISMATCH'}") CloseHandle(h) if __name__ == "__main__": main()