"""patch_v8_thunk_v2.py [--revert] v8-thunk-v2: SAFE drain of UIElement_UIItem leaked pool. v1 (the broken one) hooked Flush — drain ran during normal panel refresh and ate items needed for display. v2 hooks OnVisibilityChanged (vis=false branch) instead — drain only when the panel becomes hidden, NOT during Flush. Mechanism: At 0x004e499e, OnVisibilityChanged has 24 bytes implementing: if ((this->+0x554 >> 0x11 & 1) && vis != 0) UpdateEmptySlots() epilogue at 0x004e49b6: pop esi; pop ebx; ret 4 We replace those 24 bytes with `JMP ` (5 bytes) + 19 NOPs. Thunk reproduces the original logic AND adds a drain path: if (!(this->+0x554 >> 0x11 & 1)) return if (vis != 0) UpdateEmptySlots() ; stock visible path else ; vis == false: drain (NEW) for up to 8 iterations: idx = this->+0x610 - 1 if idx < 0: break item_array = GetItem(this, idx) if item_array == NULL: break ; (no more items) real_item = item_array->vtable[37](0x10000032) ; type check if real_item == 0: break ; (not a UIItem at end) state = real_item->UIItem_GetState() if state != WAITING: break ; (active item at end, stop) InternalDeleteItem(this, real_item) Safety: - Only drains on vis=false (panel actually going hidden) - Cap of 8 items per hide event (prevents catastrophic burst) - Stops at first non-WAITING item at end (mimics UpdateEmptySlots's own trim behavior — never deletes active items) - Preserves the original visible-true behavior exactly Notable correctness: - InternalDeleteItem is called with the result of vtable[37], NOT the raw item from the array (mirrors UpdateEmptySlots's pattern) """ import argparse import ctypes import ctypes.wintypes as wt import struct import sys PATCH_SITE_VA = 0x004e499e PATCH_LEN = 24 # Original 24 bytes (will verify before patching): ORIG_BYTES = bytes([ 0x8B, 0x86, 0x54, 0x05, 0x00, 0x00, # mov eax, [esi+0x554] 0xC1, 0xE8, 0x11, # shr eax, 0x11 0xA8, 0x01, # test al, 1 0x74, 0x0B, # jz +0x0B (to 0x004e49b6) 0x84, 0xDB, # test bl, bl 0x74, 0x07, # jz +0x07 0x8B, 0xCE, # mov ecx, esi 0xE8, 0xDA, 0xF9, 0xFF, 0xFF, # call UpdateEmptySlots ]) assert len(ORIG_BYTES) == 24 GETITEM_VA = 0x0046dc50 GETSTATE_VA = 0x004e1e20 INTDELETE_VA = 0x004e41c0 UPDATEEMPTYSLOTS_VA = 0x004e4390 def build_thunk(base: int) -> bytes: """Build the v8-thunk-v2 at absolute address `base`.""" out = bytearray() refs = {} # symbolic name -> (offset_of_rel_byte, target_va, size_of_call) # ------ prologue ------ out += bytes([0x57]) # push edi (save caller's edi) out += bytes([0x8B, 0x86, 0x54, 0x05, 0x00, 0x00]) # mov eax, [esi+0x554] out += bytes([0xC1, 0xE8, 0x11]) # shr eax, 0x11 out += bytes([0xA8, 0x01]) # test al, 1 jz_epi_1 = len(out) out += bytes([0x74, 0x00]) # jz .epi (patch) out += bytes([0x84, 0xDB]) # test bl, bl jnz_visible = len(out) out += bytes([0x75, 0x00]) # jnz .visible (patch) out += bytes([0xBF, 0x08, 0x00, 0x00, 0x00]) # mov edi, 8 (cap) # ------ loop ------ loop_top = len(out) out += bytes([0x8B, 0x86, 0x10, 0x06, 0x00, 0x00]) # mov eax, [esi+0x610] out += bytes([0x48]) # dec eax js_epi_1 = len(out) out += bytes([0x78, 0x00]) # js .epi (patch) out += bytes([0x50]) # push eax (idx) out += bytes([0x8B, 0xCE]) # mov ecx, esi call_getitem = len(out) out += bytes([0xE8, 0, 0, 0, 0]) # call GetItem out += bytes([0x85, 0xC0]) # test eax, eax jz_epi_2 = len(out) out += bytes([0x74, 0x00]) # jz .epi out += bytes([0x68, 0x32, 0x00, 0x00, 0x10]) # push 0x10000032 out += bytes([0x8B, 0xC8]) # mov ecx, eax out += bytes([0x8B, 0x10]) # mov edx, [eax] out += bytes([0xFF, 0x92, 0x94, 0x00, 0x00, 0x00]) # call [edx+0x94] out += bytes([0x85, 0xC0]) # test eax, eax jz_epi_3 = len(out) out += bytes([0x74, 0x00]) # jz .epi out += bytes([0x50]) # push eax (save real_item) out += bytes([0x8B, 0xC8]) # mov ecx, eax call_getstate = len(out) out += bytes([0xE8, 0, 0, 0, 0]) # call GetState out += bytes([0x59]) # pop ecx (ecx = real_item) out += bytes([0x3D, 0x1C, 0x00, 0x00, 0x10]) # cmp eax, 0x1000001c jne_epi = len(out) out += bytes([0x75, 0x00]) # jne .epi out += bytes([0x51]) # push ecx (arg = real_item) out += bytes([0x8B, 0xCE]) # mov ecx, esi call_intdel = len(out) out += bytes([0xE8, 0, 0, 0, 0]) # call InternalDeleteItem out += bytes([0x4F]) # dec edi (cap--) jnz_loop = len(out) out += bytes([0x75, 0x00]) # jnz loop_top (patch) jmp_epi = len(out) out += bytes([0xEB, 0x00]) # jmp .epi # ------ visible path ------ visible_label = len(out) out += bytes([0x8B, 0xCE]) # mov ecx, esi call_updemp = len(out) out += bytes([0xE8, 0, 0, 0, 0]) # call UpdateEmptySlots # ------ epilogue ------ epi_label = len(out) out += bytes([0x5F]) # pop edi out += bytes([0x5E]) # pop esi out += bytes([0x5B]) # pop ebx out += bytes([0xC2, 0x04, 0x00]) # ret 4 # ----- Resolve relative jumps and calls ----- def patch_rel8(at, target_off): rel = target_off - (at + 2) assert -128 <= rel <= 127, f"rel8 overflow {rel}" out[at + 1] = rel & 0xFF def patch_rel32_call(at, target_va): # E8 at offset `at` ... rel32 at at+1..at+4. Next-instr addr = base+at+5. rel = target_va - (base + at + 5) out[at + 1:at + 5] = struct.pack("