Clean source + repo down to deliverables only

- Remove disabled patches from DLL source (v18/v19/v20/v23/v24
  prototype paths and all v25-v38 d3d9 investigation tooling).
  Production DLL ships v3b/v5/v11/v14/v22 + crash handler only.
- Strip repo to 16 files: README, .gitignore, DLL source (8 files),
  build.bat, prebuilt dist/leakfix.dll, two installer tools.
- Rewrite README around per-patch pseudo-C showing each bug + fix.
- Update dist/leakfix.dll to the cleaned-source build (SHA 99ab51fe).
This commit is contained in:
acbot 2026-05-26 20:50:57 +02:00
parent 57b5e43d0e
commit 92ec4e5ecf
189 changed files with 175 additions and 1647185 deletions

View file

@ -20,177 +20,15 @@ constexpr uintptr_t V5_RT_DESTROY_VA = 0x0044C4F0; // RenderTexture::Destroy
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_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

View file

@ -1,4 +1,4 @@
// patches.cpp — apply v3b, v5, v11, v12, v14 inline to our own process
// patches.cpp — apply v3b, v5, v11, v14, v22 inline to our own process
#include "patches.h"
#include "logging.h"
#include "thunks.h"
@ -56,7 +56,7 @@ bool write_jmp_rel32(uintptr_t at, uintptr_t target, size_t total_replace) {
namespace leakfix {
// ===== v3b =====
// ===== v3b — palette over-increment NOP =====
bool apply_v3b() {
const uint8_t nops[3] = { 0x90, 0x90, 0x90 };
const uint8_t orig1[3] = { 0xff, 0x40, 0x24 }; // inc dword [eax+0x24]
@ -82,28 +82,21 @@ bool apply_v3b() {
return true;
}
// ===== v5 =====
// ===== v5 — RenderSurface/Texture PurgeResource override =====
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);
}
if (rs_cur == ac::V5_NOOP_STUB_VA) {
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) {
if (rt_cur == ac::V5_NOOP_STUB_VA) {
write_memory(ac::V5_RT_VTABLE_SLOT_2, &rt_new, 4);
logf("v5: RT vtable slot -> 0x%08x", rt_new);
} else {
@ -112,9 +105,9 @@ bool apply_v5() {
return true;
}
// ===== v11 =====
// ===== v11 — dangling-pointer crash guards =====
bool apply_v11() {
// Site 1: 2-byte rewrite of a JMP target
// Site 1: 2-byte rewrite of a JMP target (delete_contents hash walk)
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
@ -143,17 +136,7 @@ bool apply_v11() {
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 =====
// ===== v14 — CEnvCell::Destroy ClipPlaneList leak =====
bool apply_v14() {
static const uint8_t ORIG[18] = {
0x8B, 0x86, 0xDC, 0x00, 0x00, 0x00, // mov eax, [esi+0xDC]
@ -165,7 +148,6 @@ bool apply_v14() {
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");
@ -202,7 +184,6 @@ extern "C" int __fastcall v22_unpacker_wrapper(void* self, void* edx,
} __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);
}
@ -211,20 +192,17 @@ extern "C" int __fastcall v22_unpacker_wrapper(void* self, void* edx,
}
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);
@ -236,7 +214,6 @@ bool apply_v22() {
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));
@ -250,89 +227,6 @@ bool apply_v22() {
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();
@ -340,21 +234,6 @@ bool apply_all_patches() {
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;
}

View file

@ -1,222 +0,0 @@
# 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.

View file

@ -1,13 +1,7 @@
// 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 =====
//
@ -31,24 +25,12 @@ extern "C" int __fastcall purge_rendertexture_thunk(void* self, void* /*edx*/) {
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.
// V14_PATCH_SITE_VA 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)
@ -89,33 +71,3 @@ extern "C" __declspec(naked) void v14_clipplane_cleanup_thunk() {
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
}
}

View file

@ -7,22 +7,9 @@ extern "C" {
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.
// v14 — naked thunk JMPed to from V14_PATCH_SITE_VA.
// 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"