Initial commit — leak-hunt project complete
Five bugs identified and patched in retail Asheron's Call client: - v3b: palette refcount over-increment (3-byte NOP at two sites) - v5: RenderSurface PurgeResource no-op stub (vtable slot 2 thunk) - v11: two dangling-pointer crash guards (NULL-check + reorder) - v14: CEnvCell::Destroy ClipPlaneList leak (18-byte JMP to cleanup thunk) - v22: unpacker stale-pointer SEH guard (whole-function __try/__except) All five ship in leakfix.dll (117 KB, SHA d282f23c…) which is loaded by acclient.exe at process start via PE import table patching by tools/install_leakfix.py. Controlled 15-client fleet soak: unpatched control died at 26h with palette exhaustion; all 14 patched clients survived past that point and reached ≥5-day uptime. Residual ~15 MB/h growth traced to d3d9.dll's internal slab allocator (260KB surface backing buffers retained after Release). See REPORT.md §10 for the full investigation; conclusion is that it's unfixable from outside d3d9. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
57b5e43d0e
199 changed files with 1648333 additions and 0 deletions
319
dll/DESIGN.md
Normal file
319
dll/DESIGN.md
Normal file
|
|
@ -0,0 +1,319 @@
|
|||
# leakfix.dll — Standalone Native Patch DLL
|
||||
|
||||
## Goal
|
||||
|
||||
Consolidate all runtime patches (v3b, v5, v11, v12, v14) **plus** add a
|
||||
periodic CObjCell/LongHash cleanup sweep that's impossible at the
|
||||
byte-patching level. Ship as a single native 32-bit DLL + tiny launcher
|
||||
EXE. No Decal dependency.
|
||||
|
||||
## Why now
|
||||
|
||||
- Per-client byte patching works but doesn't scale to the residual
|
||||
~7–8 MB/hr CPhysicsObj-family leak (requires real cleanup loops, not
|
||||
inline thunks).
|
||||
- The Python patchers re-apply on every restart via the monitor —
|
||||
brittle. A DLL loads with the process.
|
||||
- Native code = clean crash dumps at real fault sites (no CLR wrapping
|
||||
like UB's `System.AccessViolationException` issue).
|
||||
|
||||
## Tech stack
|
||||
|
||||
- **Language:** C++17, MSVC `cl.exe` (verified working: `MSVC 14.44.35207`).
|
||||
- **Target:** 32-bit x86 (`/arch:IA32`, default for `vcvars32`).
|
||||
- **Runtime:** static link (`/MT`) → no extra runtime DLL dependency.
|
||||
- **Hooking:** MinHook (single-header MIT, ~700 LOC) for frame-tick detour.
|
||||
- **AC struct mirrors:** subset of `references/acclient.h`.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
dll/
|
||||
├── DESIGN.md # this file
|
||||
├── leakfix/
|
||||
│ ├── build.bat # one-shot build via vcvars32
|
||||
│ ├── src/
|
||||
│ │ ├── dllmain.cpp # DllMain, patch application, hook install
|
||||
│ │ ├── patches.cpp # v3b, v5, v11, v12, v14 application
|
||||
│ │ ├── thunks.cpp # inline-asm thunks (v14 ClipPlaneList, v5 purge)
|
||||
│ │ ├── sweep.cpp # periodic CObjCell/LongHash cleanup
|
||||
│ │ ├── hook.cpp # MinHook wiring for frame-tick detour
|
||||
│ │ ├── logging.cpp # rolling log file
|
||||
│ │ ├── ac_addrs.h # EoR address constants
|
||||
│ │ ├── ac_types.h # struct mirrors
|
||||
│ │ └── minhook/ # vendored MinHook source
|
||||
│ └── injector/
|
||||
│ └── inject.cpp # CreateProcess(suspended) + LoadLibraryA inject
|
||||
└── test/ # hello.dll already verified
|
||||
```
|
||||
|
||||
## Patch porting plan
|
||||
|
||||
Each existing Python patcher becomes a few lines of C++ that runs in
|
||||
`DllMain` on `DLL_PROCESS_ATTACH`.
|
||||
|
||||
### v3b — palette NOP (trivial port)
|
||||
|
||||
```cpp
|
||||
WriteCode(0x0053EFFE, "\x90\x90\x90", 3);
|
||||
WriteCode(0x0053F19C, "\x90\x90\x90", 3);
|
||||
```
|
||||
|
||||
### v5 — RenderSurface PurgeResource vtable override
|
||||
|
||||
The current 10-byte thunk becomes a real function:
|
||||
|
||||
```cpp
|
||||
typedef void (__thiscall *DestroyFn)(void* self);
|
||||
constexpr auto RENDERSURFACE_DESTROY = (DestroyFn)0x00444540;
|
||||
constexpr auto RENDERTEXTURE_DESTROY = (DestroyFn)0x0044C4F0;
|
||||
|
||||
int __thiscall purge_rendersurface(void* self) {
|
||||
RENDERSURFACE_DESTROY(self);
|
||||
return 1;
|
||||
}
|
||||
int __thiscall purge_rendertexture(void* self) {
|
||||
RENDERTEXTURE_DESTROY(self);
|
||||
return 1;
|
||||
}
|
||||
|
||||
void apply_v5() {
|
||||
WriteVtableSlot(0x0079A684, (void*)&purge_rendersurface);
|
||||
WriteVtableSlot(0x0079C1A0, (void*)&purge_rendertexture);
|
||||
}
|
||||
```
|
||||
|
||||
Replaces VirtualAllocEx + 10-byte thunk with proper function pointers
|
||||
inside our DLL's .text.
|
||||
|
||||
### v11 — NULL-check NOPs
|
||||
|
||||
Two byte-level rewrites identical to Python patcher.
|
||||
|
||||
### v12 — unpacker validator + dispatch redirect
|
||||
|
||||
- Patcher allocates a 29-byte validator thunk + rewrites a dispatch
|
||||
table entry.
|
||||
- C++ version: validator becomes a `__declspec(naked)` function;
|
||||
dispatch table entry becomes a function pointer.
|
||||
|
||||
### v14 — CEnvCell ClipPlaneList fix
|
||||
|
||||
Replace 18 bytes at `0x0052E661` with a 5-byte JMP into a naked
|
||||
function:
|
||||
|
||||
```cpp
|
||||
__declspec(naked) void clipplane_cleanup_thunk() {
|
||||
__asm {
|
||||
pushad
|
||||
mov edi, [esi + 0xDC]
|
||||
test edi, edi
|
||||
jz done
|
||||
mov ecx, [edi]
|
||||
test ecx, ecx
|
||||
jz free_outer
|
||||
push ecx
|
||||
mov eax, 0x0053C760 ; ClipPlaneList::~ClipPlaneList
|
||||
call eax
|
||||
pop ecx
|
||||
push ecx
|
||||
mov eax, 0x005DF15E ; operator delete
|
||||
call eax
|
||||
add esp, 4
|
||||
free_outer:
|
||||
push edi
|
||||
mov eax, 0x005DF164 ; operator delete[]
|
||||
call eax
|
||||
add esp, 4
|
||||
mov [esi + 0xDC], ebx
|
||||
done:
|
||||
popad
|
||||
push 0x0052E673 ; resume
|
||||
ret
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then install a 5-byte `E9 rel32` from `0x0052E661` to `clipplane_cleanup_thunk`,
|
||||
followed by 13 NOPs.
|
||||
|
||||
## NEW: CObjCell/LongHash cleanup sweep
|
||||
|
||||
This is the actual reason for going to a DLL. Byte patches can't
|
||||
express the logic.
|
||||
|
||||
### What we know
|
||||
|
||||
- Top owner vtable holding leaked CPhysicsObjs: `0x0079BF64` (= `LongHash<CPhysicsObj>::Node`, 21,553 hits).
|
||||
- Secondary: `0x007ED3B0` (CObjCell-family containers, `object_list` DArrays) and `0x007CA4DC` (another LongHash family).
|
||||
- All `CPhysicsObj::Destroy` teardown code is correct when called — the bug is it's never called for these objects.
|
||||
|
||||
### Sweep design
|
||||
|
||||
```cpp
|
||||
struct LongHashNode {
|
||||
LongHashNode* next;
|
||||
uint32_t key;
|
||||
void* value; // CPhysicsObj*
|
||||
};
|
||||
|
||||
struct LongHashTable {
|
||||
void* vtable;
|
||||
LongHashNode** buckets;
|
||||
uint32_t bucket_count;
|
||||
uint32_t entry_count;
|
||||
// ... mirror layout from acclient.h
|
||||
};
|
||||
|
||||
void sweep_physobj_table(LongHashTable* table, uint32_t cutoff_ts) {
|
||||
for (uint32_t b = 0; b < table->bucket_count; ++b) {
|
||||
LongHashNode** prev = &table->buckets[b];
|
||||
LongHashNode* node = *prev;
|
||||
while (node) {
|
||||
LongHashNode* next = node->next;
|
||||
CPhysicsObj* po = (CPhysicsObj*)node->value;
|
||||
|
||||
if (is_safe_to_destroy(po, cutoff_ts)) {
|
||||
*prev = next;
|
||||
CPhysicsObj_Destroy(po); // 0x005145D0
|
||||
operator_delete(node);
|
||||
--table->entry_count;
|
||||
} else {
|
||||
prev = &node->next;
|
||||
}
|
||||
node = next;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Safety predicates (critical — these prevent v13-class crashes)
|
||||
|
||||
A CPhysicsObj is "safe to destroy" only if:
|
||||
|
||||
1. `po->parent == NULL` (not currently attached to anything live)
|
||||
2. `po->object_state` indicates dead/destroyed (need to find flag)
|
||||
3. `po->last_used_timestamp` is older than some threshold (e.g., 60s)
|
||||
4. `po->cell == NULL` (not in any cell's object list)
|
||||
5. `po` is NOT referenced from any other table we know about (best-effort scan)
|
||||
|
||||
If any predicate is uncertain, leave it. **Conservative wins.**
|
||||
|
||||
### Tick hook
|
||||
|
||||
Need to find a function AC calls every frame, hook it via MinHook,
|
||||
and trigger sweep every N frames (e.g., every 300 frames ≈ 5s at 60fps).
|
||||
|
||||
Candidate hook targets to investigate:
|
||||
- `Render::Render` or main game loop entry
|
||||
- `Input::ProcessFrame`
|
||||
- `cm_GameLoop::Tick` (if it exists)
|
||||
|
||||
This needs another small investigation. Once found, hook:
|
||||
|
||||
```cpp
|
||||
typedef void (__cdecl *TickFn)();
|
||||
TickFn original_tick;
|
||||
|
||||
void __cdecl hooked_tick() {
|
||||
original_tick();
|
||||
static int counter = 0;
|
||||
if (++counter >= 300) {
|
||||
counter = 0;
|
||||
sweep_all_physobj_tables();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Injection mechanism
|
||||
|
||||
### Phase 1 — launcher EXE (development & testing)
|
||||
|
||||
```cpp
|
||||
int main(int argc, char** argv) {
|
||||
STARTUPINFO si = { sizeof(si) };
|
||||
PROCESS_INFORMATION pi;
|
||||
CreateProcess("acclient.exe", build_cmdline(argc, argv),
|
||||
NULL, NULL, FALSE, CREATE_SUSPENDED,
|
||||
NULL, NULL, &si, &pi);
|
||||
|
||||
// Inject DLL
|
||||
void* mem = VirtualAllocEx(pi.hProcess, NULL, MAX_PATH, MEM_COMMIT, PAGE_READWRITE);
|
||||
WriteProcessMemory(pi.hProcess, mem, "C:\\path\\to\\leakfix.dll", MAX_PATH, NULL);
|
||||
HANDLE thr = CreateRemoteThread(pi.hProcess, NULL, 0,
|
||||
(LPTHREAD_START_ROUTINE)GetProcAddress(GetModuleHandle("kernel32"), "LoadLibraryA"),
|
||||
mem, 0, NULL);
|
||||
WaitForSingleObject(thr, INFINITE);
|
||||
ResumeThread(pi.hThread);
|
||||
return 0;
|
||||
}
|
||||
```
|
||||
|
||||
Usage: `leakfix_launch.exe -h server -p port -u user -...` → drops in
|
||||
as substitute for direct `acclient.exe`.
|
||||
|
||||
### Phase 2 — PE import table modification (production)
|
||||
|
||||
Patch `acclient.exe`'s PE header to add `leakfix.dll` to its imports.
|
||||
Then the OS loader pulls our DLL in automatically before AC's
|
||||
`WinMain` runs. User just runs acclient as normal.
|
||||
|
||||
Tool: small Python or C++ utility that does:
|
||||
- Open PE
|
||||
- Find IMPORT_DIRECTORY
|
||||
- Add new IMAGE_IMPORT_DESCRIPTOR pointing at `leakfix.dll`
|
||||
- Stuff in a fake IAT with a single function (`leakfix_init` exported from our DLL)
|
||||
- Resave executable
|
||||
|
||||
(There are existing tools like `LoadDll`, `PE Bear`, or
|
||||
`peimporter` we can crib from.)
|
||||
|
||||
## Build setup
|
||||
|
||||
```batch
|
||||
@echo off
|
||||
call "C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars32.bat"
|
||||
cl /LD /nologo /O2 /MT /EHsc /std:c++17 /W3 ^
|
||||
/D_CRT_SECURE_NO_WARNINGS /D_WIN32_WINNT=0x0601 ^
|
||||
/Fe:leakfix.dll ^
|
||||
src\dllmain.cpp src\patches.cpp src\thunks.cpp src\sweep.cpp ^
|
||||
src\hook.cpp src\logging.cpp src\minhook\*.c ^
|
||||
/link kernel32.lib user32.lib
|
||||
```
|
||||
|
||||
`/MT` avoids needing `vcruntime*.dll` alongside.
|
||||
|
||||
## Implementation order
|
||||
|
||||
1. ✅ Verify toolchain builds 32-bit DLL (hello.dll)
|
||||
2. Write `dllmain.cpp` + `patches.cpp` with v3b only — verify same bytes as Python patcher produces, manually inject into a test PID
|
||||
3. Add v11 (similar simple byte writes)
|
||||
4. Add v5 (real `__thiscall` purge functions in our DLL .text)
|
||||
5. Add v12 (more complex but pattern same as v5)
|
||||
6. Add v14 (inline-asm naked function)
|
||||
7. Build injector EXE, test full apply-on-attach flow
|
||||
8. Find frame-tick hook target via Ghidra (separate task)
|
||||
9. Wire MinHook + sweep skeleton
|
||||
10. Implement sweep predicates iteratively, very long soak windows per iteration
|
||||
11. Optional: PE import table patcher for one-launcher-binary UX
|
||||
|
||||
## Risk management
|
||||
|
||||
- Each patch porting step is verified against the Python patcher's
|
||||
byte output before merging. No new bytes = no new risk.
|
||||
- Sweep is the only NEW logic and follows v13 lessons: long soaks,
|
||||
conservative predicates, refuse-to-destroy-if-uncertain rule.
|
||||
- Crash dumps land cleanly because we're not crossing managed/unmanaged
|
||||
boundary.
|
||||
|
||||
## What it replaces
|
||||
|
||||
- `tools/patch_palette_v3b.py` — runtime-applied at DLL load
|
||||
- `tools/patch_purge_v5_test.py` — runtime-applied at DLL load
|
||||
- `tools/patch_v11_test.py` — runtime-applied at DLL load
|
||||
- `tools/patch_v12_test.py` — runtime-applied at DLL load
|
||||
- `tools/patch_v14_cenvcell_clipplane.py` — runtime-applied at DLL load
|
||||
- `tools/fleet_monitor.sh` auto-patching cascade — no longer needed (DLL
|
||||
applies all on every restart automatically)
|
||||
|
||||
Snapshot/HB monitoring stays in place — that's separate from patching.
|
||||
34
dll/leakfix/build.bat
Normal file
34
dll/leakfix/build.bat
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
@echo off
|
||||
setlocal
|
||||
pushd "%~dp0"
|
||||
|
||||
set "VCVARS=C:\Program Files (x86)\Microsoft Visual Studio\2022\BuildTools\VC\Auxiliary\Build\vcvars32.bat"
|
||||
if not exist "%VCVARS%" (
|
||||
echo ERROR: vcvars32.bat not found at "%VCVARS%"
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
call "%VCVARS%" >nul
|
||||
|
||||
if not exist build mkdir build
|
||||
|
||||
cl /LD /nologo /O2 /MT /EHsc /std:c++17 /W3 ^
|
||||
/D_CRT_SECURE_NO_WARNINGS /D_WIN32_WINNT=0x0601 ^
|
||||
/Fo"build\\" /Fd"build\\" ^
|
||||
/Fe"build\leakfix.dll" ^
|
||||
src\dllmain.cpp src\patches.cpp src\thunks.cpp src\logging.cpp src\instr.cpp ^
|
||||
/link /SUBSYSTEM:WINDOWS kernel32.lib user32.lib dbghelp.lib /OUT:"build\leakfix.dll"
|
||||
set RC=%ERRORLEVEL%
|
||||
|
||||
if %RC% NEQ 0 (
|
||||
echo BUILD FAILED rc=%RC%
|
||||
popd
|
||||
exit /b %RC%
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Built: %CD%\build\leakfix.dll
|
||||
echo.
|
||||
|
||||
popd
|
||||
endlocal
|
||||
BIN
dll/leakfix/dist/leakfix.dll
vendored
Normal file
BIN
dll/leakfix/dist/leakfix.dll
vendored
Normal file
Binary file not shown.
66
dll/leakfix/injector/inject.cpp
Normal file
66
dll/leakfix/injector/inject.cpp
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// inject.cpp — load leakfix.dll into a running acclient.exe PID.
|
||||
//
|
||||
// Usage: inject.exe <pid> <abs_path_to_leakfix.dll>
|
||||
//
|
||||
// Mechanism: OpenProcess + VirtualAllocEx + WriteProcessMemory +
|
||||
// CreateRemoteThread(LoadLibraryA). Standard Win32 DLL injection.
|
||||
#include <windows.h>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc != 3) {
|
||||
std::printf("usage: %s <pid> <dll_path>\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
DWORD pid = (DWORD)std::strtoul(argv[1], nullptr, 10);
|
||||
const char* dll = argv[2];
|
||||
|
||||
HANDLE h = OpenProcess(
|
||||
PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
|
||||
PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
|
||||
FALSE, pid);
|
||||
if (!h) {
|
||||
std::printf("OpenProcess(%lu) failed err=%lu\n", pid, GetLastError());
|
||||
return 2;
|
||||
}
|
||||
|
||||
size_t path_len = std::strlen(dll) + 1;
|
||||
void* remote = VirtualAllocEx(h, nullptr, path_len, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
|
||||
if (!remote) {
|
||||
std::printf("VirtualAllocEx failed err=%lu\n", GetLastError());
|
||||
CloseHandle(h); return 3;
|
||||
}
|
||||
SIZE_T written = 0;
|
||||
if (!WriteProcessMemory(h, remote, dll, path_len, &written)) {
|
||||
std::printf("WriteProcessMemory failed err=%lu\n", GetLastError());
|
||||
VirtualFreeEx(h, remote, 0, MEM_RELEASE); CloseHandle(h); return 4;
|
||||
}
|
||||
|
||||
HMODULE k32 = GetModuleHandleA("kernel32.dll");
|
||||
LPTHREAD_START_ROUTINE loadlib = (LPTHREAD_START_ROUTINE)GetProcAddress(k32, "LoadLibraryA");
|
||||
if (!loadlib) {
|
||||
std::printf("GetProcAddress(LoadLibraryA) failed err=%lu\n", GetLastError());
|
||||
VirtualFreeEx(h, remote, 0, MEM_RELEASE); CloseHandle(h); return 5;
|
||||
}
|
||||
|
||||
DWORD tid = 0;
|
||||
HANDLE thr = CreateRemoteThread(h, nullptr, 0, loadlib, remote, 0, &tid);
|
||||
if (!thr) {
|
||||
std::printf("CreateRemoteThread failed err=%lu\n", GetLastError());
|
||||
VirtualFreeEx(h, remote, 0, MEM_RELEASE); CloseHandle(h); return 6;
|
||||
}
|
||||
|
||||
std::printf("injected; remote tid=%lu, waiting for LoadLibraryA to return...\n", tid);
|
||||
WaitForSingleObject(thr, 30000);
|
||||
|
||||
DWORD exit_code = 0;
|
||||
GetExitCodeThread(thr, &exit_code);
|
||||
std::printf("LoadLibraryA returned 0x%08lx (non-zero = HMODULE)\n", exit_code);
|
||||
|
||||
CloseHandle(thr);
|
||||
VirtualFreeEx(h, remote, 0, MEM_RELEASE);
|
||||
CloseHandle(h);
|
||||
return exit_code ? 0 : 7;
|
||||
}
|
||||
196
dll/leakfix/src/ac_addrs.h
Normal file
196
dll/leakfix/src/ac_addrs.h
Normal file
|
|
@ -0,0 +1,196 @@
|
|||
// ac_addrs.h — EoR acclient.exe addresses used by leakfix.dll
|
||||
// Verified against acclient.exe v0.0.11.6096 EoR (Jan 2017).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace ac {
|
||||
|
||||
// ===== v3b — palette over-increment NOP =====
|
||||
constexpr uintptr_t V3B_SITE_1 = 0x0053EFFE; // inc dword [ecx+24] -> NOP NOP NOP
|
||||
constexpr uintptr_t V3B_SITE_2 = 0x0053F19C; // inc dword [esi+24] -> NOP NOP NOP
|
||||
|
||||
// ===== v5 — RenderSurface/Texture PurgeResource override =====
|
||||
constexpr uintptr_t V5_RS_VTABLE_SLOT_2 = 0x0079A684; // RenderSurface vtable +0x08
|
||||
constexpr uintptr_t V5_RT_VTABLE_SLOT_2 = 0x0079C1A0; // RenderTexture vtable +0x08
|
||||
constexpr uintptr_t V5_NOOP_STUB_VA = 0x004154A0; // expected original (mov al,1; ret)
|
||||
constexpr uintptr_t V5_RS_DESTROY_VA = 0x00444540; // RenderSurface::Destroy
|
||||
constexpr uintptr_t V5_RT_DESTROY_VA = 0x0044C4F0; // RenderTexture::Destroy
|
||||
|
||||
// ===== v11 — NULL-check guards =====
|
||||
constexpr uintptr_t V11_SITE_1_VA = 0x00587126; // delete_contents JMP retarget
|
||||
constexpr uintptr_t V11_SITE_2_VA = 0x005E565D; // ~GXTri3Mesh slot 0 NULL-check
|
||||
|
||||
// ===== v12 — unpacker validator + dispatch redirect =====
|
||||
constexpr uintptr_t V12_VALIDATOR_VA = 0x00526A45; // overwrite 11-NOP pad + 18 bytes
|
||||
constexpr uintptr_t V12_DISPATCH_VA = 0x007C92C8; // dispatch table entry (4 bytes)
|
||||
constexpr uintptr_t V12_OLD_FUNC_VA = 0x00526A50; // original unpacker entry
|
||||
// Dispatch points at validator (V12_VALIDATOR_VA) instead
|
||||
|
||||
// ===== v14 — CEnvCell::Destroy ClipPlaneList leak =====
|
||||
constexpr uintptr_t V14_PATCH_SITE_VA = 0x0052E661; // 18-byte leak block
|
||||
constexpr uintptr_t V14_RESUME_VA = 0x0052E673; // continue here after thunk
|
||||
constexpr uintptr_t V14_CLIPPLANELIST_DTOR = 0x0053C760; // ClipPlaneList::~ClipPlaneList
|
||||
constexpr uintptr_t V14_OPERATOR_DELETE = 0x005DF15E; // operator delete
|
||||
constexpr uintptr_t V14_OPERATOR_DELETE_ARR = 0x005DF164; // operator delete[]
|
||||
|
||||
// ===== v18 — s_Resources sweep (free v5-purged shells) =====
|
||||
// EoR GraphicsResource::s_Resources (SmartArray<GraphicsResource*, 1>):
|
||||
// +0x0: m_data (GraphicsResource**)
|
||||
// +0x4: m_sizeAndDeallocate (high bit = own-buffer flag)
|
||||
// +0x8: m_num
|
||||
constexpr uintptr_t V18_S_RESOURCES_VA = 0x008398C4; // &s_Resources
|
||||
constexpr uintptr_t V18_S_RESOURCES_MNUM_VA = 0x008398CC; // &s_Resources.m_num
|
||||
// GraphicsResource subobject layout (offsets from entry pointer):
|
||||
// +0x00: vfptr (subobject vtable)
|
||||
// +0x04: padding (zero)
|
||||
// +0x08: m_bIsLost byte (1 = purged shell, eligible for sweep)
|
||||
// +0x10: m_TimeUsed (8 B long double)
|
||||
// +0x18: m_FrameUsed
|
||||
// +0x1C: m_bIsThrashable + m_AutoRestore + pad
|
||||
// +0x20: m_nResourceSize (0 after Destroy)
|
||||
// +0x24: m_ListIndex (-1 sentinel after UnlinkResource)
|
||||
// UnlinkResource (cdecl, arg1 = GraphicsResource*); 2013 0x00446B70 + 0x160
|
||||
constexpr uintptr_t V18_UNLINK_RESOURCE_VA = 0x00446CD0;
|
||||
// Whitelisted vfptrs eligible for sweep (only the v5-patched classes —
|
||||
// their Destroy is known to leave the shell with NULL state fields).
|
||||
constexpr uintptr_t V18_VTABLE_RENDERSURF = 0x0079A67C; // RenderSurface base
|
||||
constexpr uintptr_t V18_VTABLE_RENDERTEX = 0x0079C198; // RenderTexture
|
||||
|
||||
// ===== v20 — RenderSurfaceD3D PurgeResource -> Destroy redirect =====
|
||||
// Mirror v5 for the D3D subclass that v5 correctly skipped.
|
||||
//
|
||||
// RenderSurfaceD3D is a GraphicsResource subclass whose own PurgeResource
|
||||
// (0x00696D90) releases the D3D9 IUnknown but never destroys the 304-byte
|
||||
// C++ shell. Result: 37-50% of s_Resources entries with vtable 0x00801A94
|
||||
// flagged m_bIsLost=true accumulate indefinitely (v18-A1 evidence).
|
||||
//
|
||||
// Fix: replace slot 2 of the GR secondary vtable with a thunk that calls
|
||||
// RenderSurfaceD3D::Destroy. CRITICAL: Destroy expects PRIMARY `this`
|
||||
// (proven by its internal `lea ecx, [esi+0x30]` before MarkResourceAsNotLost),
|
||||
// but the engine dispatches PurgeResource with GR-view `this`. The thunk
|
||||
// must adjust ecx by -0x30 before calling Destroy.
|
||||
constexpr uintptr_t V20_RSD3D_GR_VTABLE_SLOT_2 = 0x00801A9C; // 0x00801A94 + 0x08
|
||||
constexpr uintptr_t V20_RSD3D_PURGE_VA = 0x00696D90; // expected current value
|
||||
constexpr uintptr_t V20_RSD3D_DESTROY_VA = 0x00696EB0; // RenderSurfaceD3D::Destroy
|
||||
|
||||
// ===== v19 — feed iter-3-triple CPhysicsObj candidates to AC's safe
|
||||
// destruction queue via CObjectMaint::AddObjectToBeDestroyed.
|
||||
//
|
||||
// Predicates (from project_iter3_predicate_data — ~9-15% of CPhysicsObjs
|
||||
// pass the "triple" set):
|
||||
// parent == NULL (+0x40)
|
||||
// cell == NULL (+0x90)
|
||||
// hash_next == NULL (+0x04 — not linked into any hash chain)
|
||||
//
|
||||
// Each candidate's id (+0x08) is pushed to AC's destruction queue with a
|
||||
// 25s delay. AC's Tick processor (CObjectMaint at 2013 0x005089b0) drains
|
||||
// the queue and calls vtable->RecvNotice_SetSelectedItem(id), which is
|
||||
// AC's native destruction-by-id path. This uses AC's existing safe
|
||||
// machinery; we just feed it data it wasn't seeing.
|
||||
//
|
||||
// Gated by env LEAKFIX_V19_FEED — default OFF (count-only).
|
||||
constexpr uintptr_t V19_OBJ_MAINT_GLOBAL = 0x00844D64; // CPhysicsObj::obj_maint (CObjectMaint**)
|
||||
constexpr uintptr_t V19_ADD_TO_DESTROY_VA = 0x00509A40; // CObjectMaint::AddObjectToBeDestroyed (__thiscall: ECX=this, stack=id)
|
||||
|
||||
// ===== v25 — D3D9 texture create tracker =====
|
||||
//
|
||||
// Hooks IDirect3DDevice9::CreateTexture (vtable slot 23) to log every
|
||||
// texture allocation with the caller's return address. AC's d3d9.dll
|
||||
// is dynamically loaded, so we find it at runtime via GetModuleHandle.
|
||||
// The device vtable is identified by its slot count (119 valid d3d9
|
||||
// pointers — only IDirect3DDevice9 is that large).
|
||||
constexpr int V25_DEVICE_EVICT_MANAGED_SLOT = 5; // IDirect3DDevice9::EvictManagedResources
|
||||
constexpr int V25_DEVICE_CREATE_TEXTURE_SLOT = 23; // IDirect3DDevice9::CreateTexture
|
||||
constexpr int V25_DEVICE_CREATE_OFFSCREEN_SLOT = 36; // IDirect3DDevice9::CreateOffscreenPlainSurface
|
||||
// This is the dominant 260KB-allocator path
|
||||
// for AC's RenderSurfaceD3D::CreateD3DSurface
|
||||
constexpr int V25_DEVICE_MIN_VTABLE_SLOTS = 110; // signature for "is the device" (119 expected; allow margin)
|
||||
constexpr int V25_MAX_CALLER_BUCKETS = 64; // distinct caller VAs to track
|
||||
|
||||
// AC global pointer chain to the active IDirect3DDevice9.
|
||||
// Per RenderSurfaceD3D::CreateD3DSurface decomp:
|
||||
// (**(code **)(**(int **)(DAT_00870340 + 0x468) + 0x90))(...)
|
||||
// Resolves as: device = *(*( *(uint32_t*)0x00870340 ) + 0x468); vt = *device; fn = vt[36].
|
||||
constexpr uintptr_t V25_AC_GLOBAL_VA = 0x00870340;
|
||||
constexpr uintptr_t V25_AC_DEVICE_FIELD_OFFSET = 0x468;
|
||||
|
||||
// ===== v22 — unpacker stale-pointer SEH guard =====
|
||||
//
|
||||
// Small inline unpacker at 0x00526A50 (73 bytes, no Ghidra name).
|
||||
// Pulls 4 consecutive DWORDs from arg1->buffer into this+4/+8/+C/+10,
|
||||
// auto-advancing the buffer pointer. Crashes have repeatedly been
|
||||
// observed at +0x3A (0x00526A8A: `mov edx, [edx]` — the 4th deref)
|
||||
// when arg1->buffer points at freed/kernel memory. Server-driven —
|
||||
// the 09:00 2026-05-21 incident hit 5 clients simultaneously.
|
||||
//
|
||||
// v12 (retired) tried entry validation but the bad pointer arrives
|
||||
// mid-execution. v22 takes a different approach: copy the function
|
||||
// body to executable memory, replace the original entry with a JMP
|
||||
// to a C wrapper that runs the copy inside __try/__except. On any
|
||||
// AV, returns 0 — which the engine already handles as the
|
||||
// size-check-failure code path (line 1 of the original returns 0).
|
||||
constexpr uintptr_t V22_UNPACKER_VA = 0x00526A50; // function entry
|
||||
constexpr size_t V22_UNPACKER_LEN = 76; // 73-byte body + 3 NOP tail
|
||||
// (round up to avoid clipping ret)
|
||||
|
||||
// ===== v23 — CPhysicsObj orphan-creation hook =====
|
||||
//
|
||||
// CObjectMaint::ReleaseObjCell (0x005086E0) filters cell-unload destruction
|
||||
// with `(state & 1) == 0 AND parent == NULL` — children of CPhysicsObjs are
|
||||
// silently skipped. Later, CPhysicsObj::unparent_children (0x00513FE0) nulls
|
||||
// their parent without calling AddObjectToBeDestroyed. Result: orphan with
|
||||
// parent=NULL, cell=NULL, hash_next=NULL still held by CObjectMaint::object_table
|
||||
// and LongHash<CPhysicsObj>::Node. Exactly matches the iter-3 triple signature.
|
||||
//
|
||||
// v23 hooks the `mov dword ptr [esi+0x40], 0` instruction (the parent-NULL
|
||||
// write inside unparent_children) and calls AddObjectToBeDestroyed on the
|
||||
// child being orphaned. AC's 25-second deferred-destroy queue gives the
|
||||
// engine time to settle any in-flight references — same safety property
|
||||
// that made v19's feeder safe.
|
||||
//
|
||||
// Default: log-only mode (count would-be-enqueues, no FEED). Gated by env
|
||||
// LEAKFIX_V23_ENQUEUE=1 OR file flag leakfix_v23_enqueue.flag.
|
||||
//
|
||||
// Patch site: at 0x00514043, replace 7 bytes `c7 46 40 00 00 00 00`
|
||||
// (mov [esi+0x40], 0) with `e8 [rel32] 90 90` (5-byte CALL to thunk + 2 NOPs).
|
||||
// Thunk preserves all regs/flags, calls log_enqueue_orphan_child(esi),
|
||||
// performs the original mov, returns past the patched bytes.
|
||||
constexpr uintptr_t V23_PATCH_SITE_VA = 0x00514043; // mov [esi+0x40], 0 inside unparent_children
|
||||
constexpr uintptr_t V23_UNPARENT_CHILDREN_VA = 0x00513FE0;
|
||||
// v23b — symmetric hook at the OTHER parent-NULL write site.
|
||||
// CPhysicsObj::unset_parent (0x00513F70) is the single-object detach
|
||||
// (also called transitively by both set_parent overloads). Same
|
||||
// instruction (mov [esi+0x40], 0), same register convention. Catches
|
||||
// mobile-class detach events that unparent_children misses.
|
||||
constexpr uintptr_t V23B_PATCH_SITE_VA = 0x00513FAC;
|
||||
constexpr uintptr_t V23B_UNSET_PARENT_VA = 0x00513F70;
|
||||
// Field offsets (relative to CPhysicsObj primary this — same as v19/iter-3)
|
||||
// id at +0x08
|
||||
// parent at +0x40
|
||||
// cell at +0x90
|
||||
// state at +0xA8
|
||||
|
||||
// ===== v24 — RenderTextureD3D shell sweep =====
|
||||
//
|
||||
// RenderTextureD3D (vtable 0x00801A18 in s_Resources) has the same shape
|
||||
// as RenderSurfaceD3D: pure-GPU class with no CPU buffers — PurgeResource
|
||||
// releases the D3D9 IUnknown but leaves the 176-byte C++ shell linked.
|
||||
// v5-style PurgeResource->Destroy redirect was proven inert by v20.
|
||||
//
|
||||
// Only way to recover the shells: invoke the scalar deleting destructor
|
||||
// (which chains to ~RenderTextureD3D → ~GraphicsResource → UnlinkResource).
|
||||
// Safety: only destroy entries that have been lost for >= AGE_THRESHOLD
|
||||
// scans AND have all D3D refs NULL (engine has fully released them).
|
||||
//
|
||||
// Default: count-only mode. Gated by env LEAKFIX_V24_SWEEP=1 OR file flag
|
||||
// leakfix_v24_sweep.flag.
|
||||
constexpr uintptr_t V24_RTD3D_VTABLE_GR = 0x00801A18; // RenderTextureD3D GR-view vtable
|
||||
constexpr uintptr_t V24_RTD3D_VTABLE_PRI = 0x00801A28; // RenderTextureD3D primary vtable
|
||||
constexpr uintptr_t V24_RTD3D_DELETING_DTOR = 0x006969D0; // GR-view adjustor thunk → ~RenderTextureD3D + delete
|
||||
// signature: __thiscall (this, int flag); flag=1 means operator delete
|
||||
// Field offsets relative to GR-view this (= primary + 0x30) for predicate:
|
||||
// m_p2DTextureD3D primary+0x98 => GR_view+0x68
|
||||
// m_pCubeTextureD3D primary+0x9C => GR_view+0x6C
|
||||
// m_D3DSurfaces.m_data primary+0xA0 => GR_view+0x70
|
||||
|
||||
} // namespace ac
|
||||
103
dll/leakfix/src/dllmain.cpp
Normal file
103
dll/leakfix/src/dllmain.cpp
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// dllmain.cpp — leakfix.dll entry point
|
||||
//
|
||||
// iter-5 (2026-05-20): patch application is deferred to a worker
|
||||
// thread that sleeps ~30 seconds before applying. This matches the
|
||||
// timing of the Python runtime patcher (tools/fleet_monitor.sh),
|
||||
// which lands its patches well after Decal init is complete. The
|
||||
// PE-import-load → DllMain → immediate apply_all_patches sequence
|
||||
// used in iter-1..iter-4 lost the race with Decal's own hook
|
||||
// installation and crashed some accounts (Unkle Leo most reliably).
|
||||
// See feedback_dll_load_order_conflict.md.
|
||||
//
|
||||
// The SEH crash handler is still installed immediately so any
|
||||
// crashes during the 30s window (including ones caused by Decal)
|
||||
// are captured.
|
||||
#include <windows.h>
|
||||
#include "logging.h"
|
||||
#include "patches.h"
|
||||
#include "instr.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool g_skip_patches = false;
|
||||
|
||||
DWORD WINAPI deferred_patch_thread(LPVOID) {
|
||||
// Give Decal / UtilityBelt time to finish their own hook
|
||||
// installation before we lay our patches on top. 30s matches
|
||||
// the Python cascade's observed-good timing.
|
||||
Sleep(30000);
|
||||
leakfix::logf("deferred-patch thread: woke after 30s, applying patches");
|
||||
if (g_skip_patches) {
|
||||
leakfix::logf("LEAKFIX_NO_PATCHES=1 — skipping patch application (diagnostic mode)");
|
||||
} else {
|
||||
leakfix::apply_all_patches();
|
||||
}
|
||||
leakfix::instr_start_periodic_scan();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void on_attach() {
|
||||
char dll_path[MAX_PATH] = {0};
|
||||
GetModuleFileNameA((HMODULE)GetModuleHandleA("leakfix.dll"), dll_path, MAX_PATH);
|
||||
|
||||
// Log next to the DLL itself
|
||||
char log_path[MAX_PATH] = {0};
|
||||
char* slash = nullptr;
|
||||
for (char* p = dll_path; *p; ++p) if (*p == '\\' || *p == '/') slash = p;
|
||||
if (slash) {
|
||||
size_t prefix = (size_t)(slash - dll_path) + 1;
|
||||
memcpy(log_path, dll_path, prefix);
|
||||
strcpy(log_path + prefix, "leakfix.log");
|
||||
} else {
|
||||
strcpy(log_path, "leakfix.log");
|
||||
}
|
||||
leakfix::log_init(log_path);
|
||||
leakfix::logf("attach: dll=%s (iter-5 deferred-patch)", dll_path);
|
||||
|
||||
// Kill switch — set LEAKFIX_NO_PATCHES=1 in env to skip patch
|
||||
// application. Instrumentation still runs. Used to bisect crashes:
|
||||
// if the no-patches variant survives, the patches are the trigger.
|
||||
char no_patches[8] = {0};
|
||||
GetEnvironmentVariableA("LEAKFIX_NO_PATCHES", no_patches, sizeof(no_patches));
|
||||
g_skip_patches = (no_patches[0] == '1');
|
||||
|
||||
// Crash handler installed immediately so the 30s pre-patch window
|
||||
// is still observable if Decal/UB crashes the process.
|
||||
leakfix::instr_install_crash_handler();
|
||||
|
||||
HANDLE h = CreateThread(nullptr, 0, deferred_patch_thread, nullptr, 0, nullptr);
|
||||
if (h) {
|
||||
CloseHandle(h);
|
||||
leakfix::logf("deferred-patch thread spawned");
|
||||
} else {
|
||||
// CreateThread failure is extraordinary — fall back to the
|
||||
// old in-DllMain apply so we at least get patches eventually.
|
||||
leakfix::logf("CreateThread failed (err=%lu) — falling back to in-DllMain apply",
|
||||
GetLastError());
|
||||
if (!g_skip_patches) leakfix::apply_all_patches();
|
||||
leakfix::instr_start_periodic_scan();
|
||||
}
|
||||
}
|
||||
} // anon
|
||||
|
||||
// Exported stub so PE-import-table patching of acclient.exe can name
|
||||
// a real symbol for the OS loader to resolve. Doing nothing is fine —
|
||||
// just being present in the DLL is what makes the import valid.
|
||||
extern "C" __declspec(dllexport) int __cdecl leakfix_init() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID) {
|
||||
switch (reason) {
|
||||
case DLL_PROCESS_ATTACH:
|
||||
DisableThreadLibraryCalls(h);
|
||||
on_attach();
|
||||
break;
|
||||
case DLL_PROCESS_DETACH:
|
||||
leakfix::instr_stop_periodic_scan();
|
||||
leakfix::logf("detach");
|
||||
leakfix::log_close();
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
85
dll/leakfix/src/instr.cpp
Normal file
85
dll/leakfix/src/instr.cpp
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
// instr.cpp — crash handler only (production build)
|
||||
//
|
||||
// Earlier revisions of this file also contained periodic scan code,
|
||||
// D3D9 lifecycle tracking, region/orphan/heap diagnostics (v25-v38).
|
||||
// All of that was investigation tooling and has been stripped from the
|
||||
// production DLL — they did not change runtime behavior, only emitted
|
||||
// log lines, but they added code surface for no benefit once the
|
||||
// d3d9-internal-pool conclusion was reached (see REPORT.md §10).
|
||||
//
|
||||
// What remains:
|
||||
// - instr_install_crash_handler() — installs SetUnhandledExceptionFilter
|
||||
// so any unhandled native exception writes a minidump next to the DLL.
|
||||
// - instr_start_periodic_scan() / instr_stop_periodic_scan() — stubs
|
||||
// kept for source-compatibility with dllmain.cpp. No-op.
|
||||
//
|
||||
// Everything else from the investigation phase is in git history.
|
||||
|
||||
#include "instr.h"
|
||||
#include "logging.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <dbghelp.h>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#pragma comment(lib, "dbghelp.lib")
|
||||
|
||||
namespace {
|
||||
|
||||
LPTOP_LEVEL_EXCEPTION_FILTER g_prev_filter = nullptr;
|
||||
|
||||
void get_dll_dir(char* out, size_t out_sz) {
|
||||
HMODULE h = nullptr;
|
||||
GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
(LPCSTR)&get_dll_dir, &h);
|
||||
char path[MAX_PATH] = {0};
|
||||
GetModuleFileNameA(h, path, MAX_PATH);
|
||||
size_t n = strlen(path);
|
||||
while (n > 0 && path[n-1] != '\\' && path[n-1] != '/') --n;
|
||||
if (n >= out_sz) n = out_sz - 1;
|
||||
memcpy(out, path, n);
|
||||
out[n] = '\0';
|
||||
}
|
||||
|
||||
LONG WINAPI top_level_handler(EXCEPTION_POINTERS* ep) {
|
||||
char dir[MAX_PATH]; get_dll_dir(dir, sizeof(dir));
|
||||
SYSTEMTIME st; GetLocalTime(&st);
|
||||
char path[MAX_PATH];
|
||||
std::snprintf(path, sizeof(path),
|
||||
"%sleakfix_crash_%lu_%04d%02d%02d_%02d%02d%02d.dmp",
|
||||
dir, GetCurrentProcessId(),
|
||||
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
|
||||
|
||||
leakfix::logf("UNHANDLED EXCEPTION code=0x%08lx addr=0x%p — writing %s",
|
||||
ep->ExceptionRecord->ExceptionCode,
|
||||
ep->ExceptionRecord->ExceptionAddress, path);
|
||||
|
||||
HANDLE hf = CreateFileA(path, GENERIC_WRITE, 0, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (hf != INVALID_HANDLE_VALUE) {
|
||||
MINIDUMP_EXCEPTION_INFORMATION mei = { GetCurrentThreadId(), ep, FALSE };
|
||||
MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(),
|
||||
hf, MiniDumpNormal, &mei, nullptr, nullptr);
|
||||
CloseHandle(hf);
|
||||
}
|
||||
if (g_prev_filter) return g_prev_filter(ep);
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
|
||||
} // anon
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
void instr_install_crash_handler() {
|
||||
g_prev_filter = SetUnhandledExceptionFilter(top_level_handler);
|
||||
logf("instr: crash handler installed");
|
||||
}
|
||||
|
||||
// Stubs — kept for source-compat with dllmain.cpp.
|
||||
void instr_start_periodic_scan() { /* no-op in production */ }
|
||||
void instr_stop_periodic_scan() { /* no-op in production */ }
|
||||
|
||||
} // namespace leakfix
|
||||
19
dll/leakfix/src/instr.h
Normal file
19
dll/leakfix/src/instr.h
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// instr.h — instrumentation features for leakfix.dll
|
||||
#pragma once
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
// Install SetUnhandledExceptionFilter so any unhandled native exception
|
||||
// writes a clean minidump to leakfix_crash_<pid>_<timestamp>.dmp next
|
||||
// to the DLL, then chains to Windows' default handling.
|
||||
void instr_install_crash_handler();
|
||||
|
||||
// Start a background thread that scans memory every 5 minutes,
|
||||
// counts known leak-class vtable instances, and appends a one-line
|
||||
// summary to leakfix.log.
|
||||
void instr_start_periodic_scan();
|
||||
|
||||
// Stop the periodic scan thread (called from DLL_PROCESS_DETACH).
|
||||
void instr_stop_periodic_scan();
|
||||
|
||||
} // namespace leakfix
|
||||
74
dll/leakfix/src/logging.cpp
Normal file
74
dll/leakfix/src/logging.cpp
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#include "logging.h"
|
||||
#include <windows.h>
|
||||
#include <cstdio>
|
||||
#include <cstdarg>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
HANDLE g_log = INVALID_HANDLE_VALUE;
|
||||
CRITICAL_SECTION g_cs;
|
||||
bool g_cs_inited = false;
|
||||
|
||||
void ensure_cs() {
|
||||
if (!g_cs_inited) {
|
||||
InitializeCriticalSection(&g_cs);
|
||||
g_cs_inited = true;
|
||||
}
|
||||
}
|
||||
|
||||
void write_line(const char* s, size_t len) {
|
||||
if (g_log == INVALID_HANDLE_VALUE) return;
|
||||
DWORD written = 0;
|
||||
WriteFile(g_log, s, (DWORD)len, &written, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
void log_init(const char* path) {
|
||||
ensure_cs();
|
||||
EnterCriticalSection(&g_cs);
|
||||
if (g_log != INVALID_HANDLE_VALUE) { LeaveCriticalSection(&g_cs); return; }
|
||||
g_log = CreateFileA(path, FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
SetFilePointer(g_log, 0, nullptr, FILE_END);
|
||||
LeaveCriticalSection(&g_cs);
|
||||
logf("===== leakfix.dll loaded (pid=%lu) =====", GetCurrentProcessId());
|
||||
}
|
||||
|
||||
void log_close() {
|
||||
ensure_cs();
|
||||
EnterCriticalSection(&g_cs);
|
||||
if (g_log != INVALID_HANDLE_VALUE) {
|
||||
CloseHandle(g_log);
|
||||
g_log = INVALID_HANDLE_VALUE;
|
||||
}
|
||||
LeaveCriticalSection(&g_cs);
|
||||
}
|
||||
|
||||
void logf(const char* fmt, ...) {
|
||||
ensure_cs();
|
||||
char buf[1024];
|
||||
SYSTEMTIME st;
|
||||
GetLocalTime(&st);
|
||||
int n = std::snprintf(buf, sizeof(buf),
|
||||
"[%04d-%02d-%02d %02d:%02d:%02d.%03d] ",
|
||||
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds);
|
||||
va_list ap; va_start(ap, fmt);
|
||||
int m = std::vsnprintf(buf + n, sizeof(buf) - n - 2, fmt, ap);
|
||||
va_end(ap);
|
||||
if (m < 0) m = 0;
|
||||
int total = n + m;
|
||||
if (total >= (int)sizeof(buf) - 1) total = sizeof(buf) - 2;
|
||||
buf[total] = '\n';
|
||||
buf[total + 1] = '\0';
|
||||
|
||||
EnterCriticalSection(&g_cs);
|
||||
write_line(buf, (size_t)total + 1);
|
||||
LeaveCriticalSection(&g_cs);
|
||||
|
||||
// Also forward to debugger if attached
|
||||
OutputDebugStringA(buf);
|
||||
}
|
||||
|
||||
} // namespace leakfix
|
||||
8
dll/leakfix/src/logging.h
Normal file
8
dll/leakfix/src/logging.h
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// logging.h — minimal file-based logging for leakfix.dll
|
||||
#pragma once
|
||||
|
||||
namespace leakfix {
|
||||
void log_init(const char* path); // open log file (relative to acclient.exe dir if not absolute)
|
||||
void log_close();
|
||||
void logf(const char* fmt, ...); // appends a timestamped line
|
||||
} // namespace leakfix
|
||||
362
dll/leakfix/src/patches.cpp
Normal file
362
dll/leakfix/src/patches.cpp
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
// patches.cpp — apply v3b, v5, v11, v12, v14 inline to our own process
|
||||
#include "patches.h"
|
||||
#include "logging.h"
|
||||
#include "thunks.h"
|
||||
#include "ac_addrs.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace leakfix;
|
||||
|
||||
namespace {
|
||||
|
||||
// Copy `data` to absolute address `addr`, flipping page protection.
|
||||
bool write_memory(uintptr_t addr, const void* data, size_t len) {
|
||||
DWORD old = 0;
|
||||
if (!VirtualProtect((void*)addr, len, PAGE_EXECUTE_READWRITE, &old)) {
|
||||
logf(" VirtualProtect(0x%08x, %u) failed err=%lu", addr, (unsigned)len, GetLastError());
|
||||
return false;
|
||||
}
|
||||
std::memcpy((void*)addr, data, len);
|
||||
DWORD restored = 0;
|
||||
VirtualProtect((void*)addr, len, old, &restored);
|
||||
FlushInstructionCache(GetCurrentProcess(), (void*)addr, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool bytes_equal(uintptr_t addr, const void* expected, size_t len) {
|
||||
return std::memcmp((void*)addr, expected, len) == 0;
|
||||
}
|
||||
|
||||
void hexdump_short(uintptr_t addr, size_t n, char* out, size_t out_sz) {
|
||||
const uint8_t* p = (const uint8_t*)addr;
|
||||
size_t used = 0;
|
||||
for (size_t i = 0; i < n && used + 3 < out_sz; ++i) {
|
||||
used += (size_t)std::snprintf(out + used, out_sz - used, "%02x", p[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Write a 5-byte JMP rel32 at `at` targeting `target`. Pad remaining bytes
|
||||
// up to `total_replace` with 0x90 NOPs.
|
||||
bool write_jmp_rel32(uintptr_t at, uintptr_t target, size_t total_replace) {
|
||||
uint8_t buf[64];
|
||||
if (total_replace > sizeof(buf)) return false;
|
||||
int32_t rel = (int32_t)(target - (at + 5));
|
||||
buf[0] = 0xE9;
|
||||
std::memcpy(buf + 1, &rel, 4);
|
||||
std::memset(buf + 5, 0x90, total_replace - 5);
|
||||
return write_memory(at, buf, total_replace);
|
||||
}
|
||||
|
||||
} // anon namespace
|
||||
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
// ===== v3b =====
|
||||
bool apply_v3b() {
|
||||
const uint8_t nops[3] = { 0x90, 0x90, 0x90 };
|
||||
const uint8_t orig1[3] = { 0xff, 0x40, 0x24 }; // inc dword [eax+0x24]
|
||||
const uint8_t orig2[3] = { 0xff, 0x46, 0x24 }; // inc dword [esi+0x24]
|
||||
|
||||
if (bytes_equal(ac::V3B_SITE_1, nops, 3) && bytes_equal(ac::V3B_SITE_2, nops, 3)) {
|
||||
logf("v3b: already applied");
|
||||
return true;
|
||||
}
|
||||
if (!bytes_equal(ac::V3B_SITE_1, orig1, 3)) {
|
||||
char h[16]; hexdump_short(ac::V3B_SITE_1, 3, h, sizeof(h));
|
||||
logf("v3b: site1 unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
if (!bytes_equal(ac::V3B_SITE_2, orig2, 3)) {
|
||||
char h[16]; hexdump_short(ac::V3B_SITE_2, 3, h, sizeof(h));
|
||||
logf("v3b: site2 unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
write_memory(ac::V3B_SITE_1, nops, 3);
|
||||
write_memory(ac::V3B_SITE_2, nops, 3);
|
||||
logf("v3b: applied (NOPs at 0x%08x + 0x%08x)", ac::V3B_SITE_1, ac::V3B_SITE_2);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v5 =====
|
||||
bool apply_v5() {
|
||||
uintptr_t rs_cur = *(uintptr_t*)ac::V5_RS_VTABLE_SLOT_2;
|
||||
uintptr_t rt_cur = *(uintptr_t*)ac::V5_RT_VTABLE_SLOT_2;
|
||||
uintptr_t rs_new = (uintptr_t)&purge_rendersurface_thunk;
|
||||
uintptr_t rt_new = (uintptr_t)&purge_rendertexture_thunk;
|
||||
|
||||
bool rs_done = (rs_cur != ac::V5_NOOP_STUB_VA);
|
||||
bool rt_done = (rt_cur != ac::V5_NOOP_STUB_VA);
|
||||
|
||||
if (!rs_done) {
|
||||
if (rs_cur != ac::V5_NOOP_STUB_VA) {
|
||||
logf("v5: RS slot already redirected (0x%08x); not overwriting", rs_cur);
|
||||
} else {
|
||||
write_memory(ac::V5_RS_VTABLE_SLOT_2, &rs_new, 4);
|
||||
logf("v5: RS vtable slot -> 0x%08x", rs_new);
|
||||
}
|
||||
} else {
|
||||
logf("v5: RS slot already non-default (0x%08x) — skipping", rs_cur);
|
||||
}
|
||||
|
||||
if (!rt_done) {
|
||||
write_memory(ac::V5_RT_VTABLE_SLOT_2, &rt_new, 4);
|
||||
logf("v5: RT vtable slot -> 0x%08x", rt_new);
|
||||
} else {
|
||||
logf("v5: RT slot already non-default (0x%08x) — skipping", rt_cur);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v11 =====
|
||||
bool apply_v11() {
|
||||
// Site 1: 2-byte rewrite of a JMP target
|
||||
const uint8_t s1_orig[2] = { 0xEB, 0x07 };
|
||||
const uint8_t s1_patched[2] = { 0xEB, 0x42 };
|
||||
// Site 2: 9-byte rewrite for ~GXTri3Mesh NULL-check
|
||||
const uint8_t s2_orig[9] = { 0x8B, 0x08, 0x50, 0xFF, 0x51, 0x08, 0x89, 0x5E, 0x08 };
|
||||
const uint8_t s2_patched[9] = { 0x89, 0x5E, 0x08, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
|
||||
|
||||
if (bytes_equal(ac::V11_SITE_1_VA, s1_patched, 2)) {
|
||||
logf("v11: site1 already patched");
|
||||
} else if (bytes_equal(ac::V11_SITE_1_VA, s1_orig, 2)) {
|
||||
write_memory(ac::V11_SITE_1_VA, s1_patched, 2);
|
||||
logf("v11: site1 patched");
|
||||
} else {
|
||||
char h[8]; hexdump_short(ac::V11_SITE_1_VA, 2, h, sizeof(h));
|
||||
logf("v11: site1 unexpected %s — skipping", h);
|
||||
}
|
||||
|
||||
if (bytes_equal(ac::V11_SITE_2_VA, s2_patched, 9)) {
|
||||
logf("v11: site2 already patched");
|
||||
} else if (bytes_equal(ac::V11_SITE_2_VA, s2_orig, 9)) {
|
||||
write_memory(ac::V11_SITE_2_VA, s2_patched, 9);
|
||||
logf("v11: site2 patched");
|
||||
} else {
|
||||
char h[24]; hexdump_short(ac::V11_SITE_2_VA, 9, h, sizeof(h));
|
||||
logf("v11: site2 unexpected %s — skipping", h);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v12 RETIRED =====
|
||||
// v12 was designed against post-Decal in-memory bytes that don't match
|
||||
// the on-disk binary. When the leakfix.dll loads at PE-import time (before
|
||||
// Decal init), it sees the truly-original bytes and v12 would refuse.
|
||||
// When the Python patcher ran later against a running PID, it saw
|
||||
// Decal-modified bytes that happened to match its expected pattern and
|
||||
// applied a duplicate range check — adding no protection beyond what
|
||||
// Decal already provides. Neither variant prevented the Shadow/Frank
|
||||
// stale-heap-pointer crashes. v12 removed.
|
||||
|
||||
// ===== v14 =====
|
||||
bool apply_v14() {
|
||||
static const uint8_t ORIG[18] = {
|
||||
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, // mov eax, [esi+0xDC]
|
||||
0x3B, 0xC3, // cmp eax, ebx
|
||||
0x74, 0x08, // jz +8
|
||||
0x8B, 0x00, // mov eax, [eax]
|
||||
0x3B, 0xC3, // cmp eax, ebx
|
||||
0x74, 0x02, // jz +2
|
||||
0x89, 0x18, // mov [eax], ebx <- the broken "fix"
|
||||
};
|
||||
|
||||
// If already patched, the first byte is 0xE9 (our JMP).
|
||||
uint8_t cur = *(uint8_t*)ac::V14_PATCH_SITE_VA;
|
||||
if (cur == 0xE9) {
|
||||
logf("v14: already applied");
|
||||
return true;
|
||||
}
|
||||
if (!bytes_equal(ac::V14_PATCH_SITE_VA, ORIG, 18)) {
|
||||
char h[40]; hexdump_short(ac::V14_PATCH_SITE_VA, 18, h, sizeof(h));
|
||||
logf("v14: site unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
uintptr_t thunk_va = (uintptr_t)&v14_clipplane_cleanup_thunk;
|
||||
if (!write_jmp_rel32(ac::V14_PATCH_SITE_VA, thunk_va, 18)) return false;
|
||||
logf("v14: applied (JMP rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v22 — unpacker stale-pointer SEH guard =====
|
||||
//
|
||||
// Function pointer type matching the unpacker's ABI:
|
||||
// __thiscall (this=ecx, arg1=stack, count=stack), ret 8.
|
||||
// We declare as __fastcall with a dummy edx param so MSVC emits the
|
||||
// right calling convention.
|
||||
typedef int (__fastcall *v22_unpacker_fn_t)(void* self, void* edx_unused, void* arg1, int count);
|
||||
|
||||
// Pointer to the relocated copy of the original 73 bytes.
|
||||
static v22_unpacker_fn_t g_v22_original_copy = nullptr;
|
||||
|
||||
// Wrapper that runs the original (via the relocated copy) inside SEH.
|
||||
// On AV: log + return 0 (engine's existing failure path).
|
||||
extern "C" int __fastcall v22_unpacker_wrapper(void* self, void* edx,
|
||||
void* arg1, int count) {
|
||||
__try {
|
||||
return g_v22_original_copy(self, edx, arg1, count);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
static volatile LONG s_caught = 0;
|
||||
LONG n = InterlockedIncrement(&s_caught);
|
||||
// Throttle logging: first 5, then every 256th
|
||||
if (n <= 5 || (n & 0xFF) == 0) {
|
||||
logf("v22: caught AV in unpacker (total caught=%ld) — returning 0", n);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
bool apply_v22() {
|
||||
// If already patched (first byte = 0xE9 JMP), bail.
|
||||
uint8_t cur = *(uint8_t*)ac::V22_UNPACKER_VA;
|
||||
if (cur == 0xE9) {
|
||||
logf("v22: already applied");
|
||||
return true;
|
||||
}
|
||||
// Expected original first byte: 0x83 (cmp dword ptr [esp+8], 0x10)
|
||||
if (cur != 0x83) {
|
||||
char h[16]; hexdump_short(ac::V22_UNPACKER_VA, 5, h, sizeof(h));
|
||||
logf("v22: site unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allocate executable memory for the copy.
|
||||
void* copy = VirtualAlloc(NULL, ac::V22_UNPACKER_LEN,
|
||||
MEM_COMMIT | MEM_RESERVE,
|
||||
PAGE_EXECUTE_READWRITE);
|
||||
if (!copy) {
|
||||
logf("v22: VirtualAlloc failed err=%lu", GetLastError());
|
||||
return false;
|
||||
}
|
||||
std::memcpy(copy, (void*)ac::V22_UNPACKER_VA, ac::V22_UNPACKER_LEN);
|
||||
FlushInstructionCache(GetCurrentProcess(), copy, ac::V22_UNPACKER_LEN);
|
||||
g_v22_original_copy = (v22_unpacker_fn_t)copy;
|
||||
|
||||
// Patch the original entry with JMP rel32 to wrapper.
|
||||
uintptr_t wrapper_va = (uintptr_t)&v22_unpacker_wrapper;
|
||||
uint8_t patch[5];
|
||||
int32_t rel = (int32_t)(wrapper_va - (ac::V22_UNPACKER_VA + 5));
|
||||
patch[0] = 0xE9;
|
||||
std::memcpy(patch + 1, &rel, 4);
|
||||
if (!write_memory(ac::V22_UNPACKER_VA, patch, 5)) {
|
||||
logf("v22: write_memory failed");
|
||||
return false;
|
||||
}
|
||||
logf("v22: applied (JMP rel32 -> 0x%08x, copy at 0x%p)", wrapper_va, copy);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v23 — CPhysicsObj orphan-creation hook =====
|
||||
//
|
||||
// At 0x00514043 inside CPhysicsObj::unparent_children, the engine has a
|
||||
// 7-byte instruction `mov dword ptr [esi+0x40], 0` that nulls a child's
|
||||
// parent pointer. esi at that point holds the child CPhysicsObj* (primary
|
||||
// view). We replace those 7 bytes with `e8 [rel32 to thunk] 90 90` — a
|
||||
// 5-byte CALL to our v23_orphan_hook_thunk + 2 NOPs.
|
||||
//
|
||||
// The thunk preserves all regs/flags, invokes log_enqueue_orphan_child(esi),
|
||||
// performs the original `mov [esi+0x40], 0` write, and returns. The thunk
|
||||
// gates actual destruction-queue enqueue on a file flag — default off.
|
||||
bool apply_v23() {
|
||||
// Expected original bytes: c7 46 40 00 00 00 00 (mov dword ptr [esi+0x40], 0)
|
||||
static const uint8_t ORIG[7] = { 0xC7, 0x46, 0x40, 0x00, 0x00, 0x00, 0x00 };
|
||||
|
||||
// If first byte is 0xE8 (CALL), we already applied this patch.
|
||||
uint8_t cur = *(uint8_t*)ac::V23_PATCH_SITE_VA;
|
||||
if (cur == 0xE8) {
|
||||
logf("v23: already applied");
|
||||
return true;
|
||||
}
|
||||
if (!bytes_equal(ac::V23_PATCH_SITE_VA, ORIG, 7)) {
|
||||
char h[24]; hexdump_short(ac::V23_PATCH_SITE_VA, 7, h, sizeof(h));
|
||||
logf("v23: site unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
|
||||
uintptr_t thunk_va = (uintptr_t)&v23_orphan_hook_thunk;
|
||||
// Build: e8 [rel32] 90 90 (7 bytes total)
|
||||
uint8_t buf[7];
|
||||
int32_t rel = (int32_t)(thunk_va - (ac::V23_PATCH_SITE_VA + 5));
|
||||
buf[0] = 0xE8;
|
||||
std::memcpy(buf + 1, &rel, 4);
|
||||
buf[5] = 0x90;
|
||||
buf[6] = 0x90;
|
||||
if (!write_memory(ac::V23_PATCH_SITE_VA, buf, 7)) return false;
|
||||
logf("v23: applied (CALL rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
|
||||
|
||||
// === v23b — same hook on unset_parent ===
|
||||
// unset_parent's parent-NULL write is at 0x00513FAC with identical
|
||||
// 7-byte instruction `mov [esi+0x40], 0` and esi=child. Reuse the
|
||||
// same thunk; rel32 is recomputed for the new call site.
|
||||
uint8_t cur_b = *(uint8_t*)ac::V23B_PATCH_SITE_VA;
|
||||
if (cur_b == 0xE8) {
|
||||
logf("v23b: already applied");
|
||||
} else if (!bytes_equal(ac::V23B_PATCH_SITE_VA, ORIG, 7)) {
|
||||
char h[24]; hexdump_short(ac::V23B_PATCH_SITE_VA, 7, h, sizeof(h));
|
||||
logf("v23b: site unexpected bytes %s — leaving alone (v23 already applied)", h);
|
||||
} else {
|
||||
uint8_t bufB[7];
|
||||
int32_t relB = (int32_t)(thunk_va - (ac::V23B_PATCH_SITE_VA + 5));
|
||||
bufB[0] = 0xE8;
|
||||
std::memcpy(bufB + 1, &relB, 4);
|
||||
bufB[5] = 0x90;
|
||||
bufB[6] = 0x90;
|
||||
if (write_memory(ac::V23B_PATCH_SITE_VA, bufB, 7)) {
|
||||
logf("v23b: applied (CALL rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v20 — RenderSurfaceD3D PurgeResource -> Destroy =====
|
||||
bool apply_v20() {
|
||||
uintptr_t cur = *(uintptr_t*)ac::V20_RSD3D_GR_VTABLE_SLOT_2;
|
||||
if (cur != ac::V20_RSD3D_PURGE_VA) {
|
||||
// Could be already-redirected (re-apply) or unexpected — log and skip
|
||||
uintptr_t thunk_va = (uintptr_t)&purge_rendersurfaced3d_thunk;
|
||||
if (cur == thunk_va) {
|
||||
logf("v20: already redirected to our thunk (0x%08x)", cur);
|
||||
} else {
|
||||
logf("v20: slot has unexpected value (0x%08x, expected 0x%08x) — skipping",
|
||||
cur, ac::V20_RSD3D_PURGE_VA);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
uintptr_t thunk_va = (uintptr_t)&purge_rendersurfaced3d_thunk;
|
||||
if (!write_memory(ac::V20_RSD3D_GR_VTABLE_SLOT_2, &thunk_va, 4)) return false;
|
||||
logf("v20: RSD3D slot 2 -> 0x%08x (was 0x%08x = RenderSurfaceD3D::PurgeResource)",
|
||||
thunk_va, cur);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool apply_all_patches() {
|
||||
bool ok = true;
|
||||
ok &= apply_v3b();
|
||||
ok &= apply_v11();
|
||||
ok &= apply_v5();
|
||||
ok &= apply_v14();
|
||||
ok &= apply_v22();
|
||||
// v23/v23b — CPhysicsObj orphan-creation hook — DISABLED.
|
||||
// Built and probed 2026-05-21. Captured 240K+ events in soak but
|
||||
// probe of 50h heavy-looter (Elliot/3872) found only 2 instances
|
||||
// would actually pass the safe-destroy predicates — the rest are
|
||||
// inventory items at rest. The "CPhysicsObj-family leak" was a
|
||||
// misreading of normal inventory state. Same outcome as v20.
|
||||
// ok &= apply_v23();
|
||||
// v20 — RenderSurfaceD3D PurgeResource->Destroy redirect — DISABLED.
|
||||
// Shipped 2026-05-21 and proved inert: RSD3D is pure-GPU and never
|
||||
// allocates the CPU-side buffers (m_pSurfaceBits, sourceData inner ptr)
|
||||
// that RS::Destroy would free. v20's added work over the original
|
||||
// PurgeResource is zero bytes. Code retained (apply_v20 / thunk / addrs)
|
||||
// for revival if a non-buffer-based cleanup approach is designed.
|
||||
// See: project_v20_inert_outcome.md
|
||||
// ok &= apply_v20();
|
||||
logf("all-patches result: %s", ok ? "OK" : "PARTIAL");
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace leakfix
|
||||
19
dll/leakfix/src/patches.h
Normal file
19
dll/leakfix/src/patches.h
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
#pragma once
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
// Returns true if all patches applied (or were already in place).
|
||||
bool apply_all_patches();
|
||||
|
||||
bool apply_v3b();
|
||||
bool apply_v5();
|
||||
bool apply_v11();
|
||||
bool apply_v14();
|
||||
bool apply_v20();
|
||||
bool apply_v22();
|
||||
bool apply_v23();
|
||||
// v12 retired: it was a duplicate of Decal's built-in unpacker range
|
||||
// check and didn't address the actual Shadow/Frank crash class
|
||||
// (stale-heap-pointer in cursor). See memory.
|
||||
|
||||
} // namespace leakfix
|
||||
222
dll/leakfix/src/sweep_design.md
Normal file
222
dll/leakfix/src/sweep_design.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# Iter 4 — CPhysicsObj sweep design (DRAFT, NOT YET IMPLEMENTED)
|
||||
|
||||
## Goal
|
||||
|
||||
Periodically destroy abandoned CPhysicsObj instances to recover the
|
||||
residual leak documented in §6.1 of REPORT.md. **Highest-risk patch
|
||||
class** (physics-state mutation, same risk profile as v13 which
|
||||
killed Larsson at 98 min). Long soak per change is mandatory.
|
||||
|
||||
## What iter 3 told us
|
||||
|
||||
After 13 minutes on Unkle Leo (PID 16044), a typical scan shows:
|
||||
|
||||
```
|
||||
total=971 no_parent=546 no_cell=278 orphan_hash=697 both=234 triple=111
|
||||
```
|
||||
|
||||
So ~11% of all CPhysicsObj instances pass the strict triple predicate.
|
||||
On a fresh client triple count is ~100 (startup residual). Growth is
|
||||
+1-2 candidates per minute during normal play.
|
||||
|
||||
Strict-candidate sample dumps confirm:
|
||||
- `parent`, `cell`, `hash_next` all NULL ✓
|
||||
- `part_array` non-NULL (heap allocation that should be freed)
|
||||
- `shadow_objects.data` non-NULL (heap allocation that should be freed)
|
||||
- `state` has small bits set (e.g., 0x00000414 — normal active flags)
|
||||
|
||||
This matches the v17 owner-vtable diagnostic's "abandoned but heap state
|
||||
still allocated" pattern.
|
||||
|
||||
## Candidate destruction call
|
||||
|
||||
The engine already has correct teardown:
|
||||
|
||||
```c
|
||||
// EoR 0x005145D0 — CPhysicsObj::Destroy
|
||||
void __thiscall CPhysicsObj::Destroy(CPhysicsObj* this);
|
||||
```
|
||||
|
||||
Per the v17 owner-diag, `CPhysicsObj::Destroy` correctly tears down
|
||||
all owned heap state (`CPartArray::DestroyParts`, etc.). The leak is
|
||||
that it's never **called** on these abandoned objects.
|
||||
|
||||
After Destroy, the CPhysicsObj itself (~408 bytes) needs to be freed
|
||||
via `operator delete`.
|
||||
|
||||
## Predicate hardening (BEFORE we destroy anything)
|
||||
|
||||
The triple predicate may not be conservative enough. Additional
|
||||
checks before destroy:
|
||||
|
||||
1. **`update_time` is stale** — field at +0xD4 is a long double
|
||||
(timestamp). If less than `now() - 60s`, the object hasn't been
|
||||
touched in a minute. Compare via TimeGetTime() or similar global.
|
||||
2. **`state` is not "currently active"** — need to identify which
|
||||
bits indicate "being processed." For now, skip if state has any
|
||||
high bit set.
|
||||
3. **`weenie_obj == NULL`** — at +0x?? (need to verify offset).
|
||||
If a weenie-object still owns this physobj, the engine considers
|
||||
it alive even if other tracking is gone.
|
||||
4. **`movement_manager == NULL`** — at +0xC4 per acclient.h
|
||||
(LongHashData base 12 + ... + 0xB8 should be it). If there's an
|
||||
active mover, the object is in flight.
|
||||
5. **`hooks == NULL`** — at +0xE? — animation hooks pending.
|
||||
|
||||
The candidate must pass ALL these AND the iter-3 triple predicate.
|
||||
Stricter than iter 3.
|
||||
|
||||
## Safety protocol
|
||||
|
||||
1. **Throttle:** max 1 destruction per scan cycle (5 min). Even if 100
|
||||
candidates qualify, destroy ONE per scan. Surface latent bugs slowly.
|
||||
2. **Sample-first:** for the first 2 hours, LOG candidate addresses
|
||||
but do NOT destroy. Verify the candidates stay candidates over
|
||||
multiple scans (i.e., they're not transient).
|
||||
3. **Per-scan budget:** if a destruction succeeds, log address +
|
||||
pre-destroy field dump. If process crashes after, we have the last
|
||||
destroyed object for forensics.
|
||||
4. **Kill switch:** check `LEAKFIX_NO_SWEEP=1` env var at scan start.
|
||||
If set, skip destruction. Default ON (=destroy) once code lands.
|
||||
5. **Initial test target:** Unkle Leo (current designated guinea pig
|
||||
per CLAUDE.md). One client only. 4-hour soak before declaring safe.
|
||||
6. **Failure recovery:** if Unkle Leo crashes within 1 hour of
|
||||
destruction logic enabling, set the env var, restart with sweep
|
||||
disabled, mark iter-4 as failed in memory, do not retry without
|
||||
redesign.
|
||||
|
||||
## Implementation outline (when ready)
|
||||
|
||||
```cpp
|
||||
struct CPhysicsObj {
|
||||
void* vtable; // +0x00
|
||||
void* hash_next; // +0x04
|
||||
uint32_t id; // +0x08
|
||||
void* netblob_list; // +0x0C
|
||||
void* part_array; // +0x10
|
||||
// ... 12 bytes of player_vector/distance/CYpt
|
||||
void* sound_table; // +0x28
|
||||
uint32_t pad_exam; // +0x2C
|
||||
void* script_manager; // +0x30
|
||||
void* physics_script; // +0x34
|
||||
uint32_t default_script; // +0x38
|
||||
float script_intensity;// +0x3C
|
||||
void* parent; // +0x40
|
||||
void* children; // +0x44
|
||||
char position[72]; // +0x48
|
||||
void* cell; // +0x90
|
||||
uint32_t num_shadow; // +0x94
|
||||
char shadow_arr[16]; // +0x98 — DArray
|
||||
uint32_t state; // +0xA8
|
||||
uint32_t transient_state; // +0xAC
|
||||
// ... floats
|
||||
void* movement_manager;// +0xC4
|
||||
void* position_manager;// +0xC8
|
||||
int last_move_auto; // +0xCC
|
||||
int jumped_frame; // +0xD0
|
||||
double update_time; // +0xD4 (8 bytes)
|
||||
// ...
|
||||
void* weenie_obj; // +0x?? TBD
|
||||
};
|
||||
|
||||
typedef void (__fastcall *destroy_fn_t)(CPhysicsObj* self, void* edx);
|
||||
constexpr destroy_fn_t CPHYSICSOBJ_DESTROY = (destroy_fn_t)0x005145D0;
|
||||
constexpr void* OP_DELETE = (void*)0x005DF15E;
|
||||
|
||||
bool is_truly_abandoned(CPhysicsObj* p) {
|
||||
if (p->parent) return false;
|
||||
if (p->cell) return false;
|
||||
if (p->hash_next) return false;
|
||||
if (p->movement_manager) return false;
|
||||
// state mask: bits 0..15 are flags we tolerate; high bits suggest
|
||||
// active processing
|
||||
if ((p->state & 0xFFFF0000) != 0) return false;
|
||||
if (p->weenie_obj) return false; // need offset verified
|
||||
// update_time stale check
|
||||
double now = get_engine_time(); // need to find this — e.g., 0x????
|
||||
if (now - p->update_time < 60.0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void sweep_once() {
|
||||
if (env_skip_sweep()) return;
|
||||
// Walk all CPhysicsObj instances...
|
||||
CPhysicsObj* victim = nullptr;
|
||||
for (each CPhysicsObj p) {
|
||||
if (is_truly_abandoned(p)) { victim = p; break; } // ONLY ONE
|
||||
}
|
||||
if (!victim) return;
|
||||
|
||||
logf("SWEEP destroying CPhysicsObj @ 0x%p (state=0x%08x)", victim, victim->state);
|
||||
dump_physobj((uintptr_t)victim); // pre-destroy forensics
|
||||
__try {
|
||||
CPHYSICSOBJ_DESTROY(victim, 0);
|
||||
((void(__fastcall*)(void*, void*))OP_DELETE)(victim, 0);
|
||||
logf("SWEEP ok");
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
logf("SWEEP exception — abandoning sweep this scan");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Known unknowns to resolve before coding
|
||||
|
||||
1. **Engine time global address** — for the stale-`update_time` check
|
||||
2. **`weenie_obj` offset** — need to read acclient.h carefully or sample dumps
|
||||
3. **State-bit meanings** — which bits indicate "in active processing"
|
||||
4. **Does `operator delete` of a CPhysicsObj that already had Destroy() called work?** —
|
||||
Destroy probably tears down state but may not free `this`.
|
||||
5. **What if the object is mid-iteration in some other code?** —
|
||||
destroying it would leave dangling iterators. Need to check the
|
||||
render loop / update loop doesn't have outstanding refs.
|
||||
|
||||
These are NOT minor — getting any wrong = v13-class crash.
|
||||
|
||||
## Recommended path
|
||||
|
||||
1. **Iter 4a (logging-only):** add the harder predicates (`movement_manager`,
|
||||
`weenie_obj`, `update_time` stale, state mask). Log candidate count
|
||||
passing the harder set. Compare to iter-3 triple count. If much
|
||||
smaller, predicates are stricter and we have higher confidence.
|
||||
2. **Iter 4b (sample-first):** dump 3 candidates that pass the hard
|
||||
set every scan. Verify they look genuinely abandoned across multiple
|
||||
scans.
|
||||
3. **Iter 4c (destroy 1 per hour, not per scan):** initial mutation
|
||||
test at the slowest possible rate. Soak 8h+ before declaring safe.
|
||||
4. **Iter 4d (destroy N per scan, where N = current candidate count):**
|
||||
only after 4c passes 24h soak.
|
||||
|
||||
This is a 3-day minimum process if everything goes right. If a v13-class
|
||||
crash happens anywhere, restart from 4a with a redesigned predicate.
|
||||
|
||||
## Decision gate
|
||||
|
||||
Per the soak data on Unkle Leo:
|
||||
- triple candidate growth: ~5/5min = 1/min
|
||||
- After 1 hour without sweep: ~60 abandoned physobjs added
|
||||
- After 24h: ~1440 abandoned
|
||||
- At ~1KB heap state per physobj: ~1.4 MB/day from this exact predicate
|
||||
|
||||
Compare to the agent's CObjCell-family estimate of 7-8 MB/hr. The
|
||||
triple subset is much smaller than the agent's total. The harder
|
||||
predicates will be smaller still.
|
||||
|
||||
**Question for the decision-maker (the human):** is recovering
|
||||
~1-2 MB/day per active client worth a v13-class risk? Given the
|
||||
project's 5-day soak target is already met without iter 4, **the
|
||||
honest answer is probably NO** — iter 4 buys marginal improvement
|
||||
at meaningful risk.
|
||||
|
||||
If the goal is 10-day uptime for heavy looters, iter 4 might help
|
||||
but the residual is dominated by other classes (CObjCell, gm*UI
|
||||
recycle pool, palette outside v3b's scope).
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Defer iter 4 indefinitely.** Iter 3 instrumentation gives us data
|
||||
to argue for or against. The DLL form's basic patches (v3b/v5/v11/v14)
|
||||
are what produces the soak win. Adding sweep is high-risk,
|
||||
low-marginal-reward.
|
||||
|
||||
Keep this document for future reference if a future analyst decides
|
||||
the residual leak warrants the risk.
|
||||
121
dll/leakfix/src/thunks.cpp
Normal file
121
dll/leakfix/src/thunks.cpp
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
// thunks.cpp — runtime replacements called by AC into our DLL
|
||||
//
|
||||
// Production build: only the v5 / v14 / v20 / v23 thunks remain.
|
||||
// v25 / v27 / v29 D3D9 instrumentation wrappers were removed once the
|
||||
// d3d9-internal-pool investigation concluded (see REPORT.md §10).
|
||||
#include "thunks.h"
|
||||
#include "ac_addrs.h"
|
||||
#include <windows.h>
|
||||
#include <excpt.h>
|
||||
#include <intrin.h>
|
||||
|
||||
// ===== v5 — replacement PurgeResource for RenderSurface / RenderTexture =====
|
||||
//
|
||||
// Vtable slots use thiscall (ECX = this). MSVC __fastcall(arg1, arg2)
|
||||
// receives arg1 in ECX and arg2 in EDX. EDX is scratch from the caller
|
||||
// and isn't used, so we make it an unused parameter.
|
||||
//
|
||||
// Effect: instead of the no-op stub `mov al,1; ret`, we now actually
|
||||
// call Destroy() on the resource (frees its D3D handle + heap state)
|
||||
// then return 1 so PurgeOldResources marks it cleanly purged.
|
||||
|
||||
typedef void (__fastcall *destroy_fn_t)(void* self, void* edx_unused);
|
||||
|
||||
extern "C" int __fastcall purge_rendersurface_thunk(void* self, void* /*edx*/) {
|
||||
((destroy_fn_t)ac::V5_RS_DESTROY_VA)(self, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
extern "C" int __fastcall purge_rendertexture_thunk(void* self, void* /*edx*/) {
|
||||
((destroy_fn_t)ac::V5_RT_DESTROY_VA)(self, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ===== v20 — RenderSurfaceD3D PurgeResource -> Destroy =====
|
||||
// Engine dispatches via 0x00801A94 slot 2 with GR-view this (s_Resources
|
||||
// stores GR-view pointers — vfptr at offset 0x00 of the entry IS the GR
|
||||
// vtable). RenderSurfaceD3D::Destroy at 0x00696EB0 was compiled expecting
|
||||
// PRIMARY this, so we adjust ecx by -0x30 before calling Destroy.
|
||||
// Currently disabled in apply_all_patches — retained for revival.
|
||||
extern "C" int __fastcall purge_rendersurfaced3d_thunk(void* gr_view, void* /*edx*/) {
|
||||
void* primary = (char*)gr_view - 0x30;
|
||||
((destroy_fn_t)ac::V20_RSD3D_DESTROY_VA)(primary, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ===== v14 — CEnvCell::Destroy ClipPlaneList cleanup =====
|
||||
//
|
||||
// EoR's CEnvCell::Destroy contains an 18-byte cleanup block at
|
||||
// 0x0052E661 that only zeros cplane_num without freeing the underlying
|
||||
// ClipPlaneList object. We replace those 18 bytes with a 5-byte
|
||||
// JMP rel32 into the naked thunk below + 13 NOPs.
|
||||
//
|
||||
// Register context at entry (preserved from caller):
|
||||
// esi = `this` (CEnvCell)
|
||||
// ebx = 0 (cleared earlier in Destroy — relied on by the original
|
||||
// buggy `mov [eax], ebx`)
|
||||
// edi/ebp = live in surrounding loop
|
||||
//
|
||||
// On exit, we JMP to V14_RESUME_VA (the instruction immediately after
|
||||
// the 18-byte block).
|
||||
|
||||
extern "C" __declspec(naked) void v14_clipplane_cleanup_thunk() {
|
||||
__asm {
|
||||
pushad ; preserve everything
|
||||
mov edi, [esi + 0xDC] ; outer ClipPlaneList wrapper ptr
|
||||
test edi, edi
|
||||
jz done
|
||||
mov ecx, [edi] ; inner ClipPlaneList ptr
|
||||
test ecx, ecx
|
||||
jz free_outer
|
||||
// Free the inner ClipPlaneList properly
|
||||
push ecx
|
||||
mov eax, ac::V14_CLIPPLANELIST_DTOR
|
||||
call eax ; ClipPlaneList::~ClipPlaneList (thiscall)
|
||||
pop ecx
|
||||
push ecx
|
||||
mov eax, ac::V14_OPERATOR_DELETE
|
||||
call eax ; operator delete(inner)
|
||||
add esp, 4
|
||||
free_outer:
|
||||
push edi
|
||||
mov eax, ac::V14_OPERATOR_DELETE_ARR
|
||||
call eax ; operator delete[](outer)
|
||||
add esp, 4
|
||||
mov dword ptr [esi + 0xDC], 0 ; clear back-pointer
|
||||
done:
|
||||
popad
|
||||
push ac::V14_RESUME_VA ; jmp to resume point
|
||||
ret
|
||||
}
|
||||
}
|
||||
|
||||
// ===== v23 — CPhysicsObj orphan-creation hook =====
|
||||
//
|
||||
// Currently disabled in apply_all_patches. Kept for source completeness.
|
||||
// Logs every orphan-creation event; would call log_enqueue_orphan_child
|
||||
// to feed AC's safe destruction queue if re-enabled with a stricter
|
||||
// predicate (see feedback_v19_inventory_destruction_bug.md for why the
|
||||
// current iter-3 triple is unsafe).
|
||||
|
||||
extern "C" void __cdecl log_enqueue_orphan_child(void* /*child*/) {
|
||||
// No-op in production build. Earlier versions counted events into
|
||||
// a histogram + optionally invoked AC's AddObjectToBeDestroyed.
|
||||
}
|
||||
|
||||
extern "C" __declspec(naked) void v23_orphan_hook_thunk() {
|
||||
__asm {
|
||||
pushad
|
||||
pushfd
|
||||
push esi // arg = child CPhysicsObj* (primary view)
|
||||
mov eax, log_enqueue_orphan_child
|
||||
call eax
|
||||
add esp, 4
|
||||
popfd
|
||||
popad
|
||||
// Now perform the original instruction we displaced:
|
||||
// mov dword ptr [esi+0x40], 0
|
||||
mov dword ptr [esi+0x40], 0
|
||||
ret
|
||||
}
|
||||
}
|
||||
28
dll/leakfix/src/thunks.h
Normal file
28
dll/leakfix/src/thunks.h
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// thunks.h — replacement functions called by patched code paths
|
||||
#pragma once
|
||||
|
||||
extern "C" {
|
||||
|
||||
// v5 replacement vtable slot 2 functions. __thiscall so vtable call ABI matches.
|
||||
int __fastcall purge_rendersurface_thunk(void* self, void* /*edx_unused*/);
|
||||
int __fastcall purge_rendertexture_thunk(void* self, void* /*edx_unused*/);
|
||||
|
||||
// v20 replacement for RenderSurfaceD3D PurgeResource (slot 2 of 0x00801A94).
|
||||
// Adjusts ecx from GR-view to primary before calling Destroy. (Currently
|
||||
// disabled in apply_all_patches — kept for source completeness.)
|
||||
int __fastcall purge_rendersurfaced3d_thunk(void* self, void* /*edx_unused*/);
|
||||
|
||||
// v14 — naked thunk JMPed to from 0x0052E661.
|
||||
// Saves regs, frees inner ClipPlaneList, frees outer wrapper, clears the
|
||||
// back-pointer at [esi+0xDC], restores regs, jumps to V14_RESUME_VA.
|
||||
void v14_clipplane_cleanup_thunk();
|
||||
|
||||
// v23 — naked thunk CALLed from 0x00514043 (currently disabled in
|
||||
// apply_all_patches — kept for source completeness).
|
||||
void v23_orphan_hook_thunk();
|
||||
|
||||
// v23 C entrypoint (cdecl). Called from the naked thunk with the child
|
||||
// CPhysicsObj* (primary view) as its single argument.
|
||||
void __cdecl log_enqueue_orphan_child(void* child);
|
||||
|
||||
} // extern "C"
|
||||
BIN
dll/leakfix/stable/leakfix.iter3.dll
Normal file
BIN
dll/leakfix/stable/leakfix.iter3.dll
Normal file
Binary file not shown.
BIN
dll/leakfix/stable/leakfix.pre-v20.dll
Normal file
BIN
dll/leakfix/stable/leakfix.pre-v20.dll
Normal file
Binary file not shown.
BIN
dll/leakfix/stable/leakfix.pre-v23v24.dll
Normal file
BIN
dll/leakfix/stable/leakfix.pre-v23v24.dll
Normal file
Binary file not shown.
BIN
dll/leakfix/stable/leakfix.stable.dll
Normal file
BIN
dll/leakfix/stable/leakfix.stable.dll
Normal file
Binary file not shown.
BIN
dll/leakfix/stable/leakfix.v23-onlyA.dll
Normal file
BIN
dll/leakfix/stable/leakfix.v23-onlyA.dll
Normal file
Binary file not shown.
36
dll/leakfix/stable/src.iter3/ac_addrs.h
Normal file
36
dll/leakfix/stable/src.iter3/ac_addrs.h
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// ac_addrs.h — EoR acclient.exe addresses used by leakfix.dll
|
||||
// Verified against acclient.exe v0.0.11.6096 EoR (Jan 2017).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace ac {
|
||||
|
||||
// ===== v3b — palette over-increment NOP =====
|
||||
constexpr uintptr_t V3B_SITE_1 = 0x0053EFFE; // inc dword [ecx+24] -> NOP NOP NOP
|
||||
constexpr uintptr_t V3B_SITE_2 = 0x0053F19C; // inc dword [esi+24] -> NOP NOP NOP
|
||||
|
||||
// ===== v5 — RenderSurface/Texture PurgeResource override =====
|
||||
constexpr uintptr_t V5_RS_VTABLE_SLOT_2 = 0x0079A684; // RenderSurface vtable +0x08
|
||||
constexpr uintptr_t V5_RT_VTABLE_SLOT_2 = 0x0079C1A0; // RenderTexture vtable +0x08
|
||||
constexpr uintptr_t V5_NOOP_STUB_VA = 0x004154A0; // expected original (mov al,1; ret)
|
||||
constexpr uintptr_t V5_RS_DESTROY_VA = 0x00444540; // RenderSurface::Destroy
|
||||
constexpr uintptr_t V5_RT_DESTROY_VA = 0x0044C4F0; // RenderTexture::Destroy
|
||||
|
||||
// ===== v11 — NULL-check guards =====
|
||||
constexpr uintptr_t V11_SITE_1_VA = 0x00587126; // delete_contents JMP retarget
|
||||
constexpr uintptr_t V11_SITE_2_VA = 0x005E565D; // ~GXTri3Mesh slot 0 NULL-check
|
||||
|
||||
// ===== v12 — unpacker validator + dispatch redirect =====
|
||||
constexpr uintptr_t V12_VALIDATOR_VA = 0x00526A45; // overwrite 11-NOP pad + 18 bytes
|
||||
constexpr uintptr_t V12_DISPATCH_VA = 0x007C92C8; // dispatch table entry (4 bytes)
|
||||
constexpr uintptr_t V12_OLD_FUNC_VA = 0x00526A50; // original unpacker entry
|
||||
// Dispatch points at validator (V12_VALIDATOR_VA) instead
|
||||
|
||||
// ===== v14 — CEnvCell::Destroy ClipPlaneList leak =====
|
||||
constexpr uintptr_t V14_PATCH_SITE_VA = 0x0052E661; // 18-byte leak block
|
||||
constexpr uintptr_t V14_RESUME_VA = 0x0052E673; // continue here after thunk
|
||||
constexpr uintptr_t V14_CLIPPLANELIST_DTOR = 0x0053C760; // ClipPlaneList::~ClipPlaneList
|
||||
constexpr uintptr_t V14_OPERATOR_DELETE = 0x005DF15E; // operator delete
|
||||
constexpr uintptr_t V14_OPERATOR_DELETE_ARR = 0x005DF164; // operator delete[]
|
||||
|
||||
} // namespace ac
|
||||
63
dll/leakfix/stable/src.iter3/dllmain.cpp
Normal file
63
dll/leakfix/stable/src.iter3/dllmain.cpp
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// dllmain.cpp — leakfix.dll entry point
|
||||
#include <windows.h>
|
||||
#include "logging.h"
|
||||
#include "patches.h"
|
||||
#include "instr.h"
|
||||
|
||||
namespace {
|
||||
void on_attach() {
|
||||
char dll_path[MAX_PATH] = {0};
|
||||
GetModuleFileNameA((HMODULE)GetModuleHandleA("leakfix.dll"), dll_path, MAX_PATH);
|
||||
|
||||
// Log next to the DLL itself
|
||||
char log_path[MAX_PATH] = {0};
|
||||
char* slash = nullptr;
|
||||
for (char* p = dll_path; *p; ++p) if (*p == '\\' || *p == '/') slash = p;
|
||||
if (slash) {
|
||||
size_t prefix = (size_t)(slash - dll_path) + 1;
|
||||
memcpy(log_path, dll_path, prefix);
|
||||
strcpy(log_path + prefix, "leakfix.log");
|
||||
} else {
|
||||
strcpy(log_path, "leakfix.log");
|
||||
}
|
||||
leakfix::log_init(log_path);
|
||||
leakfix::logf("attach: dll=%s", dll_path);
|
||||
|
||||
// Kill switch — set LEAKFIX_NO_PATCHES=1 in env to skip patch
|
||||
// application. Instrumentation still runs. Used to bisect crashes:
|
||||
// if the no-patches variant survives, the patches are the trigger.
|
||||
char no_patches[8] = {0};
|
||||
GetEnvironmentVariableA("LEAKFIX_NO_PATCHES", no_patches, sizeof(no_patches));
|
||||
bool skip_patches = (no_patches[0] == '1');
|
||||
|
||||
leakfix::instr_install_crash_handler();
|
||||
if (skip_patches) {
|
||||
leakfix::logf("LEAKFIX_NO_PATCHES=1 — skipping patch application (diagnostic mode)");
|
||||
} else {
|
||||
leakfix::apply_all_patches();
|
||||
}
|
||||
leakfix::instr_start_periodic_scan();
|
||||
}
|
||||
} // anon
|
||||
|
||||
// Exported stub so PE-import-table patching of acclient.exe can name
|
||||
// a real symbol for the OS loader to resolve. Doing nothing is fine —
|
||||
// just being present in the DLL is what makes the import valid.
|
||||
extern "C" __declspec(dllexport) int __cdecl leakfix_init() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID) {
|
||||
switch (reason) {
|
||||
case DLL_PROCESS_ATTACH:
|
||||
DisableThreadLibraryCalls(h);
|
||||
on_attach();
|
||||
break;
|
||||
case DLL_PROCESS_DETACH:
|
||||
leakfix::instr_stop_periodic_scan();
|
||||
leakfix::logf("detach");
|
||||
leakfix::log_close();
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
418
dll/leakfix/stable/src.iter3/instr.cpp
Normal file
418
dll/leakfix/stable/src.iter3/instr.cpp
Normal file
|
|
@ -0,0 +1,418 @@
|
|||
// instr.cpp — crash dump + periodic instance-count scanner
|
||||
#include "instr.h"
|
||||
#include "logging.h"
|
||||
#include "ac_addrs.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <dbghelp.h>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#pragma comment(lib, "dbghelp.lib")
|
||||
|
||||
namespace {
|
||||
|
||||
// ===== Crash-dump =====
|
||||
|
||||
LPTOP_LEVEL_EXCEPTION_FILTER g_prev_filter = nullptr;
|
||||
|
||||
// Returns directory of our DLL, ending with backslash.
|
||||
void get_dll_dir(char* out, size_t out_sz) {
|
||||
HMODULE h = nullptr;
|
||||
GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
(LPCSTR)&get_dll_dir, &h);
|
||||
char path[MAX_PATH] = {0};
|
||||
GetModuleFileNameA(h, path, MAX_PATH);
|
||||
size_t n = strlen(path);
|
||||
while (n > 0 && path[n-1] != '\\' && path[n-1] != '/') --n;
|
||||
if (n >= out_sz) n = out_sz - 1;
|
||||
memcpy(out, path, n);
|
||||
out[n] = '\0';
|
||||
}
|
||||
|
||||
LONG WINAPI top_level_handler(EXCEPTION_POINTERS* ep) {
|
||||
char dir[MAX_PATH]; get_dll_dir(dir, sizeof(dir));
|
||||
SYSTEMTIME st; GetLocalTime(&st);
|
||||
char path[MAX_PATH];
|
||||
std::snprintf(path, sizeof(path),
|
||||
"%sleakfix_crash_%lu_%04d%02d%02d_%02d%02d%02d.dmp",
|
||||
dir, GetCurrentProcessId(),
|
||||
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
|
||||
|
||||
leakfix::logf("UNHANDLED EXCEPTION code=0x%08lx addr=0x%p — writing %s",
|
||||
ep->ExceptionRecord->ExceptionCode,
|
||||
ep->ExceptionRecord->ExceptionAddress, path);
|
||||
|
||||
HANDLE hf = CreateFileA(path, GENERIC_WRITE, 0, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (hf != INVALID_HANDLE_VALUE) {
|
||||
MINIDUMP_EXCEPTION_INFORMATION mei{};
|
||||
mei.ThreadId = GetCurrentThreadId();
|
||||
mei.ExceptionPointers = ep;
|
||||
mei.ClientPointers = FALSE;
|
||||
// MiniDumpNormal does NOT walk the heap — safer when crash is
|
||||
// heap-corruption. Includes thread stacks + module info which
|
||||
// is enough to identify the fault site. Previous attempts with
|
||||
// MiniDumpWithDataSegs produced 0-byte files because the
|
||||
// corrupted heap broke MiniDumpWriteDump mid-walk.
|
||||
BOOL ok = MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(),
|
||||
hf, MiniDumpNormal, &mei, nullptr, nullptr);
|
||||
DWORD dump_err = ok ? 0 : GetLastError();
|
||||
CloseHandle(hf);
|
||||
leakfix::logf("MiniDumpWriteDump: %s err=%lu", ok ? "ok" : "failed", dump_err);
|
||||
// If even MiniDumpNormal fails, write a tiny text file with the
|
||||
// bare minimum info — exception code, address, register state.
|
||||
if (!ok) {
|
||||
char fallback_path[MAX_PATH];
|
||||
std::snprintf(fallback_path, sizeof(fallback_path), "%s.txt", path);
|
||||
HANDLE tf = CreateFileA(fallback_path, GENERIC_WRITE, 0, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (tf != INVALID_HANDLE_VALUE) {
|
||||
CONTEXT* ctx = ep->ContextRecord;
|
||||
char body[512];
|
||||
int n = std::snprintf(body, sizeof(body),
|
||||
"exception_code=0x%08lx\n"
|
||||
"exception_address=0x%p\n"
|
||||
"eax=0x%08lx ebx=0x%08lx ecx=0x%08lx edx=0x%08lx\n"
|
||||
"esi=0x%08lx edi=0x%08lx ebp=0x%08lx esp=0x%08lx\n"
|
||||
"eip=0x%08lx eflags=0x%08lx\n",
|
||||
ep->ExceptionRecord->ExceptionCode,
|
||||
ep->ExceptionRecord->ExceptionAddress,
|
||||
ctx->Eax, ctx->Ebx, ctx->Ecx, ctx->Edx,
|
||||
ctx->Esi, ctx->Edi, ctx->Ebp, ctx->Esp,
|
||||
ctx->Eip, ctx->EFlags);
|
||||
DWORD written = 0;
|
||||
WriteFile(tf, body, n, &written, nullptr);
|
||||
CloseHandle(tf);
|
||||
leakfix::logf("fallback text crash info -> %s", fallback_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
leakfix::logf("CreateFile failed err=%lu", GetLastError());
|
||||
}
|
||||
|
||||
// Chain to previous filter (lets Windows do its thing too).
|
||||
if (g_prev_filter) return g_prev_filter(ep);
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
|
||||
// ===== Periodic scan =====
|
||||
|
||||
struct vt_entry { const char* name; uintptr_t vt; };
|
||||
|
||||
// Mirrors what tools/snapshot_compare.py tracks.
|
||||
const vt_entry VTABLES[] = {
|
||||
{"uiitem", 0x007C0498},
|
||||
{"palette", 0x007CAA08},
|
||||
{"cphysicsobj", 0x007C78EC},
|
||||
{"renderSurf", 0x0079A67C},
|
||||
{"renderSurfD3D", 0x00801A94},
|
||||
{"renderTexD3D", 0x00801A18},
|
||||
{"csurface", 0x007CA4DC},
|
||||
{"imgtex", 0x007CAB04},
|
||||
{"cgfxobj", 0x007CA418},
|
||||
{"d3dxmesh", 0x007ED3B0},
|
||||
{"position", 0x00797910},
|
||||
};
|
||||
constexpr size_t VT_COUNT = sizeof(VTABLES) / sizeof(VTABLES[0]);
|
||||
|
||||
HANDLE g_scan_thread = nullptr;
|
||||
HANDLE g_stop_event = nullptr;
|
||||
int g_prev_counts[VT_COUNT] = {0};
|
||||
bool g_have_prev = false;
|
||||
DWORD g_scan_count = 0;
|
||||
|
||||
// CPhysicsObj field offsets — VERIFIED against acclient.h + live sample
|
||||
// dumps from iter 2. LongHashData base = 12 bytes (vtable +
|
||||
// hash_next + id), then CPhysicsObj-specific fields follow.
|
||||
constexpr int CPHYS_VT_OFF = 0x00; // CPhysicsObj vt = 0x007C78EC
|
||||
constexpr int CPHYS_HASH_NEXT_OFF = 0x04; // LongHashData chain
|
||||
constexpr int CPHYS_ID_OFF = 0x08; // hash key
|
||||
constexpr int CPHYS_PARTARRAY_OFF = 0x10; // CPartArray*
|
||||
constexpr int CPHYS_PARENT_OFF = 0x40; // CPhysicsObj* parent
|
||||
constexpr int CPHYS_CHILDREN_OFF = 0x44; // CHILDLIST*
|
||||
constexpr int CPHYS_POSITION_OFF = 0x48; // Position (72 B)
|
||||
constexpr int CPHYS_CELL_OFF = 0x90; // CObjCell*
|
||||
constexpr int CPHYS_STATE_OFF = 0xA8; // unsigned int state
|
||||
constexpr int CPHYS_UPDATETIME_OFF= 0xD4; // long double (8 B at runtime)
|
||||
|
||||
constexpr int CPHYS_SAMPLE_BYTES = 256; // dump first 256 B per sample
|
||||
constexpr int CPHYS_SAMPLE_COUNT = 3; // samples per scan
|
||||
|
||||
// Find up to `want` instances of vt 0x007C78EC; return how many recorded.
|
||||
int find_physobj_samples(uintptr_t* out_addrs, int want) {
|
||||
int found = 0;
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t addr = 0;
|
||||
while (found < want && VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) {
|
||||
bool committed = mbi.State == MEM_COMMIT;
|
||||
bool priv = mbi.Type == MEM_PRIVATE;
|
||||
DWORD prot = mbi.Protect & 0xFF;
|
||||
bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE);
|
||||
if (committed && priv && readable) {
|
||||
__try {
|
||||
const uint32_t* p = (const uint32_t*)mbi.BaseAddress;
|
||||
size_t n = mbi.RegionSize / 4;
|
||||
for (size_t i = 0; i < n && found < want; ++i) {
|
||||
if (p[i] == 0x007C78EC) {
|
||||
out_addrs[found++] = (uintptr_t)(p + i);
|
||||
}
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {}
|
||||
}
|
||||
uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (next <= addr) break;
|
||||
addr = next;
|
||||
if (addr >= 0x80000000) break;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
void dump_physobj(uintptr_t obj_addr) {
|
||||
uint32_t buf[CPHYS_SAMPLE_BYTES / 4];
|
||||
__try {
|
||||
memcpy(buf, (const void*)obj_addr, CPHYS_SAMPLE_BYTES);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
leakfix::logf("sample physobj @ 0x%08x: read failed", obj_addr);
|
||||
return;
|
||||
}
|
||||
// Log as 4 DWORDs per line. Annotate each DWORD with a hint:
|
||||
// '0' if null, 'V' if it equals the CPhysicsObj vt (suggests parent),
|
||||
// 'C' if it equals a known CObjCell-family vt (suggests cell),
|
||||
// 'i' if in image range (.text/.rdata),
|
||||
// 'h' if in typical heap range.
|
||||
leakfix::logf("sample physobj @ 0x%08x:", obj_addr);
|
||||
for (int row = 0; row < CPHYS_SAMPLE_BYTES / 16; ++row) {
|
||||
char line[256];
|
||||
int n = std::snprintf(line, sizeof(line), " +0x%02x: ", row * 16);
|
||||
for (int col = 0; col < 4; ++col) {
|
||||
uint32_t v = buf[row * 4 + col];
|
||||
char tag = ' ';
|
||||
if (v == 0) tag = '0';
|
||||
else if (v == 0x007C78EC) tag = 'V'; // CPhysicsObj vt
|
||||
else if (v == 0x007ED3B0 || v == 0x007CA4F0) tag = 'C'; // CObjCell-family vt
|
||||
else if (v >= 0x00400000 && v < 0x00800000) tag = 'i'; // image range
|
||||
else if (v >= 0x01000000 && v < 0x40000000) tag = 'h'; // heap
|
||||
n += std::snprintf(line + n, sizeof(line) - n, "%08x%c ", v, tag);
|
||||
}
|
||||
leakfix::logf("%s", line);
|
||||
}
|
||||
}
|
||||
|
||||
// ITER 3 — for each CPhysicsObj instance, evaluate "safe to destroy"
|
||||
// predicates. Read-only — no mutation.
|
||||
//
|
||||
// Predicates evaluated (all on the SAME instance):
|
||||
// P_no_parent = (parent == NULL)
|
||||
// P_no_cell = (cell == NULL)
|
||||
// P_orphan_hash = (hash_next == NULL) [not linked into any hash chain]
|
||||
// P_no_part_array = (part_array == NULL)
|
||||
//
|
||||
// Logged combinations:
|
||||
// n_total = all CPhysicsObj found
|
||||
// n_no_parent = count where parent==NULL
|
||||
// n_no_cell = count where cell==NULL
|
||||
// n_orphan_hash = count where hash_next==NULL
|
||||
// n_both = count where parent==NULL AND cell==NULL (STRICT)
|
||||
// n_triple = count where parent==NULL AND cell==NULL AND hash_next==NULL
|
||||
//
|
||||
// The "triple" set is the candidate set we'd target for sweep — physobjs
|
||||
// that are not in any hash chain, not in any cell, and have no parent.
|
||||
// They are by definition unreachable from the engine's active state.
|
||||
void evaluate_predicates_and_dump_candidates() {
|
||||
int n_total = 0;
|
||||
int n_no_parent = 0;
|
||||
int n_no_cell = 0;
|
||||
int n_orphan_hash = 0;
|
||||
int n_both = 0;
|
||||
int n_triple = 0;
|
||||
|
||||
constexpr int CANDIDATE_DUMP_MAX = 3;
|
||||
uintptr_t candidates[CANDIDATE_DUMP_MAX] = {0};
|
||||
int n_candidates_recorded = 0;
|
||||
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t addr = 0;
|
||||
while (VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) {
|
||||
bool committed = mbi.State == MEM_COMMIT;
|
||||
bool priv = mbi.Type == MEM_PRIVATE;
|
||||
DWORD prot = mbi.Protect & 0xFF;
|
||||
bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE);
|
||||
if (committed && priv && readable) {
|
||||
__try {
|
||||
const uint32_t* p = (const uint32_t*)mbi.BaseAddress;
|
||||
size_t n = mbi.RegionSize / 4;
|
||||
// Walk DWORDs; need at least 0xA8/4 == 42 DWORDs of headroom
|
||||
// for the deepest field we read.
|
||||
size_t need = (CPHYS_STATE_OFF / 4) + 1;
|
||||
if (n < need) {
|
||||
addr = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (addr >= 0x80000000) break;
|
||||
continue;
|
||||
}
|
||||
for (size_t i = 0; i + need < n; ++i) {
|
||||
if (p[i] != 0x007C78EC) continue; // not CPhysicsObj
|
||||
++n_total;
|
||||
const uint32_t hash_next = p[i + (CPHYS_HASH_NEXT_OFF / 4)];
|
||||
const uint32_t parent = p[i + (CPHYS_PARENT_OFF / 4)];
|
||||
const uint32_t cell = p[i + (CPHYS_CELL_OFF / 4)];
|
||||
|
||||
const bool no_parent = (parent == 0);
|
||||
const bool no_cell = (cell == 0);
|
||||
const bool orphan_hash = (hash_next == 0);
|
||||
|
||||
if (no_parent) ++n_no_parent;
|
||||
if (no_cell) ++n_no_cell;
|
||||
if (orphan_hash) ++n_orphan_hash;
|
||||
if (no_parent && no_cell) ++n_both;
|
||||
if (no_parent && no_cell && orphan_hash) {
|
||||
++n_triple;
|
||||
if (n_candidates_recorded < CANDIDATE_DUMP_MAX) {
|
||||
candidates[n_candidates_recorded++] = (uintptr_t)(p + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {}
|
||||
}
|
||||
uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (next <= addr) break;
|
||||
addr = next;
|
||||
if (addr >= 0x80000000) break;
|
||||
}
|
||||
|
||||
leakfix::logf("predicates: total=%d no_parent=%d no_cell=%d orphan_hash=%d "
|
||||
"both=%d triple=%d (candidates for sweep)",
|
||||
n_total, n_no_parent, n_no_cell, n_orphan_hash,
|
||||
n_both, n_triple);
|
||||
|
||||
// Dump first few strict candidates so we can sanity-check they look
|
||||
// like genuinely abandoned objects (no weenie, no part_array, etc.).
|
||||
for (int i = 0; i < n_candidates_recorded; ++i) {
|
||||
leakfix::logf("--- strict candidate %d ---", i);
|
||||
dump_physobj(candidates[i]);
|
||||
}
|
||||
}
|
||||
|
||||
void scan_once() {
|
||||
int counts[VT_COUNT] = {0};
|
||||
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t addr = 0;
|
||||
int region_count = 0;
|
||||
while (VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) {
|
||||
bool committed = mbi.State == MEM_COMMIT;
|
||||
bool priv = mbi.Type == MEM_PRIVATE;
|
||||
DWORD prot = mbi.Protect & 0xFF;
|
||||
bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE);
|
||||
if (committed && priv && readable) {
|
||||
++region_count;
|
||||
// Scan in DWORD steps. In-process so direct deref is fine, but
|
||||
// wrap with SEH in case of races / partial commits.
|
||||
__try {
|
||||
const uint32_t* p = (const uint32_t*)mbi.BaseAddress;
|
||||
size_t n = mbi.RegionSize / 4;
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
uint32_t v = p[i];
|
||||
for (size_t k = 0; k < VT_COUNT; ++k) {
|
||||
if (v == VTABLES[k].vt) { ++counts[k]; break; }
|
||||
}
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
// skip this region — happens occasionally on volatile pages
|
||||
}
|
||||
}
|
||||
uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (next <= addr) break;
|
||||
addr = next;
|
||||
if (addr >= 0x80000000) break; // 32-bit user-space ceiling
|
||||
}
|
||||
|
||||
++g_scan_count;
|
||||
|
||||
char buf[1024];
|
||||
int n = std::snprintf(buf, sizeof(buf), "scan#%lu: regions=%d", g_scan_count, region_count);
|
||||
for (size_t k = 0; k < VT_COUNT && n < (int)sizeof(buf) - 16; ++k) {
|
||||
n += std::snprintf(buf + n, sizeof(buf) - n, " %s=%d", VTABLES[k].name, counts[k]);
|
||||
}
|
||||
leakfix::logf("%s", buf);
|
||||
|
||||
// Deltas vs previous scan (5-min interval after first scan)
|
||||
if (g_have_prev) {
|
||||
char dbuf[1024];
|
||||
int dn = std::snprintf(dbuf, sizeof(dbuf), "delta:");
|
||||
bool any_nonzero = false;
|
||||
for (size_t k = 0; k < VT_COUNT && dn < (int)sizeof(dbuf) - 32; ++k) {
|
||||
int d = counts[k] - g_prev_counts[k];
|
||||
if (d != 0) any_nonzero = true;
|
||||
dn += std::snprintf(dbuf + dn, sizeof(dbuf) - dn, " %s=%+d", VTABLES[k].name, d);
|
||||
}
|
||||
if (any_nonzero) leakfix::logf("%s", dbuf);
|
||||
}
|
||||
for (size_t k = 0; k < VT_COUNT; ++k) g_prev_counts[k] = counts[k];
|
||||
g_have_prev = true;
|
||||
|
||||
// Useful ratios (sanity-check our structural understanding):
|
||||
// position / cphysicsobj should be near 10 for active clients per v17
|
||||
// diag (each CPhysicsPart has 2 Positions; ~5 parts per physobj)
|
||||
// cphysicsobj count is what the sweep would target if/when we add it
|
||||
if (counts[2] > 0) { // cphysicsobj index = 2
|
||||
double pos_ratio = (double)counts[10] / (double)counts[2]; // position
|
||||
leakfix::logf("ratio: position/cphysicsobj=%.2f (idle ~7, active ~10)", pos_ratio);
|
||||
}
|
||||
|
||||
// ITER 2 — sample CPhysicsObj field layouts (every-other scan).
|
||||
if (counts[2] > 0 && (g_scan_count % 2 == 1)) {
|
||||
uintptr_t samples[CPHYS_SAMPLE_COUNT] = {0};
|
||||
int got = find_physobj_samples(samples, CPHYS_SAMPLE_COUNT);
|
||||
leakfix::logf("physobj-samples: got=%d of %d", got, CPHYS_SAMPLE_COUNT);
|
||||
for (int i = 0; i < got; ++i) dump_physobj(samples[i]);
|
||||
}
|
||||
|
||||
// ITER 3 — predicate evaluation across ALL CPhysicsObj instances
|
||||
// (every scan; full walk reused from scan_once iteration is too
|
||||
// costly so we do a second pass dedicated to this).
|
||||
if (counts[2] > 0) {
|
||||
evaluate_predicates_and_dump_candidates();
|
||||
}
|
||||
}
|
||||
|
||||
DWORD WINAPI scan_loop(LPVOID) {
|
||||
// First scan ~30s after start so the process is warmed up.
|
||||
if (WaitForSingleObject(g_stop_event, 30000) == WAIT_OBJECT_0) return 0;
|
||||
for (;;) {
|
||||
scan_once();
|
||||
// Scan every 5 minutes thereafter.
|
||||
if (WaitForSingleObject(g_stop_event, 5 * 60 * 1000) == WAIT_OBJECT_0) return 0;
|
||||
}
|
||||
}
|
||||
|
||||
} // anon
|
||||
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
void instr_install_crash_handler() {
|
||||
g_prev_filter = SetUnhandledExceptionFilter(top_level_handler);
|
||||
logf("instr: crash handler installed");
|
||||
}
|
||||
|
||||
void instr_start_periodic_scan() {
|
||||
if (g_scan_thread) return;
|
||||
g_stop_event = CreateEventA(nullptr, TRUE, FALSE, nullptr);
|
||||
g_scan_thread = CreateThread(nullptr, 0, scan_loop, nullptr, 0, nullptr);
|
||||
logf("instr: periodic scanner started (interval=5min)");
|
||||
}
|
||||
|
||||
void instr_stop_periodic_scan() {
|
||||
if (!g_scan_thread) return;
|
||||
if (g_stop_event) SetEvent(g_stop_event);
|
||||
WaitForSingleObject(g_scan_thread, 5000);
|
||||
CloseHandle(g_scan_thread);
|
||||
if (g_stop_event) CloseHandle(g_stop_event);
|
||||
g_scan_thread = nullptr;
|
||||
g_stop_event = nullptr;
|
||||
}
|
||||
|
||||
} // namespace leakfix
|
||||
19
dll/leakfix/stable/src.iter3/instr.h
Normal file
19
dll/leakfix/stable/src.iter3/instr.h
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// instr.h — instrumentation features for leakfix.dll
|
||||
#pragma once
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
// Install SetUnhandledExceptionFilter so any unhandled native exception
|
||||
// writes a clean minidump to leakfix_crash_<pid>_<timestamp>.dmp next
|
||||
// to the DLL, then chains to Windows' default handling.
|
||||
void instr_install_crash_handler();
|
||||
|
||||
// Start a background thread that scans memory every 5 minutes,
|
||||
// counts known leak-class vtable instances, and appends a one-line
|
||||
// summary to leakfix.log.
|
||||
void instr_start_periodic_scan();
|
||||
|
||||
// Stop the periodic scan thread (called from DLL_PROCESS_DETACH).
|
||||
void instr_stop_periodic_scan();
|
||||
|
||||
} // namespace leakfix
|
||||
74
dll/leakfix/stable/src.iter3/logging.cpp
Normal file
74
dll/leakfix/stable/src.iter3/logging.cpp
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#include "logging.h"
|
||||
#include <windows.h>
|
||||
#include <cstdio>
|
||||
#include <cstdarg>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
HANDLE g_log = INVALID_HANDLE_VALUE;
|
||||
CRITICAL_SECTION g_cs;
|
||||
bool g_cs_inited = false;
|
||||
|
||||
void ensure_cs() {
|
||||
if (!g_cs_inited) {
|
||||
InitializeCriticalSection(&g_cs);
|
||||
g_cs_inited = true;
|
||||
}
|
||||
}
|
||||
|
||||
void write_line(const char* s, size_t len) {
|
||||
if (g_log == INVALID_HANDLE_VALUE) return;
|
||||
DWORD written = 0;
|
||||
WriteFile(g_log, s, (DWORD)len, &written, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
void log_init(const char* path) {
|
||||
ensure_cs();
|
||||
EnterCriticalSection(&g_cs);
|
||||
if (g_log != INVALID_HANDLE_VALUE) { LeaveCriticalSection(&g_cs); return; }
|
||||
g_log = CreateFileA(path, FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
SetFilePointer(g_log, 0, nullptr, FILE_END);
|
||||
LeaveCriticalSection(&g_cs);
|
||||
logf("===== leakfix.dll loaded (pid=%lu) =====", GetCurrentProcessId());
|
||||
}
|
||||
|
||||
void log_close() {
|
||||
ensure_cs();
|
||||
EnterCriticalSection(&g_cs);
|
||||
if (g_log != INVALID_HANDLE_VALUE) {
|
||||
CloseHandle(g_log);
|
||||
g_log = INVALID_HANDLE_VALUE;
|
||||
}
|
||||
LeaveCriticalSection(&g_cs);
|
||||
}
|
||||
|
||||
void logf(const char* fmt, ...) {
|
||||
ensure_cs();
|
||||
char buf[1024];
|
||||
SYSTEMTIME st;
|
||||
GetLocalTime(&st);
|
||||
int n = std::snprintf(buf, sizeof(buf),
|
||||
"[%04d-%02d-%02d %02d:%02d:%02d.%03d] ",
|
||||
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds);
|
||||
va_list ap; va_start(ap, fmt);
|
||||
int m = std::vsnprintf(buf + n, sizeof(buf) - n - 2, fmt, ap);
|
||||
va_end(ap);
|
||||
if (m < 0) m = 0;
|
||||
int total = n + m;
|
||||
if (total >= (int)sizeof(buf) - 1) total = sizeof(buf) - 2;
|
||||
buf[total] = '\n';
|
||||
buf[total + 1] = '\0';
|
||||
|
||||
EnterCriticalSection(&g_cs);
|
||||
write_line(buf, (size_t)total + 1);
|
||||
LeaveCriticalSection(&g_cs);
|
||||
|
||||
// Also forward to debugger if attached
|
||||
OutputDebugStringA(buf);
|
||||
}
|
||||
|
||||
} // namespace leakfix
|
||||
8
dll/leakfix/stable/src.iter3/logging.h
Normal file
8
dll/leakfix/stable/src.iter3/logging.h
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// logging.h — minimal file-based logging for leakfix.dll
|
||||
#pragma once
|
||||
|
||||
namespace leakfix {
|
||||
void log_init(const char* path); // open log file (relative to acclient.exe dir if not absolute)
|
||||
void log_close();
|
||||
void logf(const char* fmt, ...); // appends a timestamped line
|
||||
} // namespace leakfix
|
||||
195
dll/leakfix/stable/src.iter3/patches.cpp
Normal file
195
dll/leakfix/stable/src.iter3/patches.cpp
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// patches.cpp — apply v3b, v5, v11, v12, v14 inline to our own process
|
||||
#include "patches.h"
|
||||
#include "logging.h"
|
||||
#include "thunks.h"
|
||||
#include "ac_addrs.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace leakfix;
|
||||
|
||||
namespace {
|
||||
|
||||
// Copy `data` to absolute address `addr`, flipping page protection.
|
||||
bool write_memory(uintptr_t addr, const void* data, size_t len) {
|
||||
DWORD old = 0;
|
||||
if (!VirtualProtect((void*)addr, len, PAGE_EXECUTE_READWRITE, &old)) {
|
||||
logf(" VirtualProtect(0x%08x, %u) failed err=%lu", addr, (unsigned)len, GetLastError());
|
||||
return false;
|
||||
}
|
||||
std::memcpy((void*)addr, data, len);
|
||||
DWORD restored = 0;
|
||||
VirtualProtect((void*)addr, len, old, &restored);
|
||||
FlushInstructionCache(GetCurrentProcess(), (void*)addr, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool bytes_equal(uintptr_t addr, const void* expected, size_t len) {
|
||||
return std::memcmp((void*)addr, expected, len) == 0;
|
||||
}
|
||||
|
||||
void hexdump_short(uintptr_t addr, size_t n, char* out, size_t out_sz) {
|
||||
const uint8_t* p = (const uint8_t*)addr;
|
||||
size_t used = 0;
|
||||
for (size_t i = 0; i < n && used + 3 < out_sz; ++i) {
|
||||
used += (size_t)std::snprintf(out + used, out_sz - used, "%02x", p[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Write a 5-byte JMP rel32 at `at` targeting `target`. Pad remaining bytes
|
||||
// up to `total_replace` with 0x90 NOPs.
|
||||
bool write_jmp_rel32(uintptr_t at, uintptr_t target, size_t total_replace) {
|
||||
uint8_t buf[64];
|
||||
if (total_replace > sizeof(buf)) return false;
|
||||
int32_t rel = (int32_t)(target - (at + 5));
|
||||
buf[0] = 0xE9;
|
||||
std::memcpy(buf + 1, &rel, 4);
|
||||
std::memset(buf + 5, 0x90, total_replace - 5);
|
||||
return write_memory(at, buf, total_replace);
|
||||
}
|
||||
|
||||
} // anon namespace
|
||||
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
// ===== v3b =====
|
||||
bool apply_v3b() {
|
||||
const uint8_t nops[3] = { 0x90, 0x90, 0x90 };
|
||||
const uint8_t orig1[3] = { 0xff, 0x40, 0x24 }; // inc dword [eax+0x24]
|
||||
const uint8_t orig2[3] = { 0xff, 0x46, 0x24 }; // inc dword [esi+0x24]
|
||||
|
||||
if (bytes_equal(ac::V3B_SITE_1, nops, 3) && bytes_equal(ac::V3B_SITE_2, nops, 3)) {
|
||||
logf("v3b: already applied");
|
||||
return true;
|
||||
}
|
||||
if (!bytes_equal(ac::V3B_SITE_1, orig1, 3)) {
|
||||
char h[16]; hexdump_short(ac::V3B_SITE_1, 3, h, sizeof(h));
|
||||
logf("v3b: site1 unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
if (!bytes_equal(ac::V3B_SITE_2, orig2, 3)) {
|
||||
char h[16]; hexdump_short(ac::V3B_SITE_2, 3, h, sizeof(h));
|
||||
logf("v3b: site2 unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
write_memory(ac::V3B_SITE_1, nops, 3);
|
||||
write_memory(ac::V3B_SITE_2, nops, 3);
|
||||
logf("v3b: applied (NOPs at 0x%08x + 0x%08x)", ac::V3B_SITE_1, ac::V3B_SITE_2);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v5 =====
|
||||
bool apply_v5() {
|
||||
uintptr_t rs_cur = *(uintptr_t*)ac::V5_RS_VTABLE_SLOT_2;
|
||||
uintptr_t rt_cur = *(uintptr_t*)ac::V5_RT_VTABLE_SLOT_2;
|
||||
uintptr_t rs_new = (uintptr_t)&purge_rendersurface_thunk;
|
||||
uintptr_t rt_new = (uintptr_t)&purge_rendertexture_thunk;
|
||||
|
||||
bool rs_done = (rs_cur != ac::V5_NOOP_STUB_VA);
|
||||
bool rt_done = (rt_cur != ac::V5_NOOP_STUB_VA);
|
||||
|
||||
if (!rs_done) {
|
||||
if (rs_cur != ac::V5_NOOP_STUB_VA) {
|
||||
logf("v5: RS slot already redirected (0x%08x); not overwriting", rs_cur);
|
||||
} else {
|
||||
write_memory(ac::V5_RS_VTABLE_SLOT_2, &rs_new, 4);
|
||||
logf("v5: RS vtable slot -> 0x%08x", rs_new);
|
||||
}
|
||||
} else {
|
||||
logf("v5: RS slot already non-default (0x%08x) — skipping", rs_cur);
|
||||
}
|
||||
|
||||
if (!rt_done) {
|
||||
write_memory(ac::V5_RT_VTABLE_SLOT_2, &rt_new, 4);
|
||||
logf("v5: RT vtable slot -> 0x%08x", rt_new);
|
||||
} else {
|
||||
logf("v5: RT slot already non-default (0x%08x) — skipping", rt_cur);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v11 =====
|
||||
bool apply_v11() {
|
||||
// Site 1: 2-byte rewrite of a JMP target
|
||||
const uint8_t s1_orig[2] = { 0xEB, 0x07 };
|
||||
const uint8_t s1_patched[2] = { 0xEB, 0x42 };
|
||||
// Site 2: 9-byte rewrite for ~GXTri3Mesh NULL-check
|
||||
const uint8_t s2_orig[9] = { 0x8B, 0x08, 0x50, 0xFF, 0x51, 0x08, 0x89, 0x5E, 0x08 };
|
||||
const uint8_t s2_patched[9] = { 0x89, 0x5E, 0x08, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
|
||||
|
||||
if (bytes_equal(ac::V11_SITE_1_VA, s1_patched, 2)) {
|
||||
logf("v11: site1 already patched");
|
||||
} else if (bytes_equal(ac::V11_SITE_1_VA, s1_orig, 2)) {
|
||||
write_memory(ac::V11_SITE_1_VA, s1_patched, 2);
|
||||
logf("v11: site1 patched");
|
||||
} else {
|
||||
char h[8]; hexdump_short(ac::V11_SITE_1_VA, 2, h, sizeof(h));
|
||||
logf("v11: site1 unexpected %s — skipping", h);
|
||||
}
|
||||
|
||||
if (bytes_equal(ac::V11_SITE_2_VA, s2_patched, 9)) {
|
||||
logf("v11: site2 already patched");
|
||||
} else if (bytes_equal(ac::V11_SITE_2_VA, s2_orig, 9)) {
|
||||
write_memory(ac::V11_SITE_2_VA, s2_patched, 9);
|
||||
logf("v11: site2 patched");
|
||||
} else {
|
||||
char h[24]; hexdump_short(ac::V11_SITE_2_VA, 9, h, sizeof(h));
|
||||
logf("v11: site2 unexpected %s — skipping", h);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v12 RETIRED =====
|
||||
// v12 was designed against post-Decal in-memory bytes that don't match
|
||||
// the on-disk binary. When the leakfix.dll loads at PE-import time (before
|
||||
// Decal init), it sees the truly-original bytes and v12 would refuse.
|
||||
// When the Python patcher ran later against a running PID, it saw
|
||||
// Decal-modified bytes that happened to match its expected pattern and
|
||||
// applied a duplicate range check — adding no protection beyond what
|
||||
// Decal already provides. Neither variant prevented the Shadow/Frank
|
||||
// stale-heap-pointer crashes. v12 removed.
|
||||
|
||||
// ===== v14 =====
|
||||
bool apply_v14() {
|
||||
static const uint8_t ORIG[18] = {
|
||||
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, // mov eax, [esi+0xDC]
|
||||
0x3B, 0xC3, // cmp eax, ebx
|
||||
0x74, 0x08, // jz +8
|
||||
0x8B, 0x00, // mov eax, [eax]
|
||||
0x3B, 0xC3, // cmp eax, ebx
|
||||
0x74, 0x02, // jz +2
|
||||
0x89, 0x18, // mov [eax], ebx <- the broken "fix"
|
||||
};
|
||||
|
||||
// If already patched, the first byte is 0xE9 (our JMP).
|
||||
uint8_t cur = *(uint8_t*)ac::V14_PATCH_SITE_VA;
|
||||
if (cur == 0xE9) {
|
||||
logf("v14: already applied");
|
||||
return true;
|
||||
}
|
||||
if (!bytes_equal(ac::V14_PATCH_SITE_VA, ORIG, 18)) {
|
||||
char h[40]; hexdump_short(ac::V14_PATCH_SITE_VA, 18, h, sizeof(h));
|
||||
logf("v14: site unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
uintptr_t thunk_va = (uintptr_t)&v14_clipplane_cleanup_thunk;
|
||||
if (!write_jmp_rel32(ac::V14_PATCH_SITE_VA, thunk_va, 18)) return false;
|
||||
logf("v14: applied (JMP rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool apply_all_patches() {
|
||||
bool ok = true;
|
||||
ok &= apply_v3b();
|
||||
ok &= apply_v11();
|
||||
ok &= apply_v5();
|
||||
ok &= apply_v14();
|
||||
logf("all-patches result: %s", ok ? "OK" : "PARTIAL");
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace leakfix
|
||||
16
dll/leakfix/stable/src.iter3/patches.h
Normal file
16
dll/leakfix/stable/src.iter3/patches.h
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#pragma once
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
// Returns true if all patches applied (or were already in place).
|
||||
bool apply_all_patches();
|
||||
|
||||
bool apply_v3b();
|
||||
bool apply_v5();
|
||||
bool apply_v11();
|
||||
bool apply_v14();
|
||||
// v12 retired: it was a duplicate of Decal's built-in unpacker range
|
||||
// check and didn't address the actual Shadow/Frank crash class
|
||||
// (stale-heap-pointer in cursor). See memory.
|
||||
|
||||
} // namespace leakfix
|
||||
222
dll/leakfix/stable/src.iter3/sweep_design.md
Normal file
222
dll/leakfix/stable/src.iter3/sweep_design.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# Iter 4 — CPhysicsObj sweep design (DRAFT, NOT YET IMPLEMENTED)
|
||||
|
||||
## Goal
|
||||
|
||||
Periodically destroy abandoned CPhysicsObj instances to recover the
|
||||
residual leak documented in §6.1 of REPORT.md. **Highest-risk patch
|
||||
class** (physics-state mutation, same risk profile as v13 which
|
||||
killed Larsson at 98 min). Long soak per change is mandatory.
|
||||
|
||||
## What iter 3 told us
|
||||
|
||||
After 13 minutes on Unkle Leo (PID 16044), a typical scan shows:
|
||||
|
||||
```
|
||||
total=971 no_parent=546 no_cell=278 orphan_hash=697 both=234 triple=111
|
||||
```
|
||||
|
||||
So ~11% of all CPhysicsObj instances pass the strict triple predicate.
|
||||
On a fresh client triple count is ~100 (startup residual). Growth is
|
||||
+1-2 candidates per minute during normal play.
|
||||
|
||||
Strict-candidate sample dumps confirm:
|
||||
- `parent`, `cell`, `hash_next` all NULL ✓
|
||||
- `part_array` non-NULL (heap allocation that should be freed)
|
||||
- `shadow_objects.data` non-NULL (heap allocation that should be freed)
|
||||
- `state` has small bits set (e.g., 0x00000414 — normal active flags)
|
||||
|
||||
This matches the v17 owner-vtable diagnostic's "abandoned but heap state
|
||||
still allocated" pattern.
|
||||
|
||||
## Candidate destruction call
|
||||
|
||||
The engine already has correct teardown:
|
||||
|
||||
```c
|
||||
// EoR 0x005145D0 — CPhysicsObj::Destroy
|
||||
void __thiscall CPhysicsObj::Destroy(CPhysicsObj* this);
|
||||
```
|
||||
|
||||
Per the v17 owner-diag, `CPhysicsObj::Destroy` correctly tears down
|
||||
all owned heap state (`CPartArray::DestroyParts`, etc.). The leak is
|
||||
that it's never **called** on these abandoned objects.
|
||||
|
||||
After Destroy, the CPhysicsObj itself (~408 bytes) needs to be freed
|
||||
via `operator delete`.
|
||||
|
||||
## Predicate hardening (BEFORE we destroy anything)
|
||||
|
||||
The triple predicate may not be conservative enough. Additional
|
||||
checks before destroy:
|
||||
|
||||
1. **`update_time` is stale** — field at +0xD4 is a long double
|
||||
(timestamp). If less than `now() - 60s`, the object hasn't been
|
||||
touched in a minute. Compare via TimeGetTime() or similar global.
|
||||
2. **`state` is not "currently active"** — need to identify which
|
||||
bits indicate "being processed." For now, skip if state has any
|
||||
high bit set.
|
||||
3. **`weenie_obj == NULL`** — at +0x?? (need to verify offset).
|
||||
If a weenie-object still owns this physobj, the engine considers
|
||||
it alive even if other tracking is gone.
|
||||
4. **`movement_manager == NULL`** — at +0xC4 per acclient.h
|
||||
(LongHashData base 12 + ... + 0xB8 should be it). If there's an
|
||||
active mover, the object is in flight.
|
||||
5. **`hooks == NULL`** — at +0xE? — animation hooks pending.
|
||||
|
||||
The candidate must pass ALL these AND the iter-3 triple predicate.
|
||||
Stricter than iter 3.
|
||||
|
||||
## Safety protocol
|
||||
|
||||
1. **Throttle:** max 1 destruction per scan cycle (5 min). Even if 100
|
||||
candidates qualify, destroy ONE per scan. Surface latent bugs slowly.
|
||||
2. **Sample-first:** for the first 2 hours, LOG candidate addresses
|
||||
but do NOT destroy. Verify the candidates stay candidates over
|
||||
multiple scans (i.e., they're not transient).
|
||||
3. **Per-scan budget:** if a destruction succeeds, log address +
|
||||
pre-destroy field dump. If process crashes after, we have the last
|
||||
destroyed object for forensics.
|
||||
4. **Kill switch:** check `LEAKFIX_NO_SWEEP=1` env var at scan start.
|
||||
If set, skip destruction. Default ON (=destroy) once code lands.
|
||||
5. **Initial test target:** Unkle Leo (current designated guinea pig
|
||||
per CLAUDE.md). One client only. 4-hour soak before declaring safe.
|
||||
6. **Failure recovery:** if Unkle Leo crashes within 1 hour of
|
||||
destruction logic enabling, set the env var, restart with sweep
|
||||
disabled, mark iter-4 as failed in memory, do not retry without
|
||||
redesign.
|
||||
|
||||
## Implementation outline (when ready)
|
||||
|
||||
```cpp
|
||||
struct CPhysicsObj {
|
||||
void* vtable; // +0x00
|
||||
void* hash_next; // +0x04
|
||||
uint32_t id; // +0x08
|
||||
void* netblob_list; // +0x0C
|
||||
void* part_array; // +0x10
|
||||
// ... 12 bytes of player_vector/distance/CYpt
|
||||
void* sound_table; // +0x28
|
||||
uint32_t pad_exam; // +0x2C
|
||||
void* script_manager; // +0x30
|
||||
void* physics_script; // +0x34
|
||||
uint32_t default_script; // +0x38
|
||||
float script_intensity;// +0x3C
|
||||
void* parent; // +0x40
|
||||
void* children; // +0x44
|
||||
char position[72]; // +0x48
|
||||
void* cell; // +0x90
|
||||
uint32_t num_shadow; // +0x94
|
||||
char shadow_arr[16]; // +0x98 — DArray
|
||||
uint32_t state; // +0xA8
|
||||
uint32_t transient_state; // +0xAC
|
||||
// ... floats
|
||||
void* movement_manager;// +0xC4
|
||||
void* position_manager;// +0xC8
|
||||
int last_move_auto; // +0xCC
|
||||
int jumped_frame; // +0xD0
|
||||
double update_time; // +0xD4 (8 bytes)
|
||||
// ...
|
||||
void* weenie_obj; // +0x?? TBD
|
||||
};
|
||||
|
||||
typedef void (__fastcall *destroy_fn_t)(CPhysicsObj* self, void* edx);
|
||||
constexpr destroy_fn_t CPHYSICSOBJ_DESTROY = (destroy_fn_t)0x005145D0;
|
||||
constexpr void* OP_DELETE = (void*)0x005DF15E;
|
||||
|
||||
bool is_truly_abandoned(CPhysicsObj* p) {
|
||||
if (p->parent) return false;
|
||||
if (p->cell) return false;
|
||||
if (p->hash_next) return false;
|
||||
if (p->movement_manager) return false;
|
||||
// state mask: bits 0..15 are flags we tolerate; high bits suggest
|
||||
// active processing
|
||||
if ((p->state & 0xFFFF0000) != 0) return false;
|
||||
if (p->weenie_obj) return false; // need offset verified
|
||||
// update_time stale check
|
||||
double now = get_engine_time(); // need to find this — e.g., 0x????
|
||||
if (now - p->update_time < 60.0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void sweep_once() {
|
||||
if (env_skip_sweep()) return;
|
||||
// Walk all CPhysicsObj instances...
|
||||
CPhysicsObj* victim = nullptr;
|
||||
for (each CPhysicsObj p) {
|
||||
if (is_truly_abandoned(p)) { victim = p; break; } // ONLY ONE
|
||||
}
|
||||
if (!victim) return;
|
||||
|
||||
logf("SWEEP destroying CPhysicsObj @ 0x%p (state=0x%08x)", victim, victim->state);
|
||||
dump_physobj((uintptr_t)victim); // pre-destroy forensics
|
||||
__try {
|
||||
CPHYSICSOBJ_DESTROY(victim, 0);
|
||||
((void(__fastcall*)(void*, void*))OP_DELETE)(victim, 0);
|
||||
logf("SWEEP ok");
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
logf("SWEEP exception — abandoning sweep this scan");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Known unknowns to resolve before coding
|
||||
|
||||
1. **Engine time global address** — for the stale-`update_time` check
|
||||
2. **`weenie_obj` offset** — need to read acclient.h carefully or sample dumps
|
||||
3. **State-bit meanings** — which bits indicate "in active processing"
|
||||
4. **Does `operator delete` of a CPhysicsObj that already had Destroy() called work?** —
|
||||
Destroy probably tears down state but may not free `this`.
|
||||
5. **What if the object is mid-iteration in some other code?** —
|
||||
destroying it would leave dangling iterators. Need to check the
|
||||
render loop / update loop doesn't have outstanding refs.
|
||||
|
||||
These are NOT minor — getting any wrong = v13-class crash.
|
||||
|
||||
## Recommended path
|
||||
|
||||
1. **Iter 4a (logging-only):** add the harder predicates (`movement_manager`,
|
||||
`weenie_obj`, `update_time` stale, state mask). Log candidate count
|
||||
passing the harder set. Compare to iter-3 triple count. If much
|
||||
smaller, predicates are stricter and we have higher confidence.
|
||||
2. **Iter 4b (sample-first):** dump 3 candidates that pass the hard
|
||||
set every scan. Verify they look genuinely abandoned across multiple
|
||||
scans.
|
||||
3. **Iter 4c (destroy 1 per hour, not per scan):** initial mutation
|
||||
test at the slowest possible rate. Soak 8h+ before declaring safe.
|
||||
4. **Iter 4d (destroy N per scan, where N = current candidate count):**
|
||||
only after 4c passes 24h soak.
|
||||
|
||||
This is a 3-day minimum process if everything goes right. If a v13-class
|
||||
crash happens anywhere, restart from 4a with a redesigned predicate.
|
||||
|
||||
## Decision gate
|
||||
|
||||
Per the soak data on Unkle Leo:
|
||||
- triple candidate growth: ~5/5min = 1/min
|
||||
- After 1 hour without sweep: ~60 abandoned physobjs added
|
||||
- After 24h: ~1440 abandoned
|
||||
- At ~1KB heap state per physobj: ~1.4 MB/day from this exact predicate
|
||||
|
||||
Compare to the agent's CObjCell-family estimate of 7-8 MB/hr. The
|
||||
triple subset is much smaller than the agent's total. The harder
|
||||
predicates will be smaller still.
|
||||
|
||||
**Question for the decision-maker (the human):** is recovering
|
||||
~1-2 MB/day per active client worth a v13-class risk? Given the
|
||||
project's 5-day soak target is already met without iter 4, **the
|
||||
honest answer is probably NO** — iter 4 buys marginal improvement
|
||||
at meaningful risk.
|
||||
|
||||
If the goal is 10-day uptime for heavy looters, iter 4 might help
|
||||
but the residual is dominated by other classes (CObjCell, gm*UI
|
||||
recycle pool, palette outside v3b's scope).
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Defer iter 4 indefinitely.** Iter 3 instrumentation gives us data
|
||||
to argue for or against. The DLL form's basic patches (v3b/v5/v11/v14)
|
||||
are what produces the soak win. Adding sweep is high-risk,
|
||||
low-marginal-reward.
|
||||
|
||||
Keep this document for future reference if a future analyst decides
|
||||
the residual leak warrants the risk.
|
||||
72
dll/leakfix/stable/src.iter3/thunks.cpp
Normal file
72
dll/leakfix/stable/src.iter3/thunks.cpp
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// thunks.cpp — runtime replacements called by AC into our DLL
|
||||
#include "thunks.h"
|
||||
#include "ac_addrs.h"
|
||||
|
||||
// ===== v5 — replacement PurgeResource for RenderSurface / RenderTexture =====
|
||||
//
|
||||
// Vtable slots use thiscall (ECX = this). MSVC __fastcall(arg1, arg2)
|
||||
// receives arg1 in ECX and arg2 in EDX. EDX is scratch from the caller
|
||||
// and isn't used, so we make it an unused parameter.
|
||||
//
|
||||
// Effect: instead of the no-op stub `mov al,1; ret`, we now actually
|
||||
// call Destroy() on the resource (frees its D3D handle + heap state)
|
||||
// then return 1 so PurgeOldResources marks it cleanly purged.
|
||||
|
||||
typedef void (__fastcall *destroy_fn_t)(void* self, void* edx_unused);
|
||||
|
||||
extern "C" int __fastcall purge_rendersurface_thunk(void* self, void* /*edx*/) {
|
||||
((destroy_fn_t)ac::V5_RS_DESTROY_VA)(self, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
extern "C" int __fastcall purge_rendertexture_thunk(void* self, void* /*edx*/) {
|
||||
((destroy_fn_t)ac::V5_RT_DESTROY_VA)(self, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ===== v14 — CEnvCell::Destroy ClipPlaneList cleanup =====
|
||||
//
|
||||
// EoR's CEnvCell::Destroy contains an 18-byte cleanup block at
|
||||
// 0x0052E661 that only zeros cplane_num without freeing the underlying
|
||||
// ClipPlaneList object. We replace those 18 bytes with a 5-byte
|
||||
// JMP rel32 into the naked thunk below + 13 NOPs.
|
||||
//
|
||||
// Register context at entry (preserved from caller):
|
||||
// esi = `this` (CEnvCell)
|
||||
// ebx = 0 (cleared earlier in Destroy — relied on by the original
|
||||
// buggy `mov [eax], ebx`)
|
||||
// edi/ebp = live in surrounding loop
|
||||
//
|
||||
// On exit, we JMP to V14_RESUME_VA (the instruction immediately after
|
||||
// the 18-byte block).
|
||||
|
||||
extern "C" __declspec(naked) void v14_clipplane_cleanup_thunk() {
|
||||
__asm {
|
||||
pushad ; preserve everything
|
||||
mov edi, [esi + 0xDC] ; outer ClipPlaneList wrapper ptr
|
||||
test edi, edi
|
||||
jz done
|
||||
mov ecx, [edi] ; inner ClipPlaneList ptr
|
||||
test ecx, ecx
|
||||
jz free_outer
|
||||
// Free the inner ClipPlaneList properly
|
||||
push ecx
|
||||
mov eax, ac::V14_CLIPPLANELIST_DTOR
|
||||
call eax ; ClipPlaneList::~ClipPlaneList (thiscall)
|
||||
pop ecx
|
||||
push ecx
|
||||
mov eax, ac::V14_OPERATOR_DELETE
|
||||
call eax ; operator delete(inner)
|
||||
add esp, 4
|
||||
free_outer:
|
||||
push edi
|
||||
mov eax, ac::V14_OPERATOR_DELETE_ARR
|
||||
call eax ; operator delete[](outer)
|
||||
add esp, 4
|
||||
mov dword ptr [esi + 0xDC], 0 ; clear back-pointer
|
||||
done:
|
||||
popad
|
||||
push ac::V14_RESUME_VA ; jmp to resume point
|
||||
ret
|
||||
}
|
||||
}
|
||||
15
dll/leakfix/stable/src.iter3/thunks.h
Normal file
15
dll/leakfix/stable/src.iter3/thunks.h
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// thunks.h — replacement functions called by patched code paths
|
||||
#pragma once
|
||||
|
||||
extern "C" {
|
||||
|
||||
// v5 replacement vtable slot 2 functions. __thiscall so vtable call ABI matches.
|
||||
int __fastcall purge_rendersurface_thunk(void* self, void* /*edx_unused*/);
|
||||
int __fastcall purge_rendertexture_thunk(void* self, void* /*edx_unused*/);
|
||||
|
||||
// v14 — naked thunk JMPed to from 0x0052E661.
|
||||
// Saves regs, frees inner ClipPlaneList, frees outer wrapper, clears the
|
||||
// back-pointer at [esi+0xDC], restores regs, jumps to V14_RESUME_VA.
|
||||
void v14_clipplane_cleanup_thunk();
|
||||
|
||||
} // extern "C"
|
||||
36
dll/leakfix/stable/src.stable/ac_addrs.h
Normal file
36
dll/leakfix/stable/src.stable/ac_addrs.h
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// ac_addrs.h — EoR acclient.exe addresses used by leakfix.dll
|
||||
// Verified against acclient.exe v0.0.11.6096 EoR (Jan 2017).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
||||
namespace ac {
|
||||
|
||||
// ===== v3b — palette over-increment NOP =====
|
||||
constexpr uintptr_t V3B_SITE_1 = 0x0053EFFE; // inc dword [ecx+24] -> NOP NOP NOP
|
||||
constexpr uintptr_t V3B_SITE_2 = 0x0053F19C; // inc dword [esi+24] -> NOP NOP NOP
|
||||
|
||||
// ===== v5 — RenderSurface/Texture PurgeResource override =====
|
||||
constexpr uintptr_t V5_RS_VTABLE_SLOT_2 = 0x0079A684; // RenderSurface vtable +0x08
|
||||
constexpr uintptr_t V5_RT_VTABLE_SLOT_2 = 0x0079C1A0; // RenderTexture vtable +0x08
|
||||
constexpr uintptr_t V5_NOOP_STUB_VA = 0x004154A0; // expected original (mov al,1; ret)
|
||||
constexpr uintptr_t V5_RS_DESTROY_VA = 0x00444540; // RenderSurface::Destroy
|
||||
constexpr uintptr_t V5_RT_DESTROY_VA = 0x0044C4F0; // RenderTexture::Destroy
|
||||
|
||||
// ===== v11 — NULL-check guards =====
|
||||
constexpr uintptr_t V11_SITE_1_VA = 0x00587126; // delete_contents JMP retarget
|
||||
constexpr uintptr_t V11_SITE_2_VA = 0x005E565D; // ~GXTri3Mesh slot 0 NULL-check
|
||||
|
||||
// ===== v12 — unpacker validator + dispatch redirect =====
|
||||
constexpr uintptr_t V12_VALIDATOR_VA = 0x00526A45; // overwrite 11-NOP pad + 18 bytes
|
||||
constexpr uintptr_t V12_DISPATCH_VA = 0x007C92C8; // dispatch table entry (4 bytes)
|
||||
constexpr uintptr_t V12_OLD_FUNC_VA = 0x00526A50; // original unpacker entry
|
||||
// Dispatch points at validator (V12_VALIDATOR_VA) instead
|
||||
|
||||
// ===== v14 — CEnvCell::Destroy ClipPlaneList leak =====
|
||||
constexpr uintptr_t V14_PATCH_SITE_VA = 0x0052E661; // 18-byte leak block
|
||||
constexpr uintptr_t V14_RESUME_VA = 0x0052E673; // continue here after thunk
|
||||
constexpr uintptr_t V14_CLIPPLANELIST_DTOR = 0x0053C760; // ClipPlaneList::~ClipPlaneList
|
||||
constexpr uintptr_t V14_OPERATOR_DELETE = 0x005DF15E; // operator delete
|
||||
constexpr uintptr_t V14_OPERATOR_DELETE_ARR = 0x005DF164; // operator delete[]
|
||||
|
||||
} // namespace ac
|
||||
103
dll/leakfix/stable/src.stable/dllmain.cpp
Normal file
103
dll/leakfix/stable/src.stable/dllmain.cpp
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
// dllmain.cpp — leakfix.dll entry point
|
||||
//
|
||||
// iter-5 (2026-05-20): patch application is deferred to a worker
|
||||
// thread that sleeps ~30 seconds before applying. This matches the
|
||||
// timing of the Python runtime patcher (tools/fleet_monitor.sh),
|
||||
// which lands its patches well after Decal init is complete. The
|
||||
// PE-import-load → DllMain → immediate apply_all_patches sequence
|
||||
// used in iter-1..iter-4 lost the race with Decal's own hook
|
||||
// installation and crashed some accounts (Unkle Leo most reliably).
|
||||
// See feedback_dll_load_order_conflict.md.
|
||||
//
|
||||
// The SEH crash handler is still installed immediately so any
|
||||
// crashes during the 30s window (including ones caused by Decal)
|
||||
// are captured.
|
||||
#include <windows.h>
|
||||
#include "logging.h"
|
||||
#include "patches.h"
|
||||
#include "instr.h"
|
||||
|
||||
namespace {
|
||||
|
||||
bool g_skip_patches = false;
|
||||
|
||||
DWORD WINAPI deferred_patch_thread(LPVOID) {
|
||||
// Give Decal / UtilityBelt time to finish their own hook
|
||||
// installation before we lay our patches on top. 30s matches
|
||||
// the Python cascade's observed-good timing.
|
||||
Sleep(30000);
|
||||
leakfix::logf("deferred-patch thread: woke after 30s, applying patches");
|
||||
if (g_skip_patches) {
|
||||
leakfix::logf("LEAKFIX_NO_PATCHES=1 — skipping patch application (diagnostic mode)");
|
||||
} else {
|
||||
leakfix::apply_all_patches();
|
||||
}
|
||||
leakfix::instr_start_periodic_scan();
|
||||
return 0;
|
||||
}
|
||||
|
||||
void on_attach() {
|
||||
char dll_path[MAX_PATH] = {0};
|
||||
GetModuleFileNameA((HMODULE)GetModuleHandleA("leakfix.dll"), dll_path, MAX_PATH);
|
||||
|
||||
// Log next to the DLL itself
|
||||
char log_path[MAX_PATH] = {0};
|
||||
char* slash = nullptr;
|
||||
for (char* p = dll_path; *p; ++p) if (*p == '\\' || *p == '/') slash = p;
|
||||
if (slash) {
|
||||
size_t prefix = (size_t)(slash - dll_path) + 1;
|
||||
memcpy(log_path, dll_path, prefix);
|
||||
strcpy(log_path + prefix, "leakfix.log");
|
||||
} else {
|
||||
strcpy(log_path, "leakfix.log");
|
||||
}
|
||||
leakfix::log_init(log_path);
|
||||
leakfix::logf("attach: dll=%s (iter-5 deferred-patch)", dll_path);
|
||||
|
||||
// Kill switch — set LEAKFIX_NO_PATCHES=1 in env to skip patch
|
||||
// application. Instrumentation still runs. Used to bisect crashes:
|
||||
// if the no-patches variant survives, the patches are the trigger.
|
||||
char no_patches[8] = {0};
|
||||
GetEnvironmentVariableA("LEAKFIX_NO_PATCHES", no_patches, sizeof(no_patches));
|
||||
g_skip_patches = (no_patches[0] == '1');
|
||||
|
||||
// Crash handler installed immediately so the 30s pre-patch window
|
||||
// is still observable if Decal/UB crashes the process.
|
||||
leakfix::instr_install_crash_handler();
|
||||
|
||||
HANDLE h = CreateThread(nullptr, 0, deferred_patch_thread, nullptr, 0, nullptr);
|
||||
if (h) {
|
||||
CloseHandle(h);
|
||||
leakfix::logf("deferred-patch thread spawned");
|
||||
} else {
|
||||
// CreateThread failure is extraordinary — fall back to the
|
||||
// old in-DllMain apply so we at least get patches eventually.
|
||||
leakfix::logf("CreateThread failed (err=%lu) — falling back to in-DllMain apply",
|
||||
GetLastError());
|
||||
if (!g_skip_patches) leakfix::apply_all_patches();
|
||||
leakfix::instr_start_periodic_scan();
|
||||
}
|
||||
}
|
||||
} // anon
|
||||
|
||||
// Exported stub so PE-import-table patching of acclient.exe can name
|
||||
// a real symbol for the OS loader to resolve. Doing nothing is fine —
|
||||
// just being present in the DLL is what makes the import valid.
|
||||
extern "C" __declspec(dllexport) int __cdecl leakfix_init() {
|
||||
return 0;
|
||||
}
|
||||
|
||||
extern "C" BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID) {
|
||||
switch (reason) {
|
||||
case DLL_PROCESS_ATTACH:
|
||||
DisableThreadLibraryCalls(h);
|
||||
on_attach();
|
||||
break;
|
||||
case DLL_PROCESS_DETACH:
|
||||
leakfix::instr_stop_periodic_scan();
|
||||
leakfix::logf("detach");
|
||||
leakfix::log_close();
|
||||
break;
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
568
dll/leakfix/stable/src.stable/instr.cpp
Normal file
568
dll/leakfix/stable/src.stable/instr.cpp
Normal file
|
|
@ -0,0 +1,568 @@
|
|||
// instr.cpp — crash dump + periodic instance-count scanner
|
||||
#include "instr.h"
|
||||
#include "logging.h"
|
||||
#include "ac_addrs.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <dbghelp.h>
|
||||
#include <cstdio>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
|
||||
#pragma comment(lib, "dbghelp.lib")
|
||||
|
||||
namespace {
|
||||
|
||||
// ===== Crash-dump =====
|
||||
|
||||
LPTOP_LEVEL_EXCEPTION_FILTER g_prev_filter = nullptr;
|
||||
|
||||
// Returns directory of our DLL, ending with backslash.
|
||||
void get_dll_dir(char* out, size_t out_sz) {
|
||||
HMODULE h = nullptr;
|
||||
GetModuleHandleExA(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS |
|
||||
GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT,
|
||||
(LPCSTR)&get_dll_dir, &h);
|
||||
char path[MAX_PATH] = {0};
|
||||
GetModuleFileNameA(h, path, MAX_PATH);
|
||||
size_t n = strlen(path);
|
||||
while (n > 0 && path[n-1] != '\\' && path[n-1] != '/') --n;
|
||||
if (n >= out_sz) n = out_sz - 1;
|
||||
memcpy(out, path, n);
|
||||
out[n] = '\0';
|
||||
}
|
||||
|
||||
LONG WINAPI top_level_handler(EXCEPTION_POINTERS* ep) {
|
||||
char dir[MAX_PATH]; get_dll_dir(dir, sizeof(dir));
|
||||
SYSTEMTIME st; GetLocalTime(&st);
|
||||
char path[MAX_PATH];
|
||||
std::snprintf(path, sizeof(path),
|
||||
"%sleakfix_crash_%lu_%04d%02d%02d_%02d%02d%02d.dmp",
|
||||
dir, GetCurrentProcessId(),
|
||||
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond);
|
||||
|
||||
leakfix::logf("UNHANDLED EXCEPTION code=0x%08lx addr=0x%p — writing %s",
|
||||
ep->ExceptionRecord->ExceptionCode,
|
||||
ep->ExceptionRecord->ExceptionAddress, path);
|
||||
|
||||
HANDLE hf = CreateFileA(path, GENERIC_WRITE, 0, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (hf != INVALID_HANDLE_VALUE) {
|
||||
MINIDUMP_EXCEPTION_INFORMATION mei{};
|
||||
mei.ThreadId = GetCurrentThreadId();
|
||||
mei.ExceptionPointers = ep;
|
||||
mei.ClientPointers = FALSE;
|
||||
// MiniDumpNormal does NOT walk the heap — safer when crash is
|
||||
// heap-corruption. Includes thread stacks + module info which
|
||||
// is enough to identify the fault site. Previous attempts with
|
||||
// MiniDumpWithDataSegs produced 0-byte files because the
|
||||
// corrupted heap broke MiniDumpWriteDump mid-walk.
|
||||
BOOL ok = MiniDumpWriteDump(GetCurrentProcess(), GetCurrentProcessId(),
|
||||
hf, MiniDumpNormal, &mei, nullptr, nullptr);
|
||||
DWORD dump_err = ok ? 0 : GetLastError();
|
||||
CloseHandle(hf);
|
||||
leakfix::logf("MiniDumpWriteDump: %s err=%lu", ok ? "ok" : "failed", dump_err);
|
||||
// If even MiniDumpNormal fails, write a tiny text file with the
|
||||
// bare minimum info — exception code, address, register state.
|
||||
if (!ok) {
|
||||
char fallback_path[MAX_PATH];
|
||||
std::snprintf(fallback_path, sizeof(fallback_path), "%s.txt", path);
|
||||
HANDLE tf = CreateFileA(fallback_path, GENERIC_WRITE, 0, nullptr,
|
||||
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
if (tf != INVALID_HANDLE_VALUE) {
|
||||
CONTEXT* ctx = ep->ContextRecord;
|
||||
char body[512];
|
||||
int n = std::snprintf(body, sizeof(body),
|
||||
"exception_code=0x%08lx\n"
|
||||
"exception_address=0x%p\n"
|
||||
"eax=0x%08lx ebx=0x%08lx ecx=0x%08lx edx=0x%08lx\n"
|
||||
"esi=0x%08lx edi=0x%08lx ebp=0x%08lx esp=0x%08lx\n"
|
||||
"eip=0x%08lx eflags=0x%08lx\n",
|
||||
ep->ExceptionRecord->ExceptionCode,
|
||||
ep->ExceptionRecord->ExceptionAddress,
|
||||
ctx->Eax, ctx->Ebx, ctx->Ecx, ctx->Edx,
|
||||
ctx->Esi, ctx->Edi, ctx->Ebp, ctx->Esp,
|
||||
ctx->Eip, ctx->EFlags);
|
||||
DWORD written = 0;
|
||||
WriteFile(tf, body, n, &written, nullptr);
|
||||
CloseHandle(tf);
|
||||
leakfix::logf("fallback text crash info -> %s", fallback_path);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
leakfix::logf("CreateFile failed err=%lu", GetLastError());
|
||||
}
|
||||
|
||||
// Chain to previous filter (lets Windows do its thing too).
|
||||
if (g_prev_filter) return g_prev_filter(ep);
|
||||
return EXCEPTION_CONTINUE_SEARCH;
|
||||
}
|
||||
|
||||
// ===== Periodic scan =====
|
||||
|
||||
struct vt_entry { const char* name; uintptr_t vt; };
|
||||
|
||||
// Mirrors what tools/snapshot_compare.py tracks.
|
||||
const vt_entry VTABLES[] = {
|
||||
{"uiitem", 0x007C0498},
|
||||
{"palette", 0x007CAA08},
|
||||
{"cphysicsobj", 0x007C78EC},
|
||||
{"renderSurf", 0x0079A67C},
|
||||
{"renderSurfD3D", 0x00801A94},
|
||||
{"renderTexD3D", 0x00801A18},
|
||||
{"csurface", 0x007CA4DC},
|
||||
{"imgtex", 0x007CAB04},
|
||||
{"cgfxobj", 0x007CA418},
|
||||
{"d3dxmesh", 0x007ED3B0},
|
||||
{"position", 0x00797910},
|
||||
};
|
||||
constexpr size_t VT_COUNT = sizeof(VTABLES) / sizeof(VTABLES[0]);
|
||||
|
||||
HANDLE g_scan_thread = nullptr;
|
||||
HANDLE g_stop_event = nullptr;
|
||||
int g_prev_counts[VT_COUNT] = {0};
|
||||
bool g_have_prev = false;
|
||||
DWORD g_scan_count = 0;
|
||||
|
||||
// CPhysicsObj field offsets — VERIFIED against acclient.h + live sample
|
||||
// dumps from iter 2. LongHashData base = 12 bytes (vtable +
|
||||
// hash_next + id), then CPhysicsObj-specific fields follow.
|
||||
constexpr int CPHYS_VT_OFF = 0x00; // CPhysicsObj vt = 0x007C78EC
|
||||
constexpr int CPHYS_HASH_NEXT_OFF = 0x04; // LongHashData chain
|
||||
constexpr int CPHYS_ID_OFF = 0x08; // hash key
|
||||
constexpr int CPHYS_PARTARRAY_OFF = 0x10; // CPartArray*
|
||||
constexpr int CPHYS_PARENT_OFF = 0x40; // CPhysicsObj* parent
|
||||
constexpr int CPHYS_CHILDREN_OFF = 0x44; // CHILDLIST*
|
||||
constexpr int CPHYS_POSITION_OFF = 0x48; // Position (72 B)
|
||||
constexpr int CPHYS_CELL_OFF = 0x90; // CObjCell*
|
||||
constexpr int CPHYS_STATE_OFF = 0xA8; // unsigned int state
|
||||
constexpr int CPHYS_TRANSTATE_OFF = 0xAC; // unsigned int transient_state
|
||||
constexpr int CPHYS_MOVMGR_OFF = 0xC4; // MovementManager*
|
||||
constexpr int CPHYS_POSMGR_OFF = 0xC8; // PositionManager*
|
||||
constexpr int CPHYS_UPDATETIME_OFF= 0xD4; // long double (8 B at runtime)
|
||||
constexpr int CPHYS_HOOKS_OFF = 0x100; // PhysicsObjHook*
|
||||
constexpr int CPHYS_WEENIEOBJ_OFF = 0x12C; // CWeenieObject*
|
||||
|
||||
// Iter-4 destroy target
|
||||
constexpr uintptr_t CPHYSICSOBJ_DESTROY_VA = 0x005145D0;
|
||||
|
||||
// Engine global time pointer (TODO: verify offset/symbol) — used for
|
||||
// stale-update_time check. For now we just compare against a wall-time
|
||||
// estimate.
|
||||
|
||||
constexpr int CPHYS_SAMPLE_BYTES = 384; // dump first 384 B per sample (incl weenie_obj at +0x12C)
|
||||
constexpr int CPHYS_SAMPLE_COUNT = 3; // samples per scan
|
||||
|
||||
// Find up to `want` instances of vt 0x007C78EC; return how many recorded.
|
||||
int find_physobj_samples(uintptr_t* out_addrs, int want) {
|
||||
int found = 0;
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t addr = 0;
|
||||
while (found < want && VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) {
|
||||
bool committed = mbi.State == MEM_COMMIT;
|
||||
bool priv = mbi.Type == MEM_PRIVATE;
|
||||
DWORD prot = mbi.Protect & 0xFF;
|
||||
bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE);
|
||||
if (committed && priv && readable) {
|
||||
__try {
|
||||
const uint32_t* p = (const uint32_t*)mbi.BaseAddress;
|
||||
size_t n = mbi.RegionSize / 4;
|
||||
for (size_t i = 0; i < n && found < want; ++i) {
|
||||
if (p[i] == 0x007C78EC) {
|
||||
out_addrs[found++] = (uintptr_t)(p + i);
|
||||
}
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {}
|
||||
}
|
||||
uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (next <= addr) break;
|
||||
addr = next;
|
||||
if (addr >= 0x80000000) break;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
void dump_physobj(uintptr_t obj_addr) {
|
||||
uint32_t buf[CPHYS_SAMPLE_BYTES / 4];
|
||||
__try {
|
||||
memcpy(buf, (const void*)obj_addr, CPHYS_SAMPLE_BYTES);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
leakfix::logf("sample physobj @ 0x%08x: read failed", obj_addr);
|
||||
return;
|
||||
}
|
||||
// Log as 4 DWORDs per line. Annotate each DWORD with a hint:
|
||||
// '0' if null, 'V' if it equals the CPhysicsObj vt (suggests parent),
|
||||
// 'C' if it equals a known CObjCell-family vt (suggests cell),
|
||||
// 'i' if in image range (.text/.rdata),
|
||||
// 'h' if in typical heap range.
|
||||
leakfix::logf("sample physobj @ 0x%08x:", obj_addr);
|
||||
for (int row = 0; row < CPHYS_SAMPLE_BYTES / 16; ++row) {
|
||||
char line[256];
|
||||
int n = std::snprintf(line, sizeof(line), " +0x%02x: ", row * 16);
|
||||
for (int col = 0; col < 4; ++col) {
|
||||
uint32_t v = buf[row * 4 + col];
|
||||
char tag = ' ';
|
||||
if (v == 0) tag = '0';
|
||||
else if (v == 0x007C78EC) tag = 'V'; // CPhysicsObj vt
|
||||
else if (v == 0x007ED3B0 || v == 0x007CA4F0) tag = 'C'; // CObjCell-family vt
|
||||
else if (v >= 0x00400000 && v < 0x00800000) tag = 'i'; // image range
|
||||
else if (v >= 0x01000000 && v < 0x40000000) tag = 'h'; // heap
|
||||
n += std::snprintf(line + n, sizeof(line) - n, "%08x%c ", v, tag);
|
||||
}
|
||||
leakfix::logf("%s", line);
|
||||
}
|
||||
}
|
||||
|
||||
// ITER 3 — for each CPhysicsObj instance, evaluate "safe to destroy"
|
||||
// predicates. Read-only — no mutation.
|
||||
//
|
||||
// Predicates evaluated (all on the SAME instance):
|
||||
// P_no_parent = (parent == NULL)
|
||||
// P_no_cell = (cell == NULL)
|
||||
// P_orphan_hash = (hash_next == NULL) [not linked into any hash chain]
|
||||
// P_no_part_array = (part_array == NULL)
|
||||
//
|
||||
// Logged combinations:
|
||||
// n_total = all CPhysicsObj found
|
||||
// n_no_parent = count where parent==NULL
|
||||
// n_no_cell = count where cell==NULL
|
||||
// n_orphan_hash = count where hash_next==NULL
|
||||
// n_both = count where parent==NULL AND cell==NULL (STRICT)
|
||||
// n_triple = count where parent==NULL AND cell==NULL AND hash_next==NULL
|
||||
//
|
||||
// The "triple" set is the candidate set we'd target for sweep — physobjs
|
||||
// that are not in any hash chain, not in any cell, and have no parent.
|
||||
// They are by definition unreachable from the engine's active state.
|
||||
void evaluate_predicates_and_dump_candidates() {
|
||||
int n_total = 0;
|
||||
int n_no_parent = 0;
|
||||
int n_no_cell = 0;
|
||||
int n_orphan_hash = 0;
|
||||
int n_both = 0;
|
||||
int n_triple = 0;
|
||||
|
||||
constexpr int CANDIDATE_DUMP_MAX = 3;
|
||||
uintptr_t candidates[CANDIDATE_DUMP_MAX] = {0};
|
||||
int n_candidates_recorded = 0;
|
||||
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t addr = 0;
|
||||
while (VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) {
|
||||
bool committed = mbi.State == MEM_COMMIT;
|
||||
bool priv = mbi.Type == MEM_PRIVATE;
|
||||
DWORD prot = mbi.Protect & 0xFF;
|
||||
bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE);
|
||||
if (committed && priv && readable) {
|
||||
__try {
|
||||
const uint32_t* p = (const uint32_t*)mbi.BaseAddress;
|
||||
size_t n = mbi.RegionSize / 4;
|
||||
// Walk DWORDs; need at least 0xA8/4 == 42 DWORDs of headroom
|
||||
// for the deepest field we read.
|
||||
size_t need = (CPHYS_STATE_OFF / 4) + 1;
|
||||
if (n < need) {
|
||||
addr = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (addr >= 0x80000000) break;
|
||||
continue;
|
||||
}
|
||||
for (size_t i = 0; i + need < n; ++i) {
|
||||
if (p[i] != 0x007C78EC) continue; // not CPhysicsObj
|
||||
++n_total;
|
||||
const uint32_t hash_next = p[i + (CPHYS_HASH_NEXT_OFF / 4)];
|
||||
const uint32_t parent = p[i + (CPHYS_PARENT_OFF / 4)];
|
||||
const uint32_t cell = p[i + (CPHYS_CELL_OFF / 4)];
|
||||
|
||||
const bool no_parent = (parent == 0);
|
||||
const bool no_cell = (cell == 0);
|
||||
const bool orphan_hash = (hash_next == 0);
|
||||
|
||||
if (no_parent) ++n_no_parent;
|
||||
if (no_cell) ++n_no_cell;
|
||||
if (orphan_hash) ++n_orphan_hash;
|
||||
if (no_parent && no_cell) ++n_both;
|
||||
if (no_parent && no_cell && orphan_hash) {
|
||||
++n_triple;
|
||||
if (n_candidates_recorded < CANDIDATE_DUMP_MAX) {
|
||||
candidates[n_candidates_recorded++] = (uintptr_t)(p + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {}
|
||||
}
|
||||
uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (next <= addr) break;
|
||||
addr = next;
|
||||
if (addr >= 0x80000000) break;
|
||||
}
|
||||
|
||||
leakfix::logf("predicates: total=%d no_parent=%d no_cell=%d orphan_hash=%d "
|
||||
"both=%d triple=%d (candidates for sweep)",
|
||||
n_total, n_no_parent, n_no_cell, n_orphan_hash,
|
||||
n_both, n_triple);
|
||||
|
||||
// Dump first few strict candidates so we can sanity-check they look
|
||||
// like genuinely abandoned objects (no weenie, no part_array, etc.).
|
||||
for (int i = 0; i < n_candidates_recorded; ++i) {
|
||||
leakfix::logf("--- strict candidate %d ---", i);
|
||||
dump_physobj(candidates[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// =====================================================================
|
||||
// ITER 4 — STRICT PREDICATES + DESTRUCTION SWEEP
|
||||
// =====================================================================
|
||||
//
|
||||
// Strict-candidate definition (all must hold):
|
||||
// - parent == NULL (+0x40)
|
||||
// - cell == NULL (+0x90)
|
||||
// - hash_next == NULL (+0x04)
|
||||
// - movement_manager == NULL (+0xC4)
|
||||
// - weenie_obj == NULL (+0x12C)
|
||||
// - state has no high bits set (high 16 bits == 0)
|
||||
// - transient_state == 0 (+0xAC)
|
||||
//
|
||||
// 4a: log candidates, no mutation
|
||||
// 4b: destroy 1 candidate per scan via CPhysicsObj::Destroy then
|
||||
// operator delete
|
||||
|
||||
typedef void (__fastcall *physobj_destroy_fn)(void* self, void* edx);
|
||||
|
||||
bool is_strict_abandoned(const uint32_t* p) {
|
||||
if (p[CPHYS_PARENT_OFF / 4] != 0) return false;
|
||||
if (p[CPHYS_CELL_OFF / 4] != 0) return false;
|
||||
if (p[CPHYS_HASH_NEXT_OFF / 4] != 0) return false;
|
||||
if (p[CPHYS_MOVMGR_OFF / 4] != 0) return false;
|
||||
if (p[CPHYS_WEENIEOBJ_OFF / 4] != 0) return false;
|
||||
const uint32_t state = p[CPHYS_STATE_OFF / 4];
|
||||
if ((state & 0xFFFF0000) != 0) return false;
|
||||
if (p[CPHYS_TRANSTATE_OFF / 4] != 0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
DWORD g_total_destroyed = 0;
|
||||
bool g_sweep_armed = false; // becomes true after first 2 scans without
|
||||
// crashes — gives time to observe candidates
|
||||
// first, then mutate on subsequent scans
|
||||
|
||||
void evaluate_strict_and_optionally_destroy() {
|
||||
int n_total = 0;
|
||||
int n_strict = 0;
|
||||
uintptr_t first_candidate = 0;
|
||||
uintptr_t first_dump_candidate = 0;
|
||||
uintptr_t second_dump_candidate = 0;
|
||||
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t addr = 0;
|
||||
while (VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) {
|
||||
bool committed = mbi.State == MEM_COMMIT;
|
||||
bool priv = mbi.Type == MEM_PRIVATE;
|
||||
DWORD prot = mbi.Protect & 0xFF;
|
||||
bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE);
|
||||
if (committed && priv && readable) {
|
||||
__try {
|
||||
const uint32_t* p = (const uint32_t*)mbi.BaseAddress;
|
||||
size_t n = mbi.RegionSize / 4;
|
||||
// Need to read up to +0x12C / 4 + 1 = 76 DWORDs
|
||||
size_t need = (CPHYS_WEENIEOBJ_OFF / 4) + 1;
|
||||
if (n < need) {
|
||||
addr = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (addr >= 0x80000000) break;
|
||||
continue;
|
||||
}
|
||||
for (size_t i = 0; i + need < n; ++i) {
|
||||
if (p[i] != 0x007C78EC) continue;
|
||||
++n_total;
|
||||
if (!is_strict_abandoned(p + i)) continue;
|
||||
++n_strict;
|
||||
if (!first_candidate) first_candidate = (uintptr_t)(p + i);
|
||||
if (!first_dump_candidate) first_dump_candidate = (uintptr_t)(p + i);
|
||||
else if (!second_dump_candidate) second_dump_candidate = (uintptr_t)(p + i);
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {}
|
||||
}
|
||||
uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (next <= addr) break;
|
||||
addr = next;
|
||||
if (addr >= 0x80000000) break;
|
||||
}
|
||||
|
||||
leakfix::logf("strict-predicates: total=%d strict_candidates=%d destroyed_so_far=%lu armed=%d",
|
||||
n_total, n_strict, g_total_destroyed, g_sweep_armed ? 1 : 0);
|
||||
|
||||
// Always dump 2 candidates for forensic comparison
|
||||
if (first_dump_candidate) {
|
||||
leakfix::logf("--- strict-4 candidate #1 (would-destroy first) ---");
|
||||
dump_physobj(first_dump_candidate);
|
||||
}
|
||||
if (second_dump_candidate) {
|
||||
leakfix::logf("--- strict-4 candidate #2 ---");
|
||||
dump_physobj(second_dump_candidate);
|
||||
}
|
||||
|
||||
// Iter 4b — actual destruction. Only after sweep is armed AND a
|
||||
// candidate is available. Throttled to 1 destruction per scan.
|
||||
// Kill switch: env LEAKFIX_NO_SWEEP=1 disables.
|
||||
if (!g_sweep_armed) {
|
||||
// arm on second consecutive scan with candidates
|
||||
static int warmup_scans = 0;
|
||||
if (n_strict > 0) ++warmup_scans;
|
||||
if (warmup_scans >= 2) {
|
||||
g_sweep_armed = true;
|
||||
leakfix::logf("SWEEP ARMED — next scan will destroy 1 candidate per cycle");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
char no_sweep[8] = {0};
|
||||
GetEnvironmentVariableA("LEAKFIX_NO_SWEEP", no_sweep, sizeof(no_sweep));
|
||||
if (no_sweep[0] == '1') {
|
||||
leakfix::logf("LEAKFIX_NO_SWEEP=1 — destruction skipped this cycle");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!first_candidate) {
|
||||
leakfix::logf("sweep: no candidate this cycle");
|
||||
return;
|
||||
}
|
||||
|
||||
leakfix::logf("SWEEP destroying CPhysicsObj @ 0x%p", (void*)first_candidate);
|
||||
__try {
|
||||
physobj_destroy_fn destroy = (physobj_destroy_fn)CPHYSICSOBJ_DESTROY_VA;
|
||||
destroy((void*)first_candidate, 0);
|
||||
// After Destroy, the object's owned state should be cleaned up.
|
||||
// We do NOT free the CPhysicsObj itself yet — Destroy may already
|
||||
// mark it dead and downstream code might still need to deref a
|
||||
// few fields (vtable, etc.). Conservative.
|
||||
++g_total_destroyed;
|
||||
leakfix::logf("SWEEP ok — total destroyed=%lu", g_total_destroyed);
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
leakfix::logf("SWEEP exception inside Destroy(); abandoning sweep this cycle. "
|
||||
"Set LEAKFIX_NO_SWEEP=1 in env to disable.");
|
||||
}
|
||||
}
|
||||
|
||||
void scan_once() {
|
||||
int counts[VT_COUNT] = {0};
|
||||
|
||||
MEMORY_BASIC_INFORMATION mbi;
|
||||
uintptr_t addr = 0;
|
||||
int region_count = 0;
|
||||
while (VirtualQuery((LPCVOID)addr, &mbi, sizeof(mbi))) {
|
||||
bool committed = mbi.State == MEM_COMMIT;
|
||||
bool priv = mbi.Type == MEM_PRIVATE;
|
||||
DWORD prot = mbi.Protect & 0xFF;
|
||||
bool readable = (prot == PAGE_READWRITE || prot == PAGE_EXECUTE_READWRITE);
|
||||
if (committed && priv && readable) {
|
||||
++region_count;
|
||||
// Scan in DWORD steps. In-process so direct deref is fine, but
|
||||
// wrap with SEH in case of races / partial commits.
|
||||
__try {
|
||||
const uint32_t* p = (const uint32_t*)mbi.BaseAddress;
|
||||
size_t n = mbi.RegionSize / 4;
|
||||
for (size_t i = 0; i < n; ++i) {
|
||||
uint32_t v = p[i];
|
||||
for (size_t k = 0; k < VT_COUNT; ++k) {
|
||||
if (v == VTABLES[k].vt) { ++counts[k]; break; }
|
||||
}
|
||||
}
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
// skip this region — happens occasionally on volatile pages
|
||||
}
|
||||
}
|
||||
uintptr_t next = (uintptr_t)mbi.BaseAddress + mbi.RegionSize;
|
||||
if (next <= addr) break;
|
||||
addr = next;
|
||||
if (addr >= 0x80000000) break; // 32-bit user-space ceiling
|
||||
}
|
||||
|
||||
++g_scan_count;
|
||||
|
||||
char buf[1024];
|
||||
int n = std::snprintf(buf, sizeof(buf), "scan#%lu: regions=%d", g_scan_count, region_count);
|
||||
for (size_t k = 0; k < VT_COUNT && n < (int)sizeof(buf) - 16; ++k) {
|
||||
n += std::snprintf(buf + n, sizeof(buf) - n, " %s=%d", VTABLES[k].name, counts[k]);
|
||||
}
|
||||
leakfix::logf("%s", buf);
|
||||
|
||||
// Deltas vs previous scan (5-min interval after first scan)
|
||||
if (g_have_prev) {
|
||||
char dbuf[1024];
|
||||
int dn = std::snprintf(dbuf, sizeof(dbuf), "delta:");
|
||||
bool any_nonzero = false;
|
||||
for (size_t k = 0; k < VT_COUNT && dn < (int)sizeof(dbuf) - 32; ++k) {
|
||||
int d = counts[k] - g_prev_counts[k];
|
||||
if (d != 0) any_nonzero = true;
|
||||
dn += std::snprintf(dbuf + dn, sizeof(dbuf) - dn, " %s=%+d", VTABLES[k].name, d);
|
||||
}
|
||||
if (any_nonzero) leakfix::logf("%s", dbuf);
|
||||
}
|
||||
for (size_t k = 0; k < VT_COUNT; ++k) g_prev_counts[k] = counts[k];
|
||||
g_have_prev = true;
|
||||
|
||||
// Useful ratios (sanity-check our structural understanding):
|
||||
// position / cphysicsobj should be near 10 for active clients per v17
|
||||
// diag (each CPhysicsPart has 2 Positions; ~5 parts per physobj)
|
||||
// cphysicsobj count is what the sweep would target if/when we add it
|
||||
if (counts[2] > 0) { // cphysicsobj index = 2
|
||||
double pos_ratio = (double)counts[10] / (double)counts[2]; // position
|
||||
leakfix::logf("ratio: position/cphysicsobj=%.2f (idle ~7, active ~10)", pos_ratio);
|
||||
}
|
||||
|
||||
// ITER 2 — sample CPhysicsObj field layouts (every-other scan).
|
||||
if (counts[2] > 0 && (g_scan_count % 2 == 1)) {
|
||||
uintptr_t samples[CPHYS_SAMPLE_COUNT] = {0};
|
||||
int got = find_physobj_samples(samples, CPHYS_SAMPLE_COUNT);
|
||||
leakfix::logf("physobj-samples: got=%d of %d", got, CPHYS_SAMPLE_COUNT);
|
||||
for (int i = 0; i < got; ++i) dump_physobj(samples[i]);
|
||||
}
|
||||
|
||||
// ITER 3 — predicate evaluation across ALL CPhysicsObj instances
|
||||
// (every scan; full walk reused from scan_once iteration is too
|
||||
// costly so we do a second pass dedicated to this).
|
||||
if (counts[2] > 0) {
|
||||
evaluate_predicates_and_dump_candidates();
|
||||
}
|
||||
|
||||
// ITER 4 — strict predicates + (after 2 warmup scans) destruction.
|
||||
if (counts[2] > 0) {
|
||||
evaluate_strict_and_optionally_destroy();
|
||||
}
|
||||
}
|
||||
|
||||
DWORD WINAPI scan_loop(LPVOID) {
|
||||
// First scan ~30s after start so the process is warmed up.
|
||||
if (WaitForSingleObject(g_stop_event, 30000) == WAIT_OBJECT_0) return 0;
|
||||
for (;;) {
|
||||
scan_once();
|
||||
// Scan every 5 minutes thereafter.
|
||||
if (WaitForSingleObject(g_stop_event, 5 * 60 * 1000) == WAIT_OBJECT_0) return 0;
|
||||
}
|
||||
}
|
||||
|
||||
} // anon
|
||||
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
void instr_install_crash_handler() {
|
||||
g_prev_filter = SetUnhandledExceptionFilter(top_level_handler);
|
||||
logf("instr: crash handler installed");
|
||||
}
|
||||
|
||||
void instr_start_periodic_scan() {
|
||||
if (g_scan_thread) return;
|
||||
g_stop_event = CreateEventA(nullptr, TRUE, FALSE, nullptr);
|
||||
g_scan_thread = CreateThread(nullptr, 0, scan_loop, nullptr, 0, nullptr);
|
||||
logf("instr: periodic scanner started (interval=5min)");
|
||||
}
|
||||
|
||||
void instr_stop_periodic_scan() {
|
||||
if (!g_scan_thread) return;
|
||||
if (g_stop_event) SetEvent(g_stop_event);
|
||||
WaitForSingleObject(g_scan_thread, 5000);
|
||||
CloseHandle(g_scan_thread);
|
||||
if (g_stop_event) CloseHandle(g_stop_event);
|
||||
g_scan_thread = nullptr;
|
||||
g_stop_event = nullptr;
|
||||
}
|
||||
|
||||
} // namespace leakfix
|
||||
19
dll/leakfix/stable/src.stable/instr.h
Normal file
19
dll/leakfix/stable/src.stable/instr.h
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// instr.h — instrumentation features for leakfix.dll
|
||||
#pragma once
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
// Install SetUnhandledExceptionFilter so any unhandled native exception
|
||||
// writes a clean minidump to leakfix_crash_<pid>_<timestamp>.dmp next
|
||||
// to the DLL, then chains to Windows' default handling.
|
||||
void instr_install_crash_handler();
|
||||
|
||||
// Start a background thread that scans memory every 5 minutes,
|
||||
// counts known leak-class vtable instances, and appends a one-line
|
||||
// summary to leakfix.log.
|
||||
void instr_start_periodic_scan();
|
||||
|
||||
// Stop the periodic scan thread (called from DLL_PROCESS_DETACH).
|
||||
void instr_stop_periodic_scan();
|
||||
|
||||
} // namespace leakfix
|
||||
74
dll/leakfix/stable/src.stable/logging.cpp
Normal file
74
dll/leakfix/stable/src.stable/logging.cpp
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#include "logging.h"
|
||||
#include <windows.h>
|
||||
#include <cstdio>
|
||||
#include <cstdarg>
|
||||
#include <cstring>
|
||||
|
||||
namespace {
|
||||
HANDLE g_log = INVALID_HANDLE_VALUE;
|
||||
CRITICAL_SECTION g_cs;
|
||||
bool g_cs_inited = false;
|
||||
|
||||
void ensure_cs() {
|
||||
if (!g_cs_inited) {
|
||||
InitializeCriticalSection(&g_cs);
|
||||
g_cs_inited = true;
|
||||
}
|
||||
}
|
||||
|
||||
void write_line(const char* s, size_t len) {
|
||||
if (g_log == INVALID_HANDLE_VALUE) return;
|
||||
DWORD written = 0;
|
||||
WriteFile(g_log, s, (DWORD)len, &written, nullptr);
|
||||
}
|
||||
}
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
void log_init(const char* path) {
|
||||
ensure_cs();
|
||||
EnterCriticalSection(&g_cs);
|
||||
if (g_log != INVALID_HANDLE_VALUE) { LeaveCriticalSection(&g_cs); return; }
|
||||
g_log = CreateFileA(path, FILE_APPEND_DATA, FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr);
|
||||
SetFilePointer(g_log, 0, nullptr, FILE_END);
|
||||
LeaveCriticalSection(&g_cs);
|
||||
logf("===== leakfix.dll loaded (pid=%lu) =====", GetCurrentProcessId());
|
||||
}
|
||||
|
||||
void log_close() {
|
||||
ensure_cs();
|
||||
EnterCriticalSection(&g_cs);
|
||||
if (g_log != INVALID_HANDLE_VALUE) {
|
||||
CloseHandle(g_log);
|
||||
g_log = INVALID_HANDLE_VALUE;
|
||||
}
|
||||
LeaveCriticalSection(&g_cs);
|
||||
}
|
||||
|
||||
void logf(const char* fmt, ...) {
|
||||
ensure_cs();
|
||||
char buf[1024];
|
||||
SYSTEMTIME st;
|
||||
GetLocalTime(&st);
|
||||
int n = std::snprintf(buf, sizeof(buf),
|
||||
"[%04d-%02d-%02d %02d:%02d:%02d.%03d] ",
|
||||
st.wYear, st.wMonth, st.wDay, st.wHour, st.wMinute, st.wSecond, st.wMilliseconds);
|
||||
va_list ap; va_start(ap, fmt);
|
||||
int m = std::vsnprintf(buf + n, sizeof(buf) - n - 2, fmt, ap);
|
||||
va_end(ap);
|
||||
if (m < 0) m = 0;
|
||||
int total = n + m;
|
||||
if (total >= (int)sizeof(buf) - 1) total = sizeof(buf) - 2;
|
||||
buf[total] = '\n';
|
||||
buf[total + 1] = '\0';
|
||||
|
||||
EnterCriticalSection(&g_cs);
|
||||
write_line(buf, (size_t)total + 1);
|
||||
LeaveCriticalSection(&g_cs);
|
||||
|
||||
// Also forward to debugger if attached
|
||||
OutputDebugStringA(buf);
|
||||
}
|
||||
|
||||
} // namespace leakfix
|
||||
8
dll/leakfix/stable/src.stable/logging.h
Normal file
8
dll/leakfix/stable/src.stable/logging.h
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
// logging.h — minimal file-based logging for leakfix.dll
|
||||
#pragma once
|
||||
|
||||
namespace leakfix {
|
||||
void log_init(const char* path); // open log file (relative to acclient.exe dir if not absolute)
|
||||
void log_close();
|
||||
void logf(const char* fmt, ...); // appends a timestamped line
|
||||
} // namespace leakfix
|
||||
195
dll/leakfix/stable/src.stable/patches.cpp
Normal file
195
dll/leakfix/stable/src.stable/patches.cpp
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
// patches.cpp — apply v3b, v5, v11, v12, v14 inline to our own process
|
||||
#include "patches.h"
|
||||
#include "logging.h"
|
||||
#include "thunks.h"
|
||||
#include "ac_addrs.h"
|
||||
|
||||
#include <windows.h>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <cstdio>
|
||||
|
||||
using namespace leakfix;
|
||||
|
||||
namespace {
|
||||
|
||||
// Copy `data` to absolute address `addr`, flipping page protection.
|
||||
bool write_memory(uintptr_t addr, const void* data, size_t len) {
|
||||
DWORD old = 0;
|
||||
if (!VirtualProtect((void*)addr, len, PAGE_EXECUTE_READWRITE, &old)) {
|
||||
logf(" VirtualProtect(0x%08x, %u) failed err=%lu", addr, (unsigned)len, GetLastError());
|
||||
return false;
|
||||
}
|
||||
std::memcpy((void*)addr, data, len);
|
||||
DWORD restored = 0;
|
||||
VirtualProtect((void*)addr, len, old, &restored);
|
||||
FlushInstructionCache(GetCurrentProcess(), (void*)addr, len);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool bytes_equal(uintptr_t addr, const void* expected, size_t len) {
|
||||
return std::memcmp((void*)addr, expected, len) == 0;
|
||||
}
|
||||
|
||||
void hexdump_short(uintptr_t addr, size_t n, char* out, size_t out_sz) {
|
||||
const uint8_t* p = (const uint8_t*)addr;
|
||||
size_t used = 0;
|
||||
for (size_t i = 0; i < n && used + 3 < out_sz; ++i) {
|
||||
used += (size_t)std::snprintf(out + used, out_sz - used, "%02x", p[i]);
|
||||
}
|
||||
}
|
||||
|
||||
// Write a 5-byte JMP rel32 at `at` targeting `target`. Pad remaining bytes
|
||||
// up to `total_replace` with 0x90 NOPs.
|
||||
bool write_jmp_rel32(uintptr_t at, uintptr_t target, size_t total_replace) {
|
||||
uint8_t buf[64];
|
||||
if (total_replace > sizeof(buf)) return false;
|
||||
int32_t rel = (int32_t)(target - (at + 5));
|
||||
buf[0] = 0xE9;
|
||||
std::memcpy(buf + 1, &rel, 4);
|
||||
std::memset(buf + 5, 0x90, total_replace - 5);
|
||||
return write_memory(at, buf, total_replace);
|
||||
}
|
||||
|
||||
} // anon namespace
|
||||
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
// ===== v3b =====
|
||||
bool apply_v3b() {
|
||||
const uint8_t nops[3] = { 0x90, 0x90, 0x90 };
|
||||
const uint8_t orig1[3] = { 0xff, 0x40, 0x24 }; // inc dword [eax+0x24]
|
||||
const uint8_t orig2[3] = { 0xff, 0x46, 0x24 }; // inc dword [esi+0x24]
|
||||
|
||||
if (bytes_equal(ac::V3B_SITE_1, nops, 3) && bytes_equal(ac::V3B_SITE_2, nops, 3)) {
|
||||
logf("v3b: already applied");
|
||||
return true;
|
||||
}
|
||||
if (!bytes_equal(ac::V3B_SITE_1, orig1, 3)) {
|
||||
char h[16]; hexdump_short(ac::V3B_SITE_1, 3, h, sizeof(h));
|
||||
logf("v3b: site1 unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
if (!bytes_equal(ac::V3B_SITE_2, orig2, 3)) {
|
||||
char h[16]; hexdump_short(ac::V3B_SITE_2, 3, h, sizeof(h));
|
||||
logf("v3b: site2 unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
write_memory(ac::V3B_SITE_1, nops, 3);
|
||||
write_memory(ac::V3B_SITE_2, nops, 3);
|
||||
logf("v3b: applied (NOPs at 0x%08x + 0x%08x)", ac::V3B_SITE_1, ac::V3B_SITE_2);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v5 =====
|
||||
bool apply_v5() {
|
||||
uintptr_t rs_cur = *(uintptr_t*)ac::V5_RS_VTABLE_SLOT_2;
|
||||
uintptr_t rt_cur = *(uintptr_t*)ac::V5_RT_VTABLE_SLOT_2;
|
||||
uintptr_t rs_new = (uintptr_t)&purge_rendersurface_thunk;
|
||||
uintptr_t rt_new = (uintptr_t)&purge_rendertexture_thunk;
|
||||
|
||||
bool rs_done = (rs_cur != ac::V5_NOOP_STUB_VA);
|
||||
bool rt_done = (rt_cur != ac::V5_NOOP_STUB_VA);
|
||||
|
||||
if (!rs_done) {
|
||||
if (rs_cur != ac::V5_NOOP_STUB_VA) {
|
||||
logf("v5: RS slot already redirected (0x%08x); not overwriting", rs_cur);
|
||||
} else {
|
||||
write_memory(ac::V5_RS_VTABLE_SLOT_2, &rs_new, 4);
|
||||
logf("v5: RS vtable slot -> 0x%08x", rs_new);
|
||||
}
|
||||
} else {
|
||||
logf("v5: RS slot already non-default (0x%08x) — skipping", rs_cur);
|
||||
}
|
||||
|
||||
if (!rt_done) {
|
||||
write_memory(ac::V5_RT_VTABLE_SLOT_2, &rt_new, 4);
|
||||
logf("v5: RT vtable slot -> 0x%08x", rt_new);
|
||||
} else {
|
||||
logf("v5: RT slot already non-default (0x%08x) — skipping", rt_cur);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v11 =====
|
||||
bool apply_v11() {
|
||||
// Site 1: 2-byte rewrite of a JMP target
|
||||
const uint8_t s1_orig[2] = { 0xEB, 0x07 };
|
||||
const uint8_t s1_patched[2] = { 0xEB, 0x42 };
|
||||
// Site 2: 9-byte rewrite for ~GXTri3Mesh NULL-check
|
||||
const uint8_t s2_orig[9] = { 0x8B, 0x08, 0x50, 0xFF, 0x51, 0x08, 0x89, 0x5E, 0x08 };
|
||||
const uint8_t s2_patched[9] = { 0x89, 0x5E, 0x08, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
|
||||
|
||||
if (bytes_equal(ac::V11_SITE_1_VA, s1_patched, 2)) {
|
||||
logf("v11: site1 already patched");
|
||||
} else if (bytes_equal(ac::V11_SITE_1_VA, s1_orig, 2)) {
|
||||
write_memory(ac::V11_SITE_1_VA, s1_patched, 2);
|
||||
logf("v11: site1 patched");
|
||||
} else {
|
||||
char h[8]; hexdump_short(ac::V11_SITE_1_VA, 2, h, sizeof(h));
|
||||
logf("v11: site1 unexpected %s — skipping", h);
|
||||
}
|
||||
|
||||
if (bytes_equal(ac::V11_SITE_2_VA, s2_patched, 9)) {
|
||||
logf("v11: site2 already patched");
|
||||
} else if (bytes_equal(ac::V11_SITE_2_VA, s2_orig, 9)) {
|
||||
write_memory(ac::V11_SITE_2_VA, s2_patched, 9);
|
||||
logf("v11: site2 patched");
|
||||
} else {
|
||||
char h[24]; hexdump_short(ac::V11_SITE_2_VA, 9, h, sizeof(h));
|
||||
logf("v11: site2 unexpected %s — skipping", h);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== v12 RETIRED =====
|
||||
// v12 was designed against post-Decal in-memory bytes that don't match
|
||||
// the on-disk binary. When the leakfix.dll loads at PE-import time (before
|
||||
// Decal init), it sees the truly-original bytes and v12 would refuse.
|
||||
// When the Python patcher ran later against a running PID, it saw
|
||||
// Decal-modified bytes that happened to match its expected pattern and
|
||||
// applied a duplicate range check — adding no protection beyond what
|
||||
// Decal already provides. Neither variant prevented the Shadow/Frank
|
||||
// stale-heap-pointer crashes. v12 removed.
|
||||
|
||||
// ===== v14 =====
|
||||
bool apply_v14() {
|
||||
static const uint8_t ORIG[18] = {
|
||||
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, // mov eax, [esi+0xDC]
|
||||
0x3B, 0xC3, // cmp eax, ebx
|
||||
0x74, 0x08, // jz +8
|
||||
0x8B, 0x00, // mov eax, [eax]
|
||||
0x3B, 0xC3, // cmp eax, ebx
|
||||
0x74, 0x02, // jz +2
|
||||
0x89, 0x18, // mov [eax], ebx <- the broken "fix"
|
||||
};
|
||||
|
||||
// If already patched, the first byte is 0xE9 (our JMP).
|
||||
uint8_t cur = *(uint8_t*)ac::V14_PATCH_SITE_VA;
|
||||
if (cur == 0xE9) {
|
||||
logf("v14: already applied");
|
||||
return true;
|
||||
}
|
||||
if (!bytes_equal(ac::V14_PATCH_SITE_VA, ORIG, 18)) {
|
||||
char h[40]; hexdump_short(ac::V14_PATCH_SITE_VA, 18, h, sizeof(h));
|
||||
logf("v14: site unexpected bytes %s — refusing", h);
|
||||
return false;
|
||||
}
|
||||
uintptr_t thunk_va = (uintptr_t)&v14_clipplane_cleanup_thunk;
|
||||
if (!write_jmp_rel32(ac::V14_PATCH_SITE_VA, thunk_va, 18)) return false;
|
||||
logf("v14: applied (JMP rel32 -> 0x%08x, thunk in leakfix.dll)", thunk_va);
|
||||
return true;
|
||||
}
|
||||
|
||||
bool apply_all_patches() {
|
||||
bool ok = true;
|
||||
ok &= apply_v3b();
|
||||
ok &= apply_v11();
|
||||
ok &= apply_v5();
|
||||
ok &= apply_v14();
|
||||
logf("all-patches result: %s", ok ? "OK" : "PARTIAL");
|
||||
return ok;
|
||||
}
|
||||
|
||||
} // namespace leakfix
|
||||
16
dll/leakfix/stable/src.stable/patches.h
Normal file
16
dll/leakfix/stable/src.stable/patches.h
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
#pragma once
|
||||
|
||||
namespace leakfix {
|
||||
|
||||
// Returns true if all patches applied (or were already in place).
|
||||
bool apply_all_patches();
|
||||
|
||||
bool apply_v3b();
|
||||
bool apply_v5();
|
||||
bool apply_v11();
|
||||
bool apply_v14();
|
||||
// v12 retired: it was a duplicate of Decal's built-in unpacker range
|
||||
// check and didn't address the actual Shadow/Frank crash class
|
||||
// (stale-heap-pointer in cursor). See memory.
|
||||
|
||||
} // namespace leakfix
|
||||
222
dll/leakfix/stable/src.stable/sweep_design.md
Normal file
222
dll/leakfix/stable/src.stable/sweep_design.md
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
# Iter 4 — CPhysicsObj sweep design (DRAFT, NOT YET IMPLEMENTED)
|
||||
|
||||
## Goal
|
||||
|
||||
Periodically destroy abandoned CPhysicsObj instances to recover the
|
||||
residual leak documented in §6.1 of REPORT.md. **Highest-risk patch
|
||||
class** (physics-state mutation, same risk profile as v13 which
|
||||
killed Larsson at 98 min). Long soak per change is mandatory.
|
||||
|
||||
## What iter 3 told us
|
||||
|
||||
After 13 minutes on Unkle Leo (PID 16044), a typical scan shows:
|
||||
|
||||
```
|
||||
total=971 no_parent=546 no_cell=278 orphan_hash=697 both=234 triple=111
|
||||
```
|
||||
|
||||
So ~11% of all CPhysicsObj instances pass the strict triple predicate.
|
||||
On a fresh client triple count is ~100 (startup residual). Growth is
|
||||
+1-2 candidates per minute during normal play.
|
||||
|
||||
Strict-candidate sample dumps confirm:
|
||||
- `parent`, `cell`, `hash_next` all NULL ✓
|
||||
- `part_array` non-NULL (heap allocation that should be freed)
|
||||
- `shadow_objects.data` non-NULL (heap allocation that should be freed)
|
||||
- `state` has small bits set (e.g., 0x00000414 — normal active flags)
|
||||
|
||||
This matches the v17 owner-vtable diagnostic's "abandoned but heap state
|
||||
still allocated" pattern.
|
||||
|
||||
## Candidate destruction call
|
||||
|
||||
The engine already has correct teardown:
|
||||
|
||||
```c
|
||||
// EoR 0x005145D0 — CPhysicsObj::Destroy
|
||||
void __thiscall CPhysicsObj::Destroy(CPhysicsObj* this);
|
||||
```
|
||||
|
||||
Per the v17 owner-diag, `CPhysicsObj::Destroy` correctly tears down
|
||||
all owned heap state (`CPartArray::DestroyParts`, etc.). The leak is
|
||||
that it's never **called** on these abandoned objects.
|
||||
|
||||
After Destroy, the CPhysicsObj itself (~408 bytes) needs to be freed
|
||||
via `operator delete`.
|
||||
|
||||
## Predicate hardening (BEFORE we destroy anything)
|
||||
|
||||
The triple predicate may not be conservative enough. Additional
|
||||
checks before destroy:
|
||||
|
||||
1. **`update_time` is stale** — field at +0xD4 is a long double
|
||||
(timestamp). If less than `now() - 60s`, the object hasn't been
|
||||
touched in a minute. Compare via TimeGetTime() or similar global.
|
||||
2. **`state` is not "currently active"** — need to identify which
|
||||
bits indicate "being processed." For now, skip if state has any
|
||||
high bit set.
|
||||
3. **`weenie_obj == NULL`** — at +0x?? (need to verify offset).
|
||||
If a weenie-object still owns this physobj, the engine considers
|
||||
it alive even if other tracking is gone.
|
||||
4. **`movement_manager == NULL`** — at +0xC4 per acclient.h
|
||||
(LongHashData base 12 + ... + 0xB8 should be it). If there's an
|
||||
active mover, the object is in flight.
|
||||
5. **`hooks == NULL`** — at +0xE? — animation hooks pending.
|
||||
|
||||
The candidate must pass ALL these AND the iter-3 triple predicate.
|
||||
Stricter than iter 3.
|
||||
|
||||
## Safety protocol
|
||||
|
||||
1. **Throttle:** max 1 destruction per scan cycle (5 min). Even if 100
|
||||
candidates qualify, destroy ONE per scan. Surface latent bugs slowly.
|
||||
2. **Sample-first:** for the first 2 hours, LOG candidate addresses
|
||||
but do NOT destroy. Verify the candidates stay candidates over
|
||||
multiple scans (i.e., they're not transient).
|
||||
3. **Per-scan budget:** if a destruction succeeds, log address +
|
||||
pre-destroy field dump. If process crashes after, we have the last
|
||||
destroyed object for forensics.
|
||||
4. **Kill switch:** check `LEAKFIX_NO_SWEEP=1` env var at scan start.
|
||||
If set, skip destruction. Default ON (=destroy) once code lands.
|
||||
5. **Initial test target:** Unkle Leo (current designated guinea pig
|
||||
per CLAUDE.md). One client only. 4-hour soak before declaring safe.
|
||||
6. **Failure recovery:** if Unkle Leo crashes within 1 hour of
|
||||
destruction logic enabling, set the env var, restart with sweep
|
||||
disabled, mark iter-4 as failed in memory, do not retry without
|
||||
redesign.
|
||||
|
||||
## Implementation outline (when ready)
|
||||
|
||||
```cpp
|
||||
struct CPhysicsObj {
|
||||
void* vtable; // +0x00
|
||||
void* hash_next; // +0x04
|
||||
uint32_t id; // +0x08
|
||||
void* netblob_list; // +0x0C
|
||||
void* part_array; // +0x10
|
||||
// ... 12 bytes of player_vector/distance/CYpt
|
||||
void* sound_table; // +0x28
|
||||
uint32_t pad_exam; // +0x2C
|
||||
void* script_manager; // +0x30
|
||||
void* physics_script; // +0x34
|
||||
uint32_t default_script; // +0x38
|
||||
float script_intensity;// +0x3C
|
||||
void* parent; // +0x40
|
||||
void* children; // +0x44
|
||||
char position[72]; // +0x48
|
||||
void* cell; // +0x90
|
||||
uint32_t num_shadow; // +0x94
|
||||
char shadow_arr[16]; // +0x98 — DArray
|
||||
uint32_t state; // +0xA8
|
||||
uint32_t transient_state; // +0xAC
|
||||
// ... floats
|
||||
void* movement_manager;// +0xC4
|
||||
void* position_manager;// +0xC8
|
||||
int last_move_auto; // +0xCC
|
||||
int jumped_frame; // +0xD0
|
||||
double update_time; // +0xD4 (8 bytes)
|
||||
// ...
|
||||
void* weenie_obj; // +0x?? TBD
|
||||
};
|
||||
|
||||
typedef void (__fastcall *destroy_fn_t)(CPhysicsObj* self, void* edx);
|
||||
constexpr destroy_fn_t CPHYSICSOBJ_DESTROY = (destroy_fn_t)0x005145D0;
|
||||
constexpr void* OP_DELETE = (void*)0x005DF15E;
|
||||
|
||||
bool is_truly_abandoned(CPhysicsObj* p) {
|
||||
if (p->parent) return false;
|
||||
if (p->cell) return false;
|
||||
if (p->hash_next) return false;
|
||||
if (p->movement_manager) return false;
|
||||
// state mask: bits 0..15 are flags we tolerate; high bits suggest
|
||||
// active processing
|
||||
if ((p->state & 0xFFFF0000) != 0) return false;
|
||||
if (p->weenie_obj) return false; // need offset verified
|
||||
// update_time stale check
|
||||
double now = get_engine_time(); // need to find this — e.g., 0x????
|
||||
if (now - p->update_time < 60.0) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
void sweep_once() {
|
||||
if (env_skip_sweep()) return;
|
||||
// Walk all CPhysicsObj instances...
|
||||
CPhysicsObj* victim = nullptr;
|
||||
for (each CPhysicsObj p) {
|
||||
if (is_truly_abandoned(p)) { victim = p; break; } // ONLY ONE
|
||||
}
|
||||
if (!victim) return;
|
||||
|
||||
logf("SWEEP destroying CPhysicsObj @ 0x%p (state=0x%08x)", victim, victim->state);
|
||||
dump_physobj((uintptr_t)victim); // pre-destroy forensics
|
||||
__try {
|
||||
CPHYSICSOBJ_DESTROY(victim, 0);
|
||||
((void(__fastcall*)(void*, void*))OP_DELETE)(victim, 0);
|
||||
logf("SWEEP ok");
|
||||
} __except (EXCEPTION_EXECUTE_HANDLER) {
|
||||
logf("SWEEP exception — abandoning sweep this scan");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Known unknowns to resolve before coding
|
||||
|
||||
1. **Engine time global address** — for the stale-`update_time` check
|
||||
2. **`weenie_obj` offset** — need to read acclient.h carefully or sample dumps
|
||||
3. **State-bit meanings** — which bits indicate "in active processing"
|
||||
4. **Does `operator delete` of a CPhysicsObj that already had Destroy() called work?** —
|
||||
Destroy probably tears down state but may not free `this`.
|
||||
5. **What if the object is mid-iteration in some other code?** —
|
||||
destroying it would leave dangling iterators. Need to check the
|
||||
render loop / update loop doesn't have outstanding refs.
|
||||
|
||||
These are NOT minor — getting any wrong = v13-class crash.
|
||||
|
||||
## Recommended path
|
||||
|
||||
1. **Iter 4a (logging-only):** add the harder predicates (`movement_manager`,
|
||||
`weenie_obj`, `update_time` stale, state mask). Log candidate count
|
||||
passing the harder set. Compare to iter-3 triple count. If much
|
||||
smaller, predicates are stricter and we have higher confidence.
|
||||
2. **Iter 4b (sample-first):** dump 3 candidates that pass the hard
|
||||
set every scan. Verify they look genuinely abandoned across multiple
|
||||
scans.
|
||||
3. **Iter 4c (destroy 1 per hour, not per scan):** initial mutation
|
||||
test at the slowest possible rate. Soak 8h+ before declaring safe.
|
||||
4. **Iter 4d (destroy N per scan, where N = current candidate count):**
|
||||
only after 4c passes 24h soak.
|
||||
|
||||
This is a 3-day minimum process if everything goes right. If a v13-class
|
||||
crash happens anywhere, restart from 4a with a redesigned predicate.
|
||||
|
||||
## Decision gate
|
||||
|
||||
Per the soak data on Unkle Leo:
|
||||
- triple candidate growth: ~5/5min = 1/min
|
||||
- After 1 hour without sweep: ~60 abandoned physobjs added
|
||||
- After 24h: ~1440 abandoned
|
||||
- At ~1KB heap state per physobj: ~1.4 MB/day from this exact predicate
|
||||
|
||||
Compare to the agent's CObjCell-family estimate of 7-8 MB/hr. The
|
||||
triple subset is much smaller than the agent's total. The harder
|
||||
predicates will be smaller still.
|
||||
|
||||
**Question for the decision-maker (the human):** is recovering
|
||||
~1-2 MB/day per active client worth a v13-class risk? Given the
|
||||
project's 5-day soak target is already met without iter 4, **the
|
||||
honest answer is probably NO** — iter 4 buys marginal improvement
|
||||
at meaningful risk.
|
||||
|
||||
If the goal is 10-day uptime for heavy looters, iter 4 might help
|
||||
but the residual is dominated by other classes (CObjCell, gm*UI
|
||||
recycle pool, palette outside v3b's scope).
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Defer iter 4 indefinitely.** Iter 3 instrumentation gives us data
|
||||
to argue for or against. The DLL form's basic patches (v3b/v5/v11/v14)
|
||||
are what produces the soak win. Adding sweep is high-risk,
|
||||
low-marginal-reward.
|
||||
|
||||
Keep this document for future reference if a future analyst decides
|
||||
the residual leak warrants the risk.
|
||||
72
dll/leakfix/stable/src.stable/thunks.cpp
Normal file
72
dll/leakfix/stable/src.stable/thunks.cpp
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
// thunks.cpp — runtime replacements called by AC into our DLL
|
||||
#include "thunks.h"
|
||||
#include "ac_addrs.h"
|
||||
|
||||
// ===== v5 — replacement PurgeResource for RenderSurface / RenderTexture =====
|
||||
//
|
||||
// Vtable slots use thiscall (ECX = this). MSVC __fastcall(arg1, arg2)
|
||||
// receives arg1 in ECX and arg2 in EDX. EDX is scratch from the caller
|
||||
// and isn't used, so we make it an unused parameter.
|
||||
//
|
||||
// Effect: instead of the no-op stub `mov al,1; ret`, we now actually
|
||||
// call Destroy() on the resource (frees its D3D handle + heap state)
|
||||
// then return 1 so PurgeOldResources marks it cleanly purged.
|
||||
|
||||
typedef void (__fastcall *destroy_fn_t)(void* self, void* edx_unused);
|
||||
|
||||
extern "C" int __fastcall purge_rendersurface_thunk(void* self, void* /*edx*/) {
|
||||
((destroy_fn_t)ac::V5_RS_DESTROY_VA)(self, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
extern "C" int __fastcall purge_rendertexture_thunk(void* self, void* /*edx*/) {
|
||||
((destroy_fn_t)ac::V5_RT_DESTROY_VA)(self, 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// ===== v14 — CEnvCell::Destroy ClipPlaneList cleanup =====
|
||||
//
|
||||
// EoR's CEnvCell::Destroy contains an 18-byte cleanup block at
|
||||
// 0x0052E661 that only zeros cplane_num without freeing the underlying
|
||||
// ClipPlaneList object. We replace those 18 bytes with a 5-byte
|
||||
// JMP rel32 into the naked thunk below + 13 NOPs.
|
||||
//
|
||||
// Register context at entry (preserved from caller):
|
||||
// esi = `this` (CEnvCell)
|
||||
// ebx = 0 (cleared earlier in Destroy — relied on by the original
|
||||
// buggy `mov [eax], ebx`)
|
||||
// edi/ebp = live in surrounding loop
|
||||
//
|
||||
// On exit, we JMP to V14_RESUME_VA (the instruction immediately after
|
||||
// the 18-byte block).
|
||||
|
||||
extern "C" __declspec(naked) void v14_clipplane_cleanup_thunk() {
|
||||
__asm {
|
||||
pushad ; preserve everything
|
||||
mov edi, [esi + 0xDC] ; outer ClipPlaneList wrapper ptr
|
||||
test edi, edi
|
||||
jz done
|
||||
mov ecx, [edi] ; inner ClipPlaneList ptr
|
||||
test ecx, ecx
|
||||
jz free_outer
|
||||
// Free the inner ClipPlaneList properly
|
||||
push ecx
|
||||
mov eax, ac::V14_CLIPPLANELIST_DTOR
|
||||
call eax ; ClipPlaneList::~ClipPlaneList (thiscall)
|
||||
pop ecx
|
||||
push ecx
|
||||
mov eax, ac::V14_OPERATOR_DELETE
|
||||
call eax ; operator delete(inner)
|
||||
add esp, 4
|
||||
free_outer:
|
||||
push edi
|
||||
mov eax, ac::V14_OPERATOR_DELETE_ARR
|
||||
call eax ; operator delete[](outer)
|
||||
add esp, 4
|
||||
mov dword ptr [esi + 0xDC], 0 ; clear back-pointer
|
||||
done:
|
||||
popad
|
||||
push ac::V14_RESUME_VA ; jmp to resume point
|
||||
ret
|
||||
}
|
||||
}
|
||||
15
dll/leakfix/stable/src.stable/thunks.h
Normal file
15
dll/leakfix/stable/src.stable/thunks.h
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// thunks.h — replacement functions called by patched code paths
|
||||
#pragma once
|
||||
|
||||
extern "C" {
|
||||
|
||||
// v5 replacement vtable slot 2 functions. __thiscall so vtable call ABI matches.
|
||||
int __fastcall purge_rendersurface_thunk(void* self, void* /*edx_unused*/);
|
||||
int __fastcall purge_rendertexture_thunk(void* self, void* /*edx_unused*/);
|
||||
|
||||
// v14 — naked thunk JMPed to from 0x0052E661.
|
||||
// Saves regs, frees inner ClipPlaneList, frees outer wrapper, clears the
|
||||
// back-pointer at [esi+0xDC], restores regs, jumps to V14_RESUME_VA.
|
||||
void v14_clipplane_cleanup_thunk();
|
||||
|
||||
} // extern "C"
|
||||
219
dll/leakfix/tools/add_import.py
Normal file
219
dll/leakfix/tools/add_import.py
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
"""add_import.py <input.exe> <output.exe> <dll_name>
|
||||
|
||||
Patch a PE EXE's import table to add a new DLL import.
|
||||
|
||||
The OS loader will pull <dll_name> into the process before the EXE's
|
||||
entry point runs — exactly what we want for leakfix.dll.
|
||||
|
||||
Mechanism:
|
||||
1. Read the PE file.
|
||||
2. Add a new section called ".limport" at the end with:
|
||||
- new IMAGE_IMPORT_DESCRIPTOR array (existing entries + ours + null)
|
||||
- ILT (Import Lookup Table) and IAT for our DLL — both pointing
|
||||
at a single hint/name "LeakfixStub" (any name; doesn't have to
|
||||
exist since loading the DLL is enough to trigger its DllMain).
|
||||
- The DLL name string.
|
||||
- Hint/name table for our exported function.
|
||||
3. Update OptionalHeader.DataDirectory[1] (IMPORT) to point at our
|
||||
new array, with the size covering all entries.
|
||||
4. Write the new file.
|
||||
|
||||
We must pick an export name that exists in leakfix.dll for the loader
|
||||
to resolve at load time, OR we can use ordinal #1 if we export by
|
||||
ordinal. Simplest: have leakfix.dll export a stub function named
|
||||
"leakfix_init" via __declspec(dllexport), and reference that here.
|
||||
"""
|
||||
import struct, sys, os
|
||||
|
||||
PE_MACHINE_I386 = 0x014c
|
||||
|
||||
def u8(b, o): return b[o]
|
||||
def u16(b, o): return struct.unpack_from("<H", b, o)[0]
|
||||
def u32(b, o): return struct.unpack_from("<I", b, o)[0]
|
||||
|
||||
def main():
|
||||
if len(sys.argv) != 4:
|
||||
print(__doc__); sys.exit(1)
|
||||
inp, outp, dll_name = sys.argv[1], sys.argv[2], sys.argv[3]
|
||||
|
||||
with open(inp, "rb") as f:
|
||||
data = bytearray(f.read())
|
||||
|
||||
# 1. Locate headers
|
||||
if data[:2] != b"MZ":
|
||||
print("not a PE file"); sys.exit(2)
|
||||
pe_off = u32(data, 0x3c)
|
||||
if data[pe_off:pe_off+4] != b"PE\0\0":
|
||||
print("PE signature not found"); sys.exit(2)
|
||||
machine = u16(data, pe_off + 4)
|
||||
if machine != PE_MACHINE_I386:
|
||||
print(f"unexpected machine 0x{machine:04x} (want 0x14c = i386)"); sys.exit(2)
|
||||
num_sections = u16(data, pe_off + 6)
|
||||
size_of_optional = u16(data, pe_off + 20)
|
||||
optional_off = pe_off + 24
|
||||
section_table_off = optional_off + size_of_optional
|
||||
|
||||
# PE32 (not PE32+); confirm magic 0x10b
|
||||
if u16(data, optional_off) != 0x010b:
|
||||
print("not PE32 (32-bit) optional header magic"); sys.exit(2)
|
||||
|
||||
image_base = u32(data, optional_off + 28)
|
||||
section_align = u32(data, optional_off + 32)
|
||||
file_align = u32(data, optional_off + 36)
|
||||
size_of_image = u32(data, optional_off + 56)
|
||||
size_of_headers = u32(data, optional_off + 60)
|
||||
data_dir_off = optional_off + 96 # for PE32
|
||||
|
||||
# Existing IMPORT directory
|
||||
imp_rva = u32(data, data_dir_off + 1*8)
|
||||
imp_size = u32(data, data_dir_off + 1*8 + 4)
|
||||
print(f"PE32 image_base=0x{image_base:08x}, sectionAlign=0x{section_align:x}, fileAlign=0x{file_align:x}")
|
||||
print(f"existing IMPORT dir: rva=0x{imp_rva:08x} size={imp_size}")
|
||||
|
||||
# 2. Read all sections
|
||||
sections = []
|
||||
for i in range(num_sections):
|
||||
sh = section_table_off + i * 40
|
||||
name = bytes(data[sh:sh+8]).rstrip(b"\0").decode("ascii", "replace")
|
||||
vsize = u32(data, sh+8)
|
||||
vaddr = u32(data, sh+12)
|
||||
rsize = u32(data, sh+16)
|
||||
roff = u32(data, sh+20)
|
||||
chars = u32(data, sh+36)
|
||||
sections.append({"name":name, "vsize":vsize, "vaddr":vaddr, "rsize":rsize, "roff":roff, "chars":chars, "sh_off":sh})
|
||||
|
||||
# find rva-to-file mapping for IMPORT
|
||||
def rva_to_off(rva):
|
||||
for s in sections:
|
||||
if s["vaddr"] <= rva < s["vaddr"] + max(s["vsize"], s["rsize"]):
|
||||
return s["roff"] + (rva - s["vaddr"])
|
||||
return None
|
||||
|
||||
imp_off = rva_to_off(imp_rva)
|
||||
if imp_off is None: print("can't map import RVA"); sys.exit(2)
|
||||
|
||||
# 3. Count existing import descriptors (each is 20 bytes; terminated by zero descriptor)
|
||||
DESC_SZ = 20
|
||||
existing_descs = bytearray()
|
||||
pos = imp_off
|
||||
while True:
|
||||
d = bytes(data[pos:pos+DESC_SZ])
|
||||
if d == b"\0"*DESC_SZ: break
|
||||
existing_descs += d
|
||||
pos += DESC_SZ
|
||||
n_existing = len(existing_descs) // DESC_SZ
|
||||
print(f"existing imports: {n_existing}")
|
||||
|
||||
# 4. Build new section
|
||||
# Section layout (offsets within section start):
|
||||
# 0x00 new descriptor array: existing descs + our desc + zero terminator
|
||||
# then ILT: one DWORD pointing at name-table; one DWORD zero (terminator)
|
||||
# then IAT: same shape
|
||||
# then name-table: hint(2) + "leakfix_init\0"
|
||||
# then dll-name: "leakfix.dll\0"
|
||||
new_section_align = section_align
|
||||
new_section = bytearray()
|
||||
|
||||
# We don't know final RVAs yet; lay out, then patch RVAs at the end.
|
||||
n_descs = n_existing + 2 # existing + ours + terminator
|
||||
desc_table_size = n_descs * DESC_SZ
|
||||
|
||||
ilt_off = desc_table_size # 2 DWORDs (1 hint+name RVA, 1 terminator)
|
||||
iat_off = ilt_off + 8
|
||||
name_table_off = iat_off + 8
|
||||
func_name = b"leakfix_init\0"
|
||||
# IMAGE_IMPORT_BY_NAME = WORD hint + name
|
||||
name_entry = b"\x00\x00" + func_name
|
||||
if len(name_entry) & 1: name_entry += b"\0"
|
||||
dll_name_off = name_table_off + len(name_entry)
|
||||
dll_name_b = dll_name.encode("ascii") + b"\0"
|
||||
if len(dll_name_b) & 1: dll_name_b += b"\0"
|
||||
|
||||
total_data_size = dll_name_off + len(dll_name_b)
|
||||
|
||||
# Round section size up to fileAlign for raw, sectionAlign for virtual
|
||||
def round_up(v, a): return (v + a - 1) & ~(a - 1)
|
||||
raw_size = round_up(total_data_size, file_align)
|
||||
virt_size = round_up(total_data_size, section_align)
|
||||
|
||||
# Determine new section's RVA: at end of image
|
||||
last_vend = max((s["vaddr"] + round_up(max(s["vsize"], s["rsize"]), section_align)) for s in sections)
|
||||
new_vaddr = round_up(last_vend, section_align)
|
||||
new_roff = len(data) # append to end of file
|
||||
new_roff = round_up(new_roff, file_align)
|
||||
# Pad file up to new_roff
|
||||
if new_roff > len(data):
|
||||
data += b"\0" * (new_roff - len(data))
|
||||
|
||||
# Now we know RVAs. Build section bytes.
|
||||
sec = bytearray(raw_size)
|
||||
|
||||
# Copy existing descriptors verbatim, then append our descriptor, then zero
|
||||
sec[0:len(existing_descs)] = existing_descs
|
||||
our_desc_off = len(existing_descs)
|
||||
# Our descriptor: ILT_RVA, TimeStamp, ForwarderChain, Name_RVA, IAT_RVA
|
||||
our_ilt_rva = new_vaddr + ilt_off
|
||||
our_iat_rva = new_vaddr + iat_off
|
||||
our_name_rva = new_vaddr + dll_name_off
|
||||
name_entry_rva = new_vaddr + name_table_off
|
||||
struct.pack_into("<IIIII", sec, our_desc_off,
|
||||
our_ilt_rva, 0, 0, our_name_rva, our_iat_rva)
|
||||
# Zero terminator after our descriptor — sec is already zeroed
|
||||
|
||||
# ILT/IAT entries (both point at the hint/name)
|
||||
struct.pack_into("<II", sec, ilt_off, name_entry_rva, 0)
|
||||
struct.pack_into("<II", sec, iat_off, name_entry_rva, 0)
|
||||
|
||||
# Name table
|
||||
sec[name_table_off:name_table_off + len(name_entry)] = name_entry
|
||||
# DLL name
|
||||
sec[dll_name_off:dll_name_off + len(dll_name_b)] = dll_name_b
|
||||
|
||||
# Append section bytes to file
|
||||
data += sec
|
||||
|
||||
# 5. Update section table
|
||||
if num_sections + 1 > (size_of_headers - section_table_off + pe_off) // 40:
|
||||
# Not enough room in headers for another section entry. Bail.
|
||||
print("ERROR: no room in PE headers for an additional section entry"); sys.exit(3)
|
||||
|
||||
new_sh = section_table_off + num_sections * 40
|
||||
name_bytes = b".limport"[:8].ljust(8, b"\0")
|
||||
data[new_sh:new_sh+8] = name_bytes
|
||||
struct.pack_into("<I", data, new_sh + 8, total_data_size) # VirtualSize
|
||||
struct.pack_into("<I", data, new_sh + 12, new_vaddr) # VirtualAddress
|
||||
struct.pack_into("<I", data, new_sh + 16, raw_size) # SizeOfRawData
|
||||
struct.pack_into("<I", data, new_sh + 20, new_roff) # PointerToRawData
|
||||
struct.pack_into("<I", data, new_sh + 24, 0) # PointerToRelocations
|
||||
struct.pack_into("<I", data, new_sh + 28, 0) # PointerToLinenumbers
|
||||
struct.pack_into("<H", data, new_sh + 32, 0) # NumberOfRelocations
|
||||
struct.pack_into("<H", data, new_sh + 34, 0) # NumberOfLinenumbers
|
||||
# Characteristics: CODE? DATA. Use 0x40000040 = INITIALIZED_DATA | READ
|
||||
# We need WRITE on the IAT but for simple loaders read-only is fine
|
||||
# because the loader rewrites IAT to actual addresses (writable while loading).
|
||||
# Use IMAGE_SCN_MEM_READ | IMAGE_SCN_MEM_WRITE | INITIALIZED_DATA = 0xC0000040
|
||||
struct.pack_into("<I", data, new_sh + 36, 0xC0000040)
|
||||
# Bump section count
|
||||
struct.pack_into("<H", data, pe_off + 6, num_sections + 1)
|
||||
|
||||
# 6. Update OptionalHeader: SizeOfImage, IMPORT data directory
|
||||
new_size_of_image = new_vaddr + virt_size
|
||||
struct.pack_into("<I", data, optional_off + 56, new_size_of_image)
|
||||
|
||||
new_imp_rva = new_vaddr + 0 # descriptor table at start of our section
|
||||
new_imp_size = (n_existing + 1) * DESC_SZ # not including null terminator per MS spec... but include for safety
|
||||
struct.pack_into("<II", data, data_dir_off + 1*8, new_imp_rva, new_imp_size + DESC_SZ)
|
||||
|
||||
# IAT data directory (index 12) might also need updating — point at our IAT.
|
||||
# For loaders, IMPORT is what matters; IAT directory is optional. Leave alone.
|
||||
|
||||
with open(outp, "wb") as f:
|
||||
f.write(data)
|
||||
print(f"wrote {outp} ({len(data)} bytes)")
|
||||
print(f" new section @ rva 0x{new_vaddr:08x} (file 0x{new_roff:x}), size {raw_size}")
|
||||
print(f" new IMPORT dir @ rva 0x{new_imp_rva:08x}, descriptors: {n_existing} existing + 1 ours")
|
||||
fn = func_name.rstrip(b"\0").decode()
|
||||
print(f" added import: {dll_name} (resolves '{fn}')")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
47
dll/leakfix/tools/list_imports.py
Normal file
47
dll/leakfix/tools/list_imports.py
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
"""list_imports.py <pe.exe> — list all DLLs imported by a PE file."""
|
||||
import struct, sys
|
||||
|
||||
with open(sys.argv[1], "rb") as f:
|
||||
data = f.read()
|
||||
pe_off = struct.unpack_from("<I", data, 0x3c)[0]
|
||||
opt_off = pe_off + 24
|
||||
num_sec = struct.unpack_from("<H", data, pe_off + 6)[0]
|
||||
size_opt = struct.unpack_from("<H", data, pe_off + 20)[0]
|
||||
sec_off = opt_off + size_opt
|
||||
imp_rva, imp_size = struct.unpack_from("<II", data, opt_off + 96 + 1*8)
|
||||
|
||||
# Build RVA->offset map
|
||||
secs = []
|
||||
for i in range(num_sec):
|
||||
sh = sec_off + i*40
|
||||
vaddr = struct.unpack_from("<I", data, sh+12)[0]
|
||||
vsize = struct.unpack_from("<I", data, sh+8)[0]
|
||||
rsize = struct.unpack_from("<I", data, sh+16)[0]
|
||||
roff = struct.unpack_from("<I", data, sh+20)[0]
|
||||
secs.append((vaddr, max(vsize, rsize), roff, bytes(data[sh:sh+8]).rstrip(b"\0").decode()))
|
||||
|
||||
def rva2off(rva):
|
||||
for vaddr, vsize, roff, _ in secs:
|
||||
if vaddr <= rva < vaddr + vsize:
|
||||
return roff + (rva - vaddr)
|
||||
return None
|
||||
|
||||
print(f"IMPORT dir RVA=0x{imp_rva:08x} size={imp_size}")
|
||||
print(f"sections:")
|
||||
for v, sz, r, n in secs:
|
||||
print(f" {n:>10} vaddr=0x{v:08x} vsize={sz:>8} roff=0x{r:x}")
|
||||
|
||||
print("imports:")
|
||||
pos = rva2off(imp_rva)
|
||||
i = 0
|
||||
while True:
|
||||
desc = data[pos:pos+20]
|
||||
if desc == b"\0"*20:
|
||||
print(f" [{i}] (null terminator)")
|
||||
break
|
||||
ilt_rva, ts, fwc, name_rva, iat_rva = struct.unpack("<IIIII", desc)
|
||||
name_off = rva2off(name_rva)
|
||||
name = bytes(data[name_off:data.index(0, name_off)]).decode("ascii", "replace") if name_off else "?"
|
||||
print(f" [{i}] {name} (ILT=0x{ilt_rva:08x} IAT=0x{iat_rva:08x})")
|
||||
pos += 20
|
||||
i += 1
|
||||
8
dll/test/hello.cpp
Normal file
8
dll/test/hello.cpp
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
#include <windows.h>
|
||||
|
||||
BOOL APIENTRY DllMain(HMODULE h, DWORD reason, LPVOID lp) {
|
||||
if (reason == DLL_PROCESS_ATTACH) {
|
||||
OutputDebugStringA("leakfix.dll loaded\n");
|
||||
}
|
||||
return TRUE;
|
||||
}
|
||||
BIN
dll/test/hello.dll
Normal file
BIN
dll/test/hello.dll
Normal file
Binary file not shown.
Loading…
Add table
Add a link
Reference in a new issue