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:
acbot 2026-05-23 21:05:17 +02:00
commit 57b5e43d0e
199 changed files with 1648333 additions and 0 deletions

61
.gitignore vendored Normal file
View file

@ -0,0 +1,61 @@
# --- credentials & local-only configuration ---
.env
*.creds
bin/launch_*.ps1
bin/launch_*.cmd
# --- big binaries (the package's pdb is committed deliberately; raw snapshots are not) ---
artifacts/**/snap_*.txt
artifacts/**/snap_*.bin
artifacts/**/dump_*.dmp
artifacts/**/*.dmp
artifacts/**/*.etl
artifacts/**/*.cab
artifacts/**/*.umdh.stdout
# --- build outputs (when Phase 3/8 land) ---
build/
out/
*.obj
*.pdb.tmp
*.exp
*.lib
!pdb/acclient.pdb
# but keep the shipping DLL itself committed
!dll/leakfix/build/leakfix.dll
# --- big files we don't want in the repo ---
leakhunt-migration.zip
tmp_*.txt
Usersacbot*
*.log
artifacts/
# memory dir is not part of the public deliverable; keep local
memory/
.claude/
# internal docs — keep local, not in the public repo
REPORT.md
HANDOFF.md
CLAUDE.md
MANIFEST.md
READ_ME_FIRST.txt
--help
# derived acclient.exe variants — copyrighted game binary, do not redistribute
dll/leakfix/acclient*.exe
dll/leakfix/dist/acclient*.exe
dll/leakfix/stable/acclient*.exe
# --- OS / editor junk ---
Thumbs.db
desktop.ini
*.swp
.vscode/
.idea/
# --- python / venv junk ---
__pycache__/
*.pyc
.venv/

697
README.md Normal file
View file

@ -0,0 +1,697 @@
# Retail AC Memory Leak Hunt
> **Status: COMPLETE 2026-05-22.** Five bugs found and patched in the
> retail AC client. Controlled fleet soak showed the unpatched control
> died at 26h with palette exhaustion; all 14 patched clients survived
> past that point and ran for ≥5-day uptime. The residual ~15 MB/h
> growth was traced to d3d9.dll's internal slab allocator and is
> unfixable from outside d3d9.
>
> If you just want to install: drop `dll/leakfix/dist/leakfix.dll`
> into your AC directory and run
> `python tools/install_leakfix.py "C:\path\to\AC"`. The installer
> patches `acclient.exe`'s import table to load `leakfix.dll` at
> startup. Idempotent — safe to re-run.
---
## What ships
| Patch | Bug | One-line fix |
|---|---|---|
| **v3b** | Palette refcount over-increment in `makeModifiedPalette` | NOP the `inc [eax+0x24]` at two sites |
| **v5** | `RenderSurface::PurgeResource` is a no-op stub | Override vtable slot 2 to call `Destroy()` for real |
| **v11** | Two dangling-pointer dereferences in `delete_contents` + `~GXTri3Mesh` | NULL-check guards |
| **v14** | `CEnvCell::Destroy` leaks the `ClipPlaneList` (just zeros the count) | Replace the 18-byte buggy block with a JMP to a thunk that actually frees the list |
| **v22** | Server-driven AV in the unpacker function at `0x00526A50` (5-client mass crash 2026-05-21 09:00) | Wrap the function in `__try / __except`, return 0 on AV (which the engine already handles as the size-check-failure code path) |
All five plus a crash-handler ship in `leakfix.dll`. Patches are
applied 30 seconds after process start (deferred so Decal/UB win their
own init race first). Crash handler is installed immediately so any
crashes during the 30s window are still captured.
## Patch pseudo-code
### v3b — palette refcount over-increment
The engine's palette-cache hit path increments the cached entry's
refcount **twice** (once in the cache lookup, once in the constructor
that wraps it). Result: refcount grows monotonically; nothing ever
hits zero; palettes accumulate until the 32-bit address space
exhausts (~26h on heavy-loot clients).
```asm
; at 0x0053EFFE (and 0x0053F19C, the sibling overload)
; before patch: after patch:
; ff 40 24 inc dword ptr [eax+0x24] 90 90 90 nop nop nop
```
```c
// effect, expressed in C:
// before: refcount++ twice per cache hit
// after: refcount++ once per cache hit (the outer increment is removed)
```
### v5 — RenderSurface PurgeResource override
`RenderSurface`'s and `RenderTexture`'s `PurgeResource` virtual slot
points at `0x004154A0`, which is `mov al, 1; ret` — a no-op stub.
When the resource manager's purge sweep walks `s_Resources` and calls
`PurgeResource()` on each entry, the call returns "1 = purged" but
the resource's D3D handle + heap state is never touched. Result:
purged-shell accumulation in `s_Resources`.
```c
// before — at slot 2 of the RenderSurface vtable (0x0079A684):
// PurgeResource = noop_stub; // 0x004154A0
// int noop_stub() { return 1; }
//
// after — slot 2 redirected to our thunk in leakfix.dll:
int purge_rendersurface_thunk(RenderSurface* self) {
RenderSurface::Destroy(self); // real cleanup
return 1; // engine marks entry purged
}
// same fix mirrored to RenderTexture slot 2 (0x0079C1A0).
```
### v11 — two dangling-pointer crash guards
Two places where the engine dereferences a pointer that's been freed
elsewhere. Both manifest as AVs that take the process down.
**Site 1** — `delete_contents` hash walk (`0x00587126`):
The loop falls through into a dereference of an already-freed bucket
node when the bucket chain was rebuilt mid-walk. Fix: retarget the
JMP so the freed-bucket branch jumps to the epilogue, skipping the
deref.
```asm
; before: eb 07 jmp +0x07 ; into the deref
; after: eb 42 jmp +0x42 ; into the epilogue (skip deref)
```
**Site 2** — `~GXTri3Mesh` slot 0 deref (`0x005E565D`):
Destructor of `GXTri3Mesh` reads its slot[0] *then* zeros it. If
slot[0] is stale (some other path already freed it), the deref AVs.
Fix: reorder so we zero first; never deref a slot we can't trust.
```asm
; before: after:
; 8B 08 mov ecx, [eax] 89 5E 08 mov [esi+8], ebx ; zero first
; 50 push eax 90 ... 90 nop x6 ; skip deref + call
; FF 51 08 call [ecx+8]
; 89 5E 08 mov [esi+8], ebx
```
### v14 — CEnvCell::ClipPlaneList leak
`CEnvCell::Destroy` contains an 18-byte cleanup block that **only
zeros `cplane_num`** — never frees the underlying `ClipPlaneList`
object hanging off `[this+0xDC]`. Every cell unload leaks one of
these. Replace the broken block with a `JMP` to a thunk in
leakfix.dll that does the real cleanup:
```c
// thunk pseudo-code:
void v14_clipplane_cleanup_thunk(CEnvCell* self) {
ClipPlaneListWrapper* outer = self->cplane_wrapper; // [esi+0xDC]
if (outer) {
ClipPlaneList* inner = outer->inner; // [outer+0x0]
if (inner) {
inner->~ClipPlaneList();
operator delete(inner);
}
operator delete[](outer);
self->cplane_wrapper = nullptr;
}
// jump back to V14_RESUME_VA (just past the original 18-byte block)
}
```
### v22 — unpacker stale-pointer SEH guard
A small inline unpacker at `0x00526A50` pulls 4 DWORDs from
`arg1->buffer`. On 2026-05-21 the server fed five clients
simultaneously a buffer pointing into freed/kernel memory; all five
AV'd on the 4th deref. The engine *already* has a code path for
"buffer too small / unpack failed" (line 1 of the function checks a
size field and returns 0). We just wrap the whole function body in
SEH and route AVs to that same return-0 path.
```c
// 1. Copy the original 73 bytes of the function to executable memory.
// 2. Patch the original entry with JMP rel32 to our wrapper.
int v22_unpacker_wrapper(this, arg1, count) {
__try {
return original_copy(this, arg1, count); // run the real unpacker
} __except (EXCEPTION_EXECUTE_HANDLER) {
// log + return 0 (engine treats this as size-check failure)
return 0;
}
}
```
## Install
```powershell
# 1. Copy leakfix.dll into your AC directory
Copy-Item .\dll\leakfix\build\leakfix.dll "C:\Turbine\Asheron's Call\"
# 2. Patch acclient.exe to import leakfix.dll
python tools\install_leakfix.py "C:\Turbine\Asheron's Call"
# 3. Verify
python tools\install_leakfix.py "C:\Turbine\Asheron's Call" verify
```
The installer adds a `.limport` PE section to acclient.exe containing
the rebuilt import table. It backs up the original to
`acclient.exe.bare_original` on first run, and is idempotent.
## Roll back
```powershell
Copy-Item "C:\Turbine\Asheron's Call\acclient.exe.bare_original" `
"C:\Turbine\Asheron's Call\acclient.exe" -Force
Remove-Item "C:\Turbine\Asheron's Call\leakfix.dll" -ErrorAction Ignore
```
## Files
- `dll/leakfix/src/` — DLL source (C++ with inline asm for the naked thunks)
- `dll/leakfix/dist/leakfix.dll` — current production build (117 KB)
- `dll/leakfix/build.bat` — build script (VS 2022 BuildTools required)
- `tools/install_leakfix.py` — patches acclient.exe to import leakfix.dll
- `tools/check_acclient_imports.py` — verify import table contains leakfix.dll
- `references/` — symbol table, pseudo-C, header for the 2013 client (PDB-backed)
The rest of this document is the original VM operator brief that
drove the investigation. Preserved for context but no longer
operationally relevant — the hunt is done.
---
# Retail AC Memory Leak Hunt — VM Operator Brief
**You are picking this up cold on a freshly-provisioned Windows VM.**
This document is your full mission brief. Read it end-to-end before
running anything, then drive the work autonomously, using
`ScheduleWakeup` (Claude Code) to pace long-running operations between
your active turns.
---
## 1. Mission
Find and patch a memory leak in the retail Asheron's Call client. The
production symptom is a hard crash after ~45 days of continuous play
on the **End-of-Retail (EoR, ~Jan 2017) client**. We don't have symbols
for that binary — but we have **full PDB symbols for the Sept 2013
v11.4186 client**, which almost certainly carries the same leak (AC was
in pure maintenance mode 2013→2017, very little net new code).
**The hunt happens on the 2013 client (symbolized).**
**The patch ships against the EoR client (via BinDiff-forward).**
### What "done" looks like
1. A specific function in the 2013 client is identified as the leak
source, with evidence: monotonic UMDH growth across multiple
snapshot diffs attributed to that function's call stack.
2. The corresponding function in the EoR client is located via
BinDiff (this step happens on the **host machine**, not the VM —
the BNDB files live there).
3. A DLL-injection patch is built that hooks the EoR function and
plugs the leak (typically: adds a missing `delete`/`Release`/decref
on a known path).
4. A 5+ day soak on EoR with the patch installed completes without
the OOM crash that reproduces unpatched in the same window.
### Hard scope boundary
This is a self-contained side quest. **Do not** expand it into a
general retail-instrumentation framework, a fork of the controller
DLL into a fully-featured bot, a parallel acdream feature, or "while
I'm here" refactors of the AC2D/Mosswart tooling. Find leak → patch →
validate → ship → done. If you catch yourself reaching for adjacent
work, stop and re-read this paragraph.
---
## 2. Why this works (assumptions you can rely on)
- **Compiler & toolchain stability.** 2013 and EoR were both built with
the same VC++ family on the same Turbine build farm. Binary structure
is highly similar.
- **Code stability.** AC went into maintenance after Throne of
Destiny (2005) and stayed there. Most of the codebase did not change
meaningfully between 2013 and EoR. A leak severe enough to crash in
45 days has almost certainly been present for many years.
- **PDB → BinDiff path is mature.** `BinDiff` and `Diaphora` routinely
achieve 8095% function-match rates across related VC++ binaries.
Once you identify the leaking function in 2013 (with name), porting
the symbol forward to EoR is signature-scan-able.
### What you're betting on, and the fallback
- **Primary bet:** the leak repros on the 2013 client. UMDH on the
2013 client + activity bot reveals it within hours-to-days. You
identify a named function, hand the name to the host for BinDiff,
receive the EoR signature back, build the patch DLL, validate.
- **Fallback:** the leak does NOT repro on 2013 — i.e. it was
introduced after Sept 2013. In that case, you fall back to hunting
on the EoR client without symbols, using BinDiff-transferred names
for whatever functions match the 2013 codebase. This is slower but
still feasible. The primary-vs-fallback determination is **Phase 1
Decision Gate** below.
---
## 3. Package contents
```
leak-hunt-vm-2026-05-12/
├── README.md ← you are here
├── MANIFEST.md ← list of out-of-repo files copied in
├── CLAUDE.md ← VM-side project rules (persistent)
├── templates/
│ ├── supervisor.ps1 ← skeleton — start ACE, start client, snapshot loop
│ ├── snapshot.ps1 ← UMDH single-shot
│ ├── activity-phases.json ← phase schedule template
│ ├── login.ahk ← AutoHotkey login skeleton
│ └── trace.cdb ← cdb scripting template
├── tools/
│ ├── check_exe_pdb.py ← verify binary ↔ PDB GUID match
│ ├── dump_pdb_info.py ← PDB metadata
│ └── pdb_extract.py ← regenerate symbols.json if needed
├── pdb/
│ └── acclient.pdb ← (29 MB, copied per MANIFEST)
└── references/
├── symbols.json ← 18,366 named functions + addresses (grep-friendly)
├── types.json ← 5,371 struct/class type definitions
├── acclient.h ← verbatim retail header structs
└── acclient_2013_pseudo_c.txt ← 64 MB symbolized Binary Ninja pseudo-C
```
The Python tools are stdlib-only (no pip). Everything else is data.
---
## 4. What you need on the VM (one-time, before starting)
If any of these is missing, **ask the user before guessing**.
| Component | Where | Notes |
|---|---|---|
| Retail AC client (2013 v11.4186) | `C:\Turbine\Asheron's Call\` | Standard install path. Verify match with `check_exe_pdb.py` before any other work. The `_NT_SYMBOL_PATH` must include `pdb/`. |
| Retail AC dat files | inside the install | `client_portal.dat`, `client_cell_1.dat`, `client_highres.dat`, `client_local_English.dat` |
| ACE server | `127.0.0.1:9000` on VM | Use ACEmulator from github.com/ACEmulator/ACE. Same config as user's dev box. Confirm it accepts logins before continuing. |
| Test character | on the VM's ACE | Suggested name: `+Leakhunt`. GM-marker `+` so debug commands are available. |
| Windows Debugging Tools | Microsoft Store WinDbg or Win10/11 SDK | Need `cdb.exe`, `umdh.exe`, `gflags.exe`. 32-bit (`x86`) versions — `acclient.exe` is 32-bit. |
| AutoHotkey v2 | autohotkey.com | For login automation. v2 only — templates assume v2 syntax. |
| Sysinternals `procdump` | sysinternals.com | Crash-dump capture. |
| MinHook (optional, for patch DLL) | github.com/TsudaKageyu/minhook | Only needed at Phase 8. Defer. |
| Shared folder or mounted drive | `Z:\` or similar | For passing snapshots back to host. Configure at VM-setup time. |
---
## 5. Configuration questions to ask the user at session start
**Ask these first, before running anything.** They materially affect
the harness.
1. **Where is ACE running** — same VM (recommended; snapshot-clean) or
on the host with VM networking through to it? Default assumption:
same VM.
2. **What's the AC install path** if it's not the standard
`C:\Turbine\Asheron's Call\`?
3. **Output flow** — shared folder path? Or push artifacts to a git
branch (e.g. `leak-hunt-vm/2026-05-12`)? Default: shared folder
to `Z:\leak-hunt\` on host.
4. **Test character name** on the VM ACE? Default: `+Leakhunt`.
5. **VM specs** — RAM and core count? (Affects whether to enable
gflags+UST from the start, which costs ~2030% perf.)
6. **EoR binary location on host** — confirm the user has it at
`C:\Users\erikn\source\repos\acdream\refs\acclient-eor-2024-09-11.bndb`
(Binary Ninja db). This isn't needed on the VM but is critical for
Phase 7 BinDiff on the host.
7. **Wake-up cadence preference** — do they want you to use
`ScheduleWakeup` for hours-long gaps, or stay continuously
active? Default: ScheduleWakeup for any gap > 30 min.
Save the user's answers as memory entries before proceeding past
Phase 0 so a future session can pick up cold.
---
## 6. Phased plan
Each phase has a **goal**, **commands**, **decision gate**, and
**estimated time**. Don't skip ahead. Don't run multiple phases in
parallel until Phase 4.
### Phase 0 — Verify the bench (target: 30 min)
**Goal:** prove the environment can launch AC, log in, and observe
memory.
1. `py tools/check_exe_pdb.py "C:\Turbine\Asheron's Call\acclient.exe"`.
Expect: `=== MATCH: this exe pairs with our acclient.pdb ===`.
If MISMATCH → stop, ask the user which build they installed.
2. `py tools/dump_pdb_info.py pdb/acclient.pdb`. Confirm GUID
`9e847e2f-777c-4bd9-886c-22256bb87f32`, age 1.
3. Start ACE locally (`dotnet run` in the ACE checkout, or
`ACE.exe` if pre-built). Confirm it listens on `127.0.0.1:9000`.
4. Manually launch AC, log in with the test character, walk one
step, log out. **This proves the bench works before you add
instrumentation.**
5. Take a clean Hyper-V / VMware snapshot named `bench-verified`.
The supervisor will revert to this before each run.
**Decision gate:** can you launch, log in, walk, log out, clean?
If no, fix this before anything else. If yes, proceed.
---
### Phase 1 — Idle baseline + decide hunt platform (target: 4 hours)
**Goal:** does the leak reproduce on the 2013 client when the player
sits at the lifestone doing nothing? If yes, primary plan; if no,
Phase 2 will find the right activity profile.
1. Enable heap allocation tagging:
`gflags /i acclient.exe +ust`. This is registry-set; survives
reboots. (Disable later with `gflags /i acclient.exe -ust`.)
2. Set `_NT_SYMBOL_PATH=<vm-path-to-pdb-dir>`.
3. Launch AC via `templates/supervisor.ps1` (which sets env and
spawns the process). Log in manually OR via `templates/login.ahk`.
4. Walk to a quiet spot (lifestone interior, away from spawn). Sit.
5. `templates/snapshot.ps1 -ProcessId <pid> -Out snap_001.txt`
immediately. Wait 30 min. Take `snap_002`. Repeat for at least
4 hours (8 snapshots).
6. `umdh -d snap_001 snap_008 -f:diff_idle.txt`. Read top 20
growing stacks. Save the diff to `Z:\leak-hunt\phase1\`.
**Decision gate:**
- **Total committed memory grew >50 MB over 4h idle?** Leak repros at
idle. Skip Phase 2, jump to Phase 4 (long-soak idle).
- **Total committed grew 550 MB?** Leak may need amplification.
Proceed to Phase 2.
- **Total committed grew <5 MB?** Leak is activity-specific or
doesn't exist on 2013. Proceed to Phase 2.
- **Memory dropped or oscillates around 0?** No leak signal at idle.
Phase 2 is where you'll find it (or won't).
Record the baseline growth-rate number in memory:
`leak_hunt_phase1_baseline_mb_per_hour`.
---
### Phase 2 — Activity-phase characterization (target: 12 days)
**Goal:** find which player activity causes the leak. The bot is not
yet built — you drive this manually with the activity-phase template
running as an AHK macro, or by playing 30-min phases yourself if no
bot is available.
The five canonical phases (see `templates/activity-phases.json`):
1. **idle** — stand at lifestone, no input
2. **wander** — walk a fixed route around Holtburg
3. **chat** — spam say/tell/global chat
4. **target-cycle** — Tab through nearby NPCs/mobs, no combat
5. **ui-cycle** — open/close inventory, character pane, spells
**Procedure per session:**
1. Start fresh from `bench-verified` snapshot.
2. Run a single phase for 1 hour with snapshots every 15 min.
3. `umdh -d` diff the snapshot pair for that phase.
4. Record growth-rate per phase to memory.
5. Repeat for each phase, single VM, single phase per run.
(If user has authorized multiple parallel VMs, run different phases
simultaneously instead of sequentially.)
**Decision gate:** rank phases by growth rate. The top phase is your
target for Phase 4 amplification. Save ranking to memory.
---
### Phase 3 — Controller DLL (target: 12 days)
**Goal:** build a small DLL that drives the leaking phase
deterministically and reproducibly, faster than a human can.
**Build approach:**
- C++ DLL, 32-bit, compiled against Visual Studio Build Tools.
- MinHook for function hooking.
- LoadLibrary'd into `acclient.exe` via a small launcher EXE
(CreateProcess SUSPENDED → WriteProcessMemory of LoadLibrary
trampoline → ResumeThread). Standard injection pattern.
- Hook a frame-loop function from `symbols.json` — search
`references/symbols.json` for `CGameLoop`, `WorldFilter`, `Tick`,
`ProcessFrame` and pick the highest-frequency one with stable
signature.
- Call retail functions directly via PDB-resolved addresses. Examples:
`CPhysicsObj::set_velocity`, `CChatManager::SendSay`,
`CPlayerSystem::SelectTarget`. These take a `this` pointer in `ecx`
(thiscall) — you'll need a small asm trampoline or use
`__thiscall` calling-convention helpers.
**The bot's job:**
- Drive the top-ranked Phase 2 activity continuously.
- Emit a heartbeat to a log file every 30s so the supervisor can
detect wedging.
- Auto-restart self-position-watchdog: if `CPhysicsObj::position`
hasn't changed in 5 min during a movement phase, signal the
supervisor to revert and retry.
**Reuse opportunity:** the user maintains MosswartMassacre and
MosswartOverlord — both are AC client DLL-injection projects. **Ask
the user for read access** before designing from scratch; they may
have a working injector + MinHook scaffolding you can port from in
hours rather than days. Do not assume; ask.
**Decision gate:** bot runs the leaking phase for 1 hour
unattended, emits heartbeats, produces measurable UMDH growth.
---
### Phase 4 — Long-soak with amplification (target: 1248 hours)
**Goal:** generate a clean signal — one or two leaking call stacks
visibly dominate the UMDH diff.
1. Revert VM to `bench-verified`.
2. Launch via `supervisor.ps1` with the controller DLL injected.
3. Snapshot every 15 min for 12+ hours.
4. `umdh -d` snap_001 vs snap_N every couple of hours during your
active turns. Between active turns, use `ScheduleWakeup` with
delay 18003600s and the reason `"long-soak snapshot check"`.
**Decision gate:** UMDH diff shows one or more call stacks with
monotonic growth across all adjacent-pair diffs, dominating the
total by ≥10× over the next-highest. That's your leak candidate(s).
---
### Phase 5 — Identify the leaking function (target: 24 hours)
**Goal:** convert the UMDH call stack into a named function we can
study and patch.
1. The top growing stack will look like:
```
ntdll!RtlAllocateHeap+0x...
acclient!operator new+0x...
acclient!CFoo::AllocateBar+0x42
acclient!CFoo::DoTheThing+0x18
acclient!CGameLoop::Tick+0x...
```
2. The named function is `CFoo::AllocateBar`. Grep
`references/acclient_2013_pseudo_c.txt` for `CFoo::AllocateBar`
to read its body.
3. Identify the paired free function (`CFoo::ReleaseBar`,
`~CFoo`, etc.) and confirm by reading both.
4. Find every call site of `CFoo::AllocateBar` (grep the pseudo-C
for the function name) and verify each has a matching paired
release. The one that doesn't is the bug.
**Decision gate:** you have (a) the leaking function name, (b) the
specific call site that doesn't free, (c) a hypothesis for the
patch (typically: add a `delete` or `Release()` on a specific code
path). Save these to memory + a write-up file.
---
### Phase 6 — Cross-reference with retail debugger trace (optional, target: 2 hours)
**Goal:** confirm the leak path is actually hit at runtime in a real
play scenario, not just statically possible.
This step is optional but recommended if the leak path is conditional
(e.g. "only when the chat buffer wraps"). Use the cdb workflow
documented in `templates/trace.cdb` and the retail-debugger section
in `CLAUDE.md`. Attach to acclient.exe, breakpoint on the alloc
function with a non-blocking action (`r $t0=@$t0+1; gc`), let it
accumulate for 30 min, count hits, correlate with UMDH growth bytes.
---
### Phase 7 — BinDiff to EoR (**HOST MACHINE — not VM**)
**Goal:** produce an EoR-binary signature and offset for the leaking
function.
This phase does not happen on the VM. The Binary Ninja databases
live on the host (`refs/acclient-eor-2024-09-11.bndb`,
`refs/acclient_2013-2024-09-11.bndb`).
**You (VM Claude) do:**
1. Write a structured handoff file
`Z:\leak-hunt\phase7-handoff.md` containing: the function name,
its 2013 RVA, the paired release function name, the suspected
missing-free call site, the call-graph context, a 3248 byte
AOB signature with wildcards over relocatable operands (cite
the byte sequence from the pseudo-C/disassembly).
2. Notify the user that Phase 7 is ready.
**The user (or a Claude session on the host) does:**
3. Load both BNDBs in Binary Ninja, run BinDiff (or use BN's native
diff). Locate the matching function in EoR.
4. Verify the AOB signature still matches in EoR (small mods are
OK — adjust wildcards as needed).
5. Write back to `Z:\leak-hunt\phase7-result.md`: EoR RVA,
confirmed signature, any structural differences worth knowing.
You (VM Claude) resume once that file appears.
---
### Phase 8 — Patch DLL (target: 1 day)
**Goal:** ship a DLL that, when loaded into `acclient.exe` (any
build that matches the signature), plugs the leak.
- Same scaffold as the controller DLL — MinHook + a launcher EXE.
- Hook the leaking function (or its caller, whichever is cleanest).
- Wrap the existing logic so the missing free is performed on the
bug path.
- Provide a versioned filename and a small README so it's clear
which client build it targets.
- **Verify on the 2013 client first** — same UMDH soak, expect the
top growing stack to vanish.
---
### Phase 9 — Multi-day soak validation (target: 5+ days)
**Goal:** prove the patch fixes the production crash.
1. Install the patch DLL injector into the EoR client setup.
2. Launch under the supervisor with snapshots every hour (lower
freq — we're not hunting now, just confirming).
3. Run a controlled activity profile (the bot's full rotation) for
5+ days continuous.
4. **Pass:** no OOM crash, committed memory stable or decreasing
slope. **Fail:** crash before 5 days → back to Phase 5 to find
the second leak.
---
### Phase 10 — Ship
1. Write up findings:
- The leak's root cause (one paragraph)
- The patch's mechanism (one paragraph)
- The 2013-vs-EoR signature note
- Validation evidence (UMDH diffs, soak duration, growth-rate
plot if you have one)
2. Save the writeup to `Z:\leak-hunt\REPORT.md` and to memory.
3. Notify the user. Stop the loop. Done.
---
## 7. Wake-up protocol
Use `ScheduleWakeup` to self-pace between phases. **Default cadence
table:**
| Situation | Delay | Why |
|---|---|---|
| Active analysis (reading UMDH diffs, writing code) | none — stay engaged | Full-context work |
| Between snapshots inside a soak (Phase 1/4/9) | 15001800 s | Cache stays warm-ish, snapshots accumulate |
| Overnight gap (≥6 h) | 3600 s and chain | One cache miss is cheap vs. burning per-hour |
| Waiting for user (Phase 7 handoff) | 3600 s | Poll for the result file |
Pass the same loop prompt each turn. The `reason` field should
identify the phase and what you'll check (e.g. `"phase 4 snapshot
check — read snap_012 and diff against snap_001"`).
---
## 8. When to stop and ask the user
- **Phase 0 verification fails** (PDB mismatch, login fails, ACE
not reachable). Don't guess at fixes.
- **The bot wedges and auto-recovery fails twice in a row.**
- **You're about to expand scope** (refactor the supervisor into a
framework, build a UI for snapshot review, port code into
acdream's tree). Stop and ask. Default answer is no.
- **You hit a decision gate where the data is genuinely ambiguous**
(e.g. growth rate is moderate but no single stack dominates).
- **Phase 5 produces a function name that isn't in symbols.json.**
Probably means an indirect call or vtable dispatch — ask before
spending hours decoding it.
- **The patch in Phase 9 doesn't validate.** Don't iterate
indefinitely; surface findings and re-plan.
---
## 9. Memory protocol
Save findings as memory entries so a session that wakes 8 hours later
can resume cold. Specifically:
- `project_leak_hunt.md` — top-level project context, current phase,
open questions
- `leak_hunt_phase_N.md` — per-phase findings, growth rates, decisions
- `leak_hunt_candidate_<funcname>.md` — once a function is suspected,
everything you know about it
- `feedback_leak_hunt_<topic>.md` — if the user gives operational
feedback during this hunt, record it
Update `MEMORY.md` index entry for each. Keep entries short and
factual; long writeups go in `Z:\leak-hunt\` files referenced from
memory.
---
## 10. Hard rules (do not violate)
1. **Don't run anything that touches the user's host machine.** The
VM is isolated for a reason. All output goes through the shared
folder.
2. **Don't disable gflags+UST mid-run.** If you need to disable,
stop the supervisor, disable, take a fresh baseline.
3. **Don't modify `acclient.exe` on disk.** All patches are
runtime DLL hooks. If you ever feel tempted to binary-patch the
exe directly, ask the user first.
4. **Don't auto-update `MEMORY.md`** without first saving the
underlying memory file. The index must point at real files.
5. **Don't claim a leak is found** without the evidence checklist:
- ≥3 consecutive UMDH diffs showing the same top stack growing
- The stack is attributed to a named function in `symbols.json`
- The call site is identified in `acclient_2013_pseudo_c.txt`
- The hypothesis for the missing free is stated
6. **Don't proceed to Phase 8 without Phase 7 handoff complete.**
The patch must target the EoR signature, not the 2013 RVA.
---
## 11. First action when you start a fresh session
```
1. Read README.md (this file) end-to-end.
2. Read CLAUDE.md (project rules — concise).
3. Run the configuration questions in §5 by the user.
4. Save their answers as memory.
5. Begin Phase 0.
```
Good hunting.

5
bin/_uac_probe.ps1 Normal file
View file

@ -0,0 +1,5 @@
$elev = ([Security.Principal.WindowsPrincipal]::new(
[Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)
"elevated=$elev`r`npid=$PID`r`nuser=$env:USERNAME" |
Out-File -FilePath 'C:\Users\acbot\leakhunt\artifacts\soak\uac_test.txt' -Encoding utf8

7
bin/_user32.ps1 Normal file
View file

@ -0,0 +1,7 @@
# Tiny P/Invoke shim so phase1_idle_baseline.ps1 can read GDI / USER handle counts.
# Dot-source this before invoking the sampler.
Add-Type -Namespace LeakHunt -Name User32 -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("User32.dll")]
public static extern int GetGuiResources(System.IntPtr hProcess, int uiFlags);
'@

57
bin/admin_hklm_only.ps1 Normal file
View file

@ -0,0 +1,57 @@
#requires -Version 5.1
<#
admin_hklm_only.ps1 minimal admin script for the two HKLM writes.
SDK Debuggers are already extracted as flat files; this script only
handles the things gflags + WER need that touch HKLM:
1. Configure WER LocalDumps for acclient.exe (auto-dumps on crash).
2. gflags +ust on acclient.exe (heap-allocation stack tagging on
FUTURE acclient spawns; current ones won't pick it up).
#>
$ErrorActionPreference = 'Continue'
$log = 'C:\Users\acbot\leakhunt\artifacts\soak\admin_hklm.log'
Start-Transcript -Path $log -Force | Out-Null
try {
if (-not ([Security.Principal.WindowsPrincipal]::new(
[Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host 'ERROR: not elevated. Aborting.' -ForegroundColor Red
Stop-Transcript | Out-Null
Read-Host 'press enter to close'
exit 1
}
Write-Host "=== admin_hklm_only.ps1 started @ $(Get-Date -Format o) ===" -ForegroundColor Cyan
# [1/2] WER LocalDumps
Write-Host '[1/2] Configuring WER LocalDumps for acclient.exe...' -ForegroundColor Cyan
$dumpDir = 'C:\Users\acbot\leakhunt\artifacts\crashdumps'
New-Item -ItemType Directory -Path $dumpDir -Force | Out-Null
$werKey = 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\acclient.exe'
New-Item -Path $werKey -Force | Out-Null
New-ItemProperty -Path $werKey -Name 'DumpFolder' -Value $dumpDir -PropertyType ExpandString -Force | Out-Null
New-ItemProperty -Path $werKey -Name 'DumpType' -Value 2 -PropertyType DWord -Force | Out-Null # 2 = Full
New-ItemProperty -Path $werKey -Name 'DumpCount' -Value 25 -PropertyType DWord -Force | Out-Null
Get-ItemProperty -Path $werKey | Format-List DumpFolder, DumpType, DumpCount
# [2/2] gflags +ust
$gflags = 'C:\Users\acbot\Tools\WindowsKits\Windows Kits\10\Debuggers\x86\gflags.exe'
Write-Host '[2/2] Enabling gflags +ust on acclient.exe...' -ForegroundColor Cyan
if (Test-Path $gflags) {
& $gflags /i acclient.exe +ust
" current image-file flags:"
& $gflags /i acclient.exe
} else {
Write-Warning "gflags.exe not found at $gflags"
}
Write-Host "=== admin_hklm_only.ps1 finished @ $(Get-Date -Format o) ===" -ForegroundColor Green
} catch {
Write-Host "FATAL: $($_ | Out-String)" -ForegroundColor Red
}
Stop-Transcript | Out-Null
Read-Host 'press enter to close'

91
bin/admin_setup.ps1 Normal file
View file

@ -0,0 +1,91 @@
#requires -Version 5.1
<#
admin_setup.ps1 one-time, ADMIN-ELEVATED setup for the leak hunt.
RUN THIS FROM AN ELEVATED POWERSHELL. It does three things:
1. Installs the Windows SDK "Windows Desktop Debuggers" feature
(~250 MB), giving us standalone cdb.exe, umdh.exe, gflags.exe
under C:\Program Files (x86)\Windows Kits\10\Debuggers\.
2. Configures WER LocalDumps for acclient.exe so any future crash
(OOM/AV/heap-corruption) auto-saves a full-memory dump to
artifacts\crashdumps\.
3. Sets gflags +ust on acclient.exe (per-image-file flag in HKLM)
so future acclient spawns tag every heap allocation with its
call stack required for Phase 5 attribution.
NOTE: currently-running acclient processes will NOT pick up the
gflags +ust setting. Only processes started after this runs will
have stack tagging. Pre-existing leakers continue to leak, just
without UST tags on their heap entries.
After this finishes, you can close the elevated shell. The
non-elevated session keeps running.
#>
$ErrorActionPreference = 'Continue' # don't crash window on first error
$log = 'C:\Users\acbot\leakhunt\artifacts\soak\admin_setup.log'
Start-Transcript -Path $log -Force | Out-Null
try {
if (-not ([Security.Principal.WindowsPrincipal]::new(
[Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host "ERROR: not elevated. Aborting." -ForegroundColor Red
Stop-Transcript | Out-Null
Read-Host 'press enter to close'
exit 1
}
Write-Host "=== admin_setup.ps1 started @ $(Get-Date -Format o) ===" -ForegroundColor Cyan
# ---- 1. Install Windows SDK Debuggers feature ----------------------------
$sdk = "$env:TEMP\winsdk\winsdksetup.exe"
if (-not (Test-Path $sdk)) {
Write-Host "SDK installer not present; downloading..." -ForegroundColor Yellow
New-Item -ItemType Directory -Path "$env:TEMP\winsdk" -Force | Out-Null
Invoke-WebRequest -Uri "https://go.microsoft.com/fwlink/?linkid=2272610" `
-OutFile $sdk -UseBasicParsing
}
Write-Host "[1/3] Installing Windows SDK Debuggers feature (silent)..." -ForegroundColor Cyan
$args = @('/features','OptionId.WindowsDesktopDebuggers','/quiet','/norestart')
$p = Start-Process -FilePath $sdk -ArgumentList $args -Wait -PassThru
if ($p.ExitCode -ne 0) {
Write-Warning "SDK setup exit code: $($p.ExitCode) (non-zero — see %TEMP%\winsdk\ logs)"
}
$cdb = 'C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe'
$umdh = 'C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\umdh.exe'
$gflags = 'C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\gflags.exe'
foreach ($t in @($cdb, $umdh, $gflags)) {
if (Test-Path $t) { Write-Host " OK $t" -ForegroundColor Green }
else { Write-Host " MISSING $t" -ForegroundColor Red }
}
# ---- 2. WER LocalDumps for acclient.exe ----------------------------------
Write-Host "[2/3] Configuring WER LocalDumps for acclient.exe..." -ForegroundColor Cyan
$dumpDir = 'C:\Users\acbot\leakhunt\artifacts\crashdumps'
New-Item -ItemType Directory -Path $dumpDir -Force | Out-Null
$werKey = 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\acclient.exe'
New-Item -Path $werKey -Force | Out-Null
New-ItemProperty -Path $werKey -Name 'DumpFolder' -Value $dumpDir -PropertyType ExpandString -Force | Out-Null
New-ItemProperty -Path $werKey -Name 'DumpType' -Value 2 -PropertyType DWord -Force | Out-Null # 2 = Full
New-ItemProperty -Path $werKey -Name 'DumpCount' -Value 25 -PropertyType DWord -Force | Out-Null
Get-ItemProperty -Path $werKey | Format-List DumpFolder, DumpType, DumpCount
# ---- 3. gflags +ust on acclient.exe --------------------------------------
Write-Host "[3/3] Enabling gflags +ust on acclient.exe (FUTURE spawns only)..." -ForegroundColor Cyan
if (Test-Path $gflags) {
& $gflags /i acclient.exe +ust
} else {
Write-Warning "gflags.exe not found at $gflags — SDK install may have failed. Skipping +ust."
}
Write-Host "=== admin_setup.ps1 finished @ $(Get-Date -Format o) ===" -ForegroundColor Cyan
Write-Host "You can close this elevated window now." -ForegroundColor Green
} catch {
Write-Host "FATAL: $($_ | Out-String)" -ForegroundColor Red
}
Stop-Transcript | Out-Null
Read-Host 'press enter to close'

27
bin/admin_uac_silent.ps1 Normal file
View file

@ -0,0 +1,27 @@
#requires -Version 5.1
<#
admin_uac_silent.ps1 sets ConsentPromptBehaviorAdmin = 0 so future
elevations from this admin user happen silently. Reversible via the
same key (default value = 5).
#>
$ErrorActionPreference = 'Continue'
$log = 'C:\Users\acbot\leakhunt\artifacts\soak\admin_uac.log'
Start-Transcript -Path $log -Force | Out-Null
try {
if (-not ([Security.Principal.WindowsPrincipal]::new(
[Security.Principal.WindowsIdentity]::GetCurrent())).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator)) {
Write-Host 'ERROR: not elevated.' -ForegroundColor Red
exit 1
}
$k = 'HKLM:\Software\Microsoft\Windows\CurrentVersion\Policies\System'
$before = (Get-ItemProperty -Path $k -Name ConsentPromptBehaviorAdmin -ErrorAction SilentlyContinue).ConsentPromptBehaviorAdmin
Write-Host ("ConsentPromptBehaviorAdmin BEFORE = {0}" -f $before)
Set-ItemProperty -Path $k -Name ConsentPromptBehaviorAdmin -Value 0 -Type DWord -Force
$after = (Get-ItemProperty -Path $k -Name ConsentPromptBehaviorAdmin).ConsentPromptBehaviorAdmin
Write-Host ("ConsentPromptBehaviorAdmin AFTER = {0}" -f $after) -ForegroundColor Green
} catch {
Write-Host "FATAL: $($_ | Out-String)" -ForegroundColor Red
}
Stop-Transcript | Out-Null
Read-Host 'press enter to close'

71
bin/bench_roundtrip.ahk Normal file
View file

@ -0,0 +1,71 @@
; bench_roundtrip.ahk
; Phase 0 step 4 — drive the AC client through char-select → in-world → clean quit,
; without a human watching. Logs to artifacts/phase0/ahk_roundtrip.log.
;
; Run with: AutoHotkey64.exe bench_roundtrip.ahk
#Requires AutoHotkey v2.0
#SingleInstance Force
WinTitle := "Asheron's Call"
LogFile := "C:\Users\acbot\leakhunt\artifacts\phase0\ahk_roundtrip.log"
Log(msg) {
global LogFile
FileAppend Format("[{1}] {2}`n", FormatTime(A_Now, "yyyy-MM-dd HH:mm:ss"), msg), LogFile
}
Log("script start")
if not WinWait(WinTitle, , 30) {
Log("FATAL: AC window not found within 30s")
ExitApp 1
}
Log("AC window found")
WinActivate WinTitle
Sleep 1500
if not WinActive(WinTitle) {
Log("WARN: WinActivate did not bring AC to foreground; trying ControlSend fallback")
}
; Phase A: if at char-select, Enter selects the default (only) character and enters world.
; If we're already in-world (memory >800 MB suggested by the supervisor probe), Enter opens
; chat which we'll close immediately.
SendInput "{Enter}"
Log("sent Enter #1 (char-select confirm or chat-open)")
Sleep 2000
; Phase B: send Escape to dismiss any modal/chat that may have opened. Harmless if no modal.
SendInput "{Esc}"
Log("sent Escape (dismiss modal/chat)")
Sleep 800
; Phase C: small movement step to satisfy "walk one step" requirement (Phase 0 §4).
; W is the AC default forward-walk bind in most layouts. Hold 250 ms.
SendInput "{w down}"
Sleep 250
SendInput "{w up}"
Log("sent W pulse (walk one step)")
Sleep 1000
; Phase D: clean quit. AC supports @quit chat command to log out to character select.
; Then we send /quit again from char-select to terminate the client process gracefully.
SendInput "{Enter}" ; open chat
Sleep 200
SendInput "@quit"
Sleep 200
SendInput "{Enter}"
Log("sent @quit (logout-to-char-select)")
Sleep 4000
; If still alive at char-select: ask the launcher menu to exit the client.
; AC's exit-to-desktop on char-select is typically Escape -> Yes-to-exit confirmation.
SendInput "{Esc}"
Sleep 600
SendInput "{Enter}" ; default focus on "Quit" or "Yes"
Log("sent Escape + Enter (exit from char-select)")
Sleep 1500
Log("script end")
ExitApp 0

51
bin/cdb_probe.ps1 Normal file
View file

@ -0,0 +1,51 @@
#requires -Version 5.1
<#
cdb_probe.ps1 <dump.dmp>
Standard analysis probe for an acclient minidump. Writes structured
output next to the dump as <dump>.probe.txt.
Runs (in order):
vertarget image version + uptime
.lastevent last debug event captured
!peb process env block
lm vM modules with versions
!address -summary VA usage summary
!address /f:Heap list heap regions and sizes
!runaway 7 thread CPU usage (kernel+user time)
~* k 12 short stack of every thread (no symbols, just RVAs)
#>
param(
[Parameter(Mandatory)] [string] $Dump
)
$ErrorActionPreference = 'Stop'
if (-not (Test-Path $Dump)) { throw "Dump file not found: $Dump" }
$cdb = 'C:\Users\acbot\Tools\WindowsKits\Windows Kits\10\Debuggers\x86\cdb.exe'
$out = "$Dump.probe.txt"
$env:_NT_SYMBOL_PATH = 'C:\Users\acbot\leakhunt\pdb'
$script = @(
'.echo === vertarget ==='
'vertarget'
'.echo === lastevent ==='
'.lastevent'
'.echo === peb ==='
'!peb'
'.echo === modules ==='
'lm vM'
'.echo === address summary ==='
'!address -summary'
'.echo === heap regions ==='
'!address /f:Heap'
'.echo === runaway ==='
'!runaway 7'
'.echo === threads top frames ==='
'~* k 12'
'q'
) -join ';'
& $cdb -z $Dump -y 'C:\Users\acbot\leakhunt\pdb' -c $script 2>&1 |
Out-File -FilePath $out -Encoding utf8
Write-Output "probe written: $out size=$([math]::Round((Get-Item $out).Length/1KB,1)) KB"

18
bin/heap_summary.cdb Normal file
View file

@ -0,0 +1,18 @@
$$ heap_summary.cdb — run via: cdb -z <dump.dmp> -cf bin\heap_summary.cdb
$$
$$ One command per line. .sympath eats the rest of its line, so the path goes
$$ on its own line. This avoids the -c semicolon-ambiguity hazard.
.sympath C:\Users\acbot\leakhunt\pdb
.symopt+ 0x40
.reload /f acclient.exe
$$ Heap summary — top-level overview, lightweight.
.echo === !heap -s ===
!heap -s
$$ Per-heap allocation-size histogram, useful for tracking growth.
.echo === !heap -stat -h 0 ===
!heap -stat -h 0
q

48
bin/keepalive_f5.ahk Normal file
View file

@ -0,0 +1,48 @@
; keepalive_f5.ahk
; Sends F5 to the AC window every 60 s via SendInput so DirectInput sees it.
; Brings AC to foreground briefly each pulse (focus-stealing is unavoidable
; for SendInput).
;
; Run with: AutoHotkey64.exe bin\keepalive_f5.ahk
#Requires AutoHotkey v2.0
#SingleInstance Force
SetTitleMatchMode 2
WinTitle := "ahk_class Turbine Device Class"
LogFile := "C:\Users\acbot\leakhunt\artifacts\phase1\keepalive.log"
LogPulse(msg) {
global LogFile
FileAppend("[" FormatTime(A_Now, "yyyy-MM-dd HH:mm:ss") "] " msg "`n", LogFile)
}
LogPulse("AHK keepalive starting, alternating z/c (2s hold) every 600s (10 min)")
i := 0
Loop {
if not WinExist(WinTitle) {
LogPulse("AC window not present - waiting 10s")
Sleep 10000
continue
}
try {
WinActivate(WinTitle)
Sleep 200
if (Mod(i, 2) = 0) {
Send("{z down}")
Sleep 2000
Send("{z up}")
LogPulse("pulse " i ": z held 2s")
} else {
Send("{c down}")
Sleep 2000
Send("{c up}")
LogPulse("pulse " i ": c held 2s")
}
i := i + 1
} catch as e {
LogPulse("ERROR: " e.Message)
}
Sleep 600000 ; 10 min production interval
}

72
bin/keepalive_f5.ps1 Normal file
View file

@ -0,0 +1,72 @@
# keepalive_f5.ps1
# Pulses F5 to the AC client window every N seconds to defeat Coldeve's
# idle-kick. Finds the window by class "Turbine Device Class" so it's
# robust to title-string encoding quirks.
param(
[int]$IntervalSec = 60,
[string]$LogFile = "C:\Users\acbot\leakhunt\artifacts\phase1\keepalive.log"
)
$ErrorActionPreference = "Stop"
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public class LhKA {
public delegate bool EnumWindowsProc(IntPtr hWnd, IntPtr lParam);
[DllImport("user32.dll")] public static extern bool EnumWindows(EnumWindowsProc lpEnumFunc, IntPtr lParam);
[DllImport("user32.dll", CharSet=CharSet.Unicode)] public static extern int GetClassName(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll")] public static extern int GetWindowThreadProcessId(IntPtr hWnd, out uint pid);
[DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd);
[DllImport("user32.dll")] public static extern bool PostMessage(IntPtr hWnd, uint Msg, IntPtr wParam, IntPtr lParam);
}
"@ -ErrorAction SilentlyContinue
function W([string]$msg) {
$t = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
"[$t] $msg" | Out-File -Append -Encoding utf8 -FilePath $LogFile
}
function Find-AcWindow {
param([int]$ProcessId)
$found = $null
$cb = [LhKA+EnumWindowsProc]{
param($h, $l)
$pid_ = 0
[void][LhKA]::GetWindowThreadProcessId($h, [ref]$pid_)
if ($pid_ -eq $ProcessId -and [LhKA]::IsWindowVisible($h)) {
$cls = New-Object System.Text.StringBuilder 256
[void][LhKA]::GetClassName($h, $cls, $cls.Capacity)
if ($cls.ToString() -eq "Turbine Device Class") {
$script:found = $h
return $false
}
}
return $true
}
[void][LhKA]::EnumWindows($cb, [IntPtr]::Zero)
return $script:found
}
$WM_KEYDOWN = 0x0100
$WM_KEYUP = 0x0101
$VK_F5 = 0x74
W "keepalive_f5 starting, pulse every $IntervalSec s"
while ($true) {
$ac = Get-Process -Name acclient -ErrorAction SilentlyContinue | Sort-Object StartTime -Descending | Select-Object -First 1
if (-not $ac) { W "acclient gone - exiting"; break }
$hwnd = Find-AcWindow -ProcessId $ac.Id
if (-not $hwnd) { W "AC window not found for PID $($ac.Id); waiting"; Start-Sleep -Seconds $IntervalSec; continue }
$ok1 = [LhKA]::PostMessage($hwnd, $WM_KEYDOWN, [IntPtr]$VK_F5, [IntPtr]0)
Start-Sleep -Milliseconds 50
$ok2 = [LhKA]::PostMessage($hwnd, $WM_KEYUP, [IntPtr]$VK_F5, [IntPtr]0)
W "F5 pulse -> hwnd=0x$($hwnd.ToInt64().ToString('X')) pid=$($ac.Id) PM=$([math]::Round($ac.PrivateMemorySize64/1MB,1))MB ok=$ok1,$ok2"
Start-Sleep -Seconds $IntervalSec
}

View file

@ -0,0 +1,111 @@
# phase1_idle_baseline.ps1
# Runs unattended for ~4 hours. Each cycle:
# - samples acclient memory metrics → CSV
# - every Nth cycle, takes a procdump (full memory) for later analysis
# - exits if acclient dies (so we know exactly when)
#
# Output:
# artifacts/phase1/memtrace.csv (one row per sample)
# artifacts/phase1/dump_NNN.dmp (every 30 min)
# artifacts/phase1/phase1.log (operator log)
#
# Run with:
# powershell -ExecutionPolicy Bypass -File bin\phase1_idle_baseline.ps1
param(
[string]$PhaseDir = "C:\Users\acbot\leakhunt\artifacts\soak",
[int]$SampleEvery = 300, # 5 min
[int]$DumpEvery = 6, # every 6th sample = every 30 min
[int]$DurationSec = 28800, # 8 h (extend if no crash)
[string]$ProcDumpExe = "C:\Tools\Sysinternals\procdump.exe"
)
$ErrorActionPreference = "Stop"
$ProgressPreference = "SilentlyContinue"
Add-Type -Namespace LeakHunt -Name User32 -MemberDefinition @'
[System.Runtime.InteropServices.DllImport("User32.dll")]
public static extern int GetGuiResources(System.IntPtr hProcess, int uiFlags);
'@ -ErrorAction SilentlyContinue
New-Item -ItemType Directory -Path $PhaseDir -Force | Out-Null
$csv = Join-Path $PhaseDir "memtrace.csv"
$log = Join-Path $PhaseDir "phase1.log"
function W([string]$msg) {
$t = (Get-Date).ToString("yyyy-MM-dd HH:mm:ss")
"[$t] $msg" | Out-File -Append -Encoding utf8 -FilePath $log
Write-Host "[$t] $msg"
}
if (-not (Test-Path $csv)) {
"iso,elapsed_s,pid,ws_mb,pm_mb,virtual_mb,handles,threads,gdi_handles,user_handles,cpu_s,dump_path" |
Out-File -Encoding utf8 -FilePath $csv
}
$ac = Get-Process -Name acclient -ErrorAction SilentlyContinue
if (-not $ac) { W "FATAL: acclient.exe not running"; exit 1 }
$acPid = $ac.Id
$start = Get-Date
W "Phase 1 idle baseline started. acclient PID=$acPid, $($DurationSec)s budget, sample every $($SampleEvery)s, dump every $($DumpEvery * $SampleEvery)s ($($DumpEvery) samples)."
$sampleIdx = 0
while ($true) {
$now = Get-Date
$elapsed = [int]($now - $start).TotalSeconds
if ($elapsed -ge $DurationSec) { W "Duration budget reached. Stopping."; break }
$ac = Get-Process -Id $acPid -ErrorAction SilentlyContinue
if (-not $ac) { W "acclient.exe EXITED unexpectedly at elapsed=$elapsed s"; break }
$sampleIdx++
$ws = [math]::Round($ac.WorkingSet64 / 1MB, 2)
$pm = [math]::Round($ac.PrivateMemorySize64 / 1MB, 2)
$vm = [math]::Round($ac.VirtualMemorySize64 / 1MB, 2)
$cpu = [math]::Round($ac.CPU, 2)
# GDI/USER handle counts via Win32 API
$gdi = [LeakHunt.User32]::GetGuiResources($ac.Handle, 0)
$usr = [LeakHunt.User32]::GetGuiResources($ac.Handle, 1)
# Optional dump
$dumpPath = ""
if ($DumpEvery -gt 0 -and (($sampleIdx - 1) % $DumpEvery) -eq 0) {
$dumpNum = (($sampleIdx - 1) / $DumpEvery) + 1
$dumpPath = Join-Path $PhaseDir ("dump_{0:D3}.dmp" -f $dumpNum)
W "Taking dump_$('{0:D3}' -f $dumpNum) ..."
$pdOut = & $ProcDumpExe -accepteula -ma $acPid $dumpPath 2>&1 | Out-String
if (-not (Test-Path $dumpPath)) {
W "procdump FAILED for sample $sampleIdx -- output: $pdOut"
$dumpPath = "FAILED"
} else {
$mb = [math]::Round((Get-Item $dumpPath).Length / 1MB, 1)
W "dump_$('{0:D3}' -f $dumpNum).dmp written ($mb MB)"
}
}
$row = "{0},{1},{2},{3},{4},{5},{6},{7},{8},{9},{10},{11}" -f `
$now.ToString("o"), $elapsed, $acPid, $ws, $pm, $vm, $ac.HandleCount, $ac.Threads.Count, $gdi, $usr, $cpu, $dumpPath
$row | Out-File -Append -Encoding utf8 -FilePath $csv
W ("sample {0,3}: WS={1,8} MB PM={2,8} MB VM={3,8} MB H={4,5} T={5,3} G={6,5} U={7,5} CPU={8,7}s" -f `
$sampleIdx, $ws, $pm, $vm, $ac.HandleCount, $ac.Threads.Count, $gdi, $usr, $cpu)
Start-Sleep -Seconds $SampleEvery
}
W "Phase 1 baseline complete."
# Print growth rate summary
$rows = Import-Csv $csv
if ($rows.Count -ge 2) {
$first = $rows[0]
$last = $rows[-1]
$dWs = [double]$last.ws_mb - [double]$first.ws_mb
$dPm = [double]$last.pm_mb - [double]$first.pm_mb
$hrs = ([double]$last.elapsed_s - [double]$first.elapsed_s) / 3600.0
$rateWs = if ($hrs -gt 0) { $dWs / $hrs } else { 0 }
$ratePm = if ($hrs -gt 0) { $dPm / $hrs } else { 0 }
W ("Growth over {0:N2} h: WS +{1:N2} MB ({2:N2} MB/h), PM +{3:N2} MB ({4:N2} MB/h)" -f $hrs, $dWs, $rateWs, $dPm, $ratePm)
}

33
bin/sample_fleet.ps1 Normal file
View file

@ -0,0 +1,33 @@
#requires -Version 5.1
<#
sample_fleet.ps1
One-shot working-set + private-bytes + virtual-bytes sample for every
running acclient.exe. Appends to artifacts/soak/memtrace_fleet.csv.
Usage:
powershell -ExecutionPolicy Bypass -File bin\sample_fleet.ps1
or schedule via ScheduleWakeup with a recurring 1500-1800s cadence.
#>
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$logDir = Join-Path $root 'artifacts\soak'
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null }
$csv = Join-Path $logDir 'memtrace_fleet.csv'
if (-not (Test-Path $csv)) {
'ts_utc,pid,start,uptime_h,ws_mb,private_mb,virtual_mb,handle_count,thread_count' |
Out-File -FilePath $csv -Encoding utf8
}
$now = [DateTime]::UtcNow
$procs = Get-Process -Name acclient -ErrorAction SilentlyContinue
foreach ($p in $procs) {
$up = [math]::Round(($now - $p.StartTime.ToUniversalTime()).TotalHours, 2)
$line = '{0:o},{1},{2:o},{3},{4},{5},{6},{7},{8}' -f $now, $p.Id,
$p.StartTime.ToUniversalTime(), $up,
[math]::Round($p.WorkingSet64 / 1MB, 1),
[math]::Round($p.PrivateMemorySize64 / 1MB, 1),
[math]::Round($p.VirtualMemorySize64 / 1MB, 1),
$p.HandleCount, $p.Threads.Count
Add-Content -LiteralPath $csv -Value $line
}
Write-Output ("samples={0} csv={1}" -f $procs.Count, $csv)

105
bin/snapshot_leakers.ps1 Normal file
View file

@ -0,0 +1,105 @@
#requires -Version 5.1
<#
snapshot_leakers.ps1
Takes a `procdump -r 1 -ma` clone-snapshot of the top-N leakiest
acclient processes that haven't been dumped recently. Designed to
be called every 30-60 min from the wakeup loop.
Decision rules:
* Skip a PID if there is already a procdump -e watcher attached
(it would block our snapshot attach; the watcher will catch OOM).
* Skip a PID if we already wrote a dump for it in the last
`$staleAfterMin` minutes.
* Rank by VirtualMemorySize64 descending. Take top $TopN.
* Output dir: artifacts/soak/ filename: dump_<pid>_<NNN>.dmp.
Tracks state in artifacts/soak/snapshot_state.json.
#>
param(
[int] $TopN = 2,
[int] $StaleAfterMin = 60,
[int] $MinVirtualMB = 1200,
[switch] $DryRun
)
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$soakDir = Join-Path $root 'artifacts\soak'
$procdump = 'C:\Users\acbot\Tools\Procdump\procdump.exe'
$state = Join-Path $soakDir 'snapshot_state.json'
if (-not (Test-Path $soakDir)) { New-Item -ItemType Directory -Path $soakDir -Force | Out-Null }
# Load state
if (Test-Path $state) {
$st = Get-Content $state -Raw | ConvertFrom-Json
} else {
$st = @{ snapshots = @() }
}
# What PIDs already have a procdump -e watcher attached?
$attachedTo = @()
$watcherCsv = Join-Path $soakDir 'watchers.csv'
if (Test-Path $watcherCsv) {
Import-Csv $watcherCsv | ForEach-Object {
$wp = [int]$_.watcher_pid
if (Get-Process -Id $wp -ErrorAction SilentlyContinue) {
$attachedTo += [int]$_.target_pid
}
}
}
# Rank live acclient processes by virtual mem
$candidates = Get-Process -Name acclient -ErrorAction SilentlyContinue |
Where-Object { [math]::Round($_.VirtualMemorySize64/1MB,0) -ge $MinVirtualMB -and $_.Id -notin $attachedTo } |
Sort-Object VirtualMemorySize64 -Descending |
Select-Object -First $TopN
$now = [DateTime]::UtcNow
foreach ($p in $candidates) {
# Skip if dumped recently
$existing = @($st.snapshots) | Where-Object { $_.pid -eq $p.Id } | Sort-Object ts_utc -Descending | Select-Object -First 1
if ($existing) {
$age = ($now - [DateTime]::Parse($existing.ts_utc)).TotalMinutes
if ($age -lt $StaleAfterMin) {
Write-Output ("skip pid={0} dumped {1:N1} min ago" -f $p.Id, $age)
continue
}
}
# Compute filename — increment N
$n = 1
while (Test-Path (Join-Path $soakDir ("dump_{0}_{1:000}.dmp" -f $p.Id, $n))) { $n++ }
$out = Join-Path $soakDir ("dump_{0}_{1:000}.dmp" -f $p.Id, $n)
$vMB = [math]::Round($p.VirtualMemorySize64/1MB,1)
$pMB = [math]::Round($p.PrivateMemorySize64/1MB,1)
if ($DryRun) {
Write-Output ("would dump pid={0} virtual={1}MB private={2}MB -> {3}" -f $p.Id, $vMB, $pMB, $out)
continue
}
Write-Output ("dump pid={0} virtual={1}MB private={2}MB" -f $p.Id, $vMB, $pMB)
$sw = [Diagnostics.Stopwatch]::StartNew()
& $procdump -r 1 -ma -accepteula $p.Id $out *> $null
$sw.Stop()
if (Test-Path $out) {
$sz = [math]::Round((Get-Item $out).Length/1MB,1)
Write-Output (" wrote {0} MB in {1:N1}s" -f $sz, $sw.Elapsed.TotalSeconds)
$entry = [PSCustomObject]@{
ts_utc = $now.ToString('o')
pid = $p.Id
start = $p.StartTime.ToUniversalTime().ToString('o')
uptime_h = [math]::Round(($now - $p.StartTime.ToUniversalTime()).TotalHours, 2)
ws_mb = [math]::Round($p.WorkingSet64/1MB,1)
private_mb = $pMB
virtual_mb = $vMB
path = $out
size_mb = $sz
elapsed_s = [math]::Round($sw.Elapsed.TotalSeconds, 1)
ust_likely = ($p.StartTime.ToUniversalTime() -gt [DateTime]::Parse('2026-05-13T06:07:43Z'))
}
$st.snapshots = @($st.snapshots) + $entry
} else {
Write-Output " DUMP FAILED — file not present"
}
}
$st | ConvertTo-Json -Depth 5 | Out-File -FilePath $state -Encoding utf8

63
bin/take_snapshot.ps1 Normal file
View file

@ -0,0 +1,63 @@
# take_snapshot.ps1
# One-shot umdh snapshot helper. Auto-finds acclient PID and the next snap_NNN filename.
#
# Usage:
# take_snapshot.ps1 -PhaseDir artifacts\phase1
#
# Requires: gflags +ust on acclient.exe (one-time, see Phase 1 step 1),
# _NT_SYMBOL_PATH set to the directory containing acclient.pdb.
param(
[Parameter(Mandatory=$true)][string]$PhaseDir,
[int]$ProcessId = 0, # 0 = auto-discover via Get-Process
[string]$UmdhExe = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\umdh.exe",
[int]$TimeoutSec = 600 # umdh on a fat 32-bit AC client can take 60-200s
)
$ErrorActionPreference = "Stop"
if (-not (Test-Path $PhaseDir)) { New-Item -ItemType Directory -Path $PhaseDir -Force | Out-Null }
if (-not (Test-Path $UmdhExe)) { throw "umdh.exe not found at $UmdhExe" }
if (-not $env:_NT_SYMBOL_PATH) { $env:_NT_SYMBOL_PATH = "C:\Users\acbot\leakhunt\pdb" }
if ($ProcessId -eq 0) {
$procs = Get-Process -Name acclient -ErrorAction SilentlyContinue
if (-not $procs) { throw "acclient.exe not running" }
if ($procs.Count -gt 1) { throw "multiple acclient.exe instances; specify -ProcessId" }
$ProcessId = $procs[0].Id
}
# Find next snap_NNN filename
$existing = Get-ChildItem -Path $PhaseDir -Filter "snap_*.txt" -ErrorAction SilentlyContinue |
ForEach-Object { [int]([regex]::Match($_.BaseName, '\d+').Value) }
$next = if ($existing) { ($existing | Measure-Object -Maximum).Maximum + 1 } else { 1 }
$Out = Join-Path $PhaseDir ("snap_{0:D3}.txt" -f $next)
Write-Host "[$(Get-Date -Format HH:mm:ss)] umdh -p:$ProcessId -f:$Out"
$argList = @("-p:$ProcessId", "-f:$Out")
$p = Start-Process -FilePath $UmdhExe -ArgumentList $argList -NoNewWindow -PassThru -RedirectStandardError "$Out.err"
if (-not $p.WaitForExit($TimeoutSec * 1000)) {
Stop-Process -Id $p.Id -Force
throw "umdh timed out after $TimeoutSec s"
}
if (-not (Test-Path $Out)) { throw "umdh produced no output (exit $($p.ExitCode))" }
$size = (Get-Item $Out).Length
# Pull the leading metadata + a quick stats summary
$head = (Get-Content $Out -TotalCount 6) -join "`n"
$totalAllocs = (Select-String -Path $Out -Pattern '^\d+ bytes \+' -AllMatches).Matches.Count
Write-Host ("[$(Get-Date -Format HH:mm:ss)] OK: {0} ({1:N0} bytes; {2} alloc-stack records)" -f $Out, $size, $totalAllocs)
Write-Host "--- head ---"
Write-Host $head
# Emit metadata for the orchestrator
[pscustomobject]@{
Path = $Out
SizeBytes = $size
Records = $totalAllocs
Pid = $ProcessId
Timestamp = (Get-Date).ToString("o")
}

319
dll/DESIGN.md Normal file
View 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
~78 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
View 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

Binary file not shown.

View 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
View 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
View 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
View 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
View 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

View 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

View 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
View 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
View 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

View 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
View 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
View 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"

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View 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

View 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;
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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.

View 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
}
}

View 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"

View 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

View 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;
}

View 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

View 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

View 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

View 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

View 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

View 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

View 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.

View 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
}
}

View 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"

View 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()

View 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
View 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

Binary file not shown.

BIN
pdb/acclient.pdb Normal file

Binary file not shown.

70719
references/acclient.h Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

91832
references/symbols.json Normal file

File diff suppressed because it is too large Load diff

26857
references/types.json Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,46 @@
{
"phases": [
{
"name": "idle",
"duration_min": 60,
"description": "Sit at lifestone. No input. Establishes baseline allocator noise.",
"actions": []
},
{
"name": "wander",
"duration_min": 60,
"description": "Walk a fixed route around Holtburg town. Targets streaming + landblock loads.",
"actions": [
{ "type": "walk_route", "waypoints": ["lifestone", "town-square", "marketplace", "south-gate", "lifestone"], "loop": true }
]
},
{
"name": "chat",
"duration_min": 60,
"description": "Spam /say and /tell. Targets chat-log buffers.",
"actions": [
{ "type": "send_chat", "channel": "say", "message_template": "test {counter}", "interval_sec": 2 }
]
},
{
"name": "target-cycle",
"duration_min": 60,
"description": "Tab through nearby targetables. No combat. Targets selection + tooltip allocation.",
"actions": [
{ "type": "press_key", "key": "Tab", "interval_sec": 3 }
]
},
{
"name": "ui-cycle",
"duration_min": 60,
"description": "Open/close inventory, character pane, spells pane. Targets UI-widget allocation.",
"actions": [
{ "type": "press_key", "key": "i", "interval_sec": 5 },
{ "type": "press_key", "key": "c", "interval_sec": 7 },
{ "type": "press_key", "key": "s", "interval_sec": 9 }
]
}
],
"snapshot_interval_min": 15,
"notes": "Phase 2 schedule. Run one phase per session, fresh from bench-verified snapshot. Compare growth rates across phases to localize the leak's subsystem."
}

52
templates/login.ahk Normal file
View file

@ -0,0 +1,52 @@
; AutoHotkey v2 — login skeleton for retail acclient.exe
;
; Drives the launcher login screen. Fills in test credentials, clicks
; through character select. Adjust ImageSearch / Click coordinates after
; first manual run — UI layouts depend on resolution and skin.
;
; Usage: launch this after supervisor.ps1 starts acclient.exe
#Requires AutoHotkey v2.0
#SingleInstance Force
; --- config ---
USERNAME := "testaccount"
PASSWORD := "testpassword"
CHAR_SLOT := 1
WAIT_TIMEOUT_S := 60
; --- end config ---
WinTitle := "Asheron's Call"
; Wait for the AC window
if not WinWait(WinTitle, , WAIT_TIMEOUT_S) {
MsgBox "AC window not found within " WAIT_TIMEOUT_S "s — aborting"
ExitApp 1
}
WinActivate WinTitle
Sleep 2000
; Type username
Send USERNAME
Send "{Tab}"
Send PASSWORD
Send "{Enter}"
; Wait for character select screen — adjust the wait for your skin
Sleep 8000
; Select character (slot 1 is top of list)
Loop CHAR_SLOT - 1 {
Send "{Down}"
Sleep 200
}
Send "{Enter}"
; Wait for in-world load
Sleep 15000
; If you got here, you're in-world.
; The supervisor doesn't need anything else from us; the controller DLL
; (Phase 3) drives in-game activity.
ExitApp 0

26
templates/snapshot.ps1 Normal file
View file

@ -0,0 +1,26 @@
# Take one UMDH stack-tagged heap snapshot of a running process.
#
# Requirements:
# - gflags /i acclient.exe +ust (one-time, registry-set)
# - _NT_SYMBOL_PATH pointing at acclient.pdb directory
# - umdh.exe on PATH (Windows Debugging Tools)
param(
[Parameter(Mandatory=$true)][int]$ProcessId,
[Parameter(Mandatory=$true)][string]$Out
)
$ErrorActionPreference = "Stop"
if (-not $env:_NT_SYMBOL_PATH) {
Write-Warning "_NT_SYMBOL_PATH not set — symbols may not resolve"
}
& umdh.exe -p:$ProcessId -f:$Out
if (-not (Test-Path $Out)) {
throw "umdh produced no output at $Out"
}
$size = (Get-Item $Out).Length
Write-Host "snapshot: $Out ($size bytes)"

119
templates/supervisor.ps1 Normal file
View file

@ -0,0 +1,119 @@
# Supervisor harness for the AC client memory-leak hunt.
#
# What this does:
# - Sets _NT_SYMBOL_PATH so umdh/cdb resolve symbols against acclient.pdb
# - Verifies gflags +ust is enabled (required for stack-tagged allocations)
# - Starts ACE (optionally) and the AC client
# - Periodically calls snapshot.ps1 to capture UMDH snapshots
# - Watches for process exit; on crash, captures procdump + final snapshot
#
# Skeleton — flesh out at Phase 1 time. Configurable up top.
param([Parameter(Mandatory=$true)][string]$Phase)
#region Config
$AcExe = "C:\Turbine\Asheron's Call\acclient.exe"
$PdbDir = "C:\Users\acbot\leakhunt\pdb"
$OutRoot = "C:\Users\acbot\leakhunt\artifacts"
$LauncherPs1 = "C:\Users\acbot\leakhunt\bin\launch_acclient.ps1" # gitignored, has creds
$UmdhExe = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\umdh.exe"
$GflagsExe = "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\gflags.exe"
$SnapshotEvery = 1800 # seconds (30 min — Phase 1 default; bump down for Phase 4)
$MaxDuration = 14400 # seconds (4h default — bump for Phase 4)
$AceCwd = $null # Coldeve real server in use; no local ACE
#endregion
$ErrorActionPreference = "Stop"
function Write-Step([string]$msg) {
Write-Host "[$(Get-Date -Format HH:mm:ss)] $msg" -ForegroundColor Cyan
}
$phaseDir = Join-Path $OutRoot $Phase
New-Item -ItemType Directory -Path $phaseDir -Force | Out-Null
Write-Step "Output: $phaseDir"
# 1. Symbol path for umdh / cdb
$env:_NT_SYMBOL_PATH = $PdbDir
Write-Step "_NT_SYMBOL_PATH = $env:_NT_SYMBOL_PATH"
# 2. Confirm gflags +ust is set on acclient.exe (via IFEO registry — no admin needed)
$ifeoFlag = (Get-ItemProperty "HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Image File Execution Options\acclient.exe" -ErrorAction SilentlyContinue).GlobalFlag
if (-not $ifeoFlag -or -not ($ifeoFlag -band 0x1000)) {
throw "gflags +ust NOT set on acclient.exe (GlobalFlag=$ifeoFlag). Run elevated: gflags /i acclient.exe +ust"
}
Write-Step "gflags +ust verified (GlobalFlag=0x$([Convert]::ToString($ifeoFlag,16)))"
# 3. (Optional) start ACE
if ($AceCwd -ne $null) {
Write-Step "Starting ACE in $AceCwd ..."
$aceProc = Start-Process -FilePath "pwsh" -ArgumentList "-c", $AceCmd `
-WorkingDirectory $AceCwd -PassThru -WindowStyle Minimized
Start-Sleep -Seconds 8
if ($aceProc.HasExited) {
throw "ACE exited during startup. Check $AceCwd."
}
} else {
Write-Step "ACE not auto-started — assume user/operator has it running"
}
# 4. Launch acclient via the credentialed launcher (auto-login via -a/-v/-h CLI args)
Write-Step "Launching acclient via $LauncherPs1 ..."
& $LauncherPs1
Start-Sleep -Seconds 5
$acProc = Get-Process -Name acclient -ErrorAction Stop | Sort-Object StartTime -Descending | Select-Object -First 1
$pid_ac = $acProc.Id
Write-Step "acclient pid = $pid_ac"
# Wait for in-world plateau (working set typically settles past ~500 MB once cell data loads)
Write-Step "Waiting for in-world plateau (working set >= 500 MB) ..."
$plateauDeadline = (Get-Date).AddSeconds(180)
while ((Get-Date) -lt $plateauDeadline) {
Start-Sleep -Seconds 5
if ($acProc.HasExited) { throw "acclient exited during login. ExitCode=$($acProc.ExitCode)" }
$ws = (Get-Process -Id $pid_ac).WorkingSet64
if ($ws -gt 500MB) {
Write-Step "Plateau detected: WS=$([math]::Round($ws/1MB,1)) MB"
break
}
}
# 6. Snapshot loop
$start = Get-Date
$snapIdx = 1
$snapshotScript = Join-Path $PSScriptRoot "snapshot.ps1"
while ($true) {
Start-Sleep -Seconds $SnapshotEvery
if ($acProc.HasExited) {
Write-Step "acclient EXITED — code $($acProc.ExitCode)"
# Capture dump if process still around (sometimes lingers briefly)
# & procdump -ma $pid_ac "$phaseDir\crash.dmp" 2>&1 | Out-Null
break
}
$snapPath = Join-Path $phaseDir ("snap_{0:D3}.txt" -f $snapIdx)
Write-Step "snapshot $snapIdx -> $snapPath"
& $snapshotScript -ProcessId $pid_ac -Out $snapPath
$snapIdx++
if (((Get-Date) - $start).TotalSeconds -gt $MaxDuration) {
Write-Step "Max duration reached. Final snapshot done."
break
}
}
# 7. Final diff
if ($snapIdx -gt 2) {
$first = Join-Path $phaseDir ("snap_001.txt")
$last = Join-Path $phaseDir ("snap_{0:D3}.txt" -f ($snapIdx - 1))
$diff = Join-Path $phaseDir "diff_first_to_last.txt"
Write-Step "Diff: $first -> $last"
& $UmdhExe -d $first $last -f:$diff
}
Write-Step "Supervisor done."

48
templates/trace.cdb Normal file
View file

@ -0,0 +1,48 @@
$$ cdb scripting template — attach to acclient.exe, set non-blocking
$$ breakpoints on suspected allocator functions, count hits, auto-detach.
$$
$$ Usage:
$$ cdb.exe -pn acclient.exe -cf <this-file> -logo <output.log>
$$
$$ Or attach by PID:
$$ cdb.exe -p <pid> -cf <this-file> -logo <output.log>
$$
$$ Tips:
$$ - `gc` = "go conditional" — continue without breaking the debuggee
$$ - `qd` = "quit detached" — leaves the debuggee running, exits cdb
$$ - Counter $t0..$t19 are persistent across breakpoint hits
$$ - Don't put `;` inside breakpoint action strings without escaping —
$$ cdb's command parser splits on `;` even inside actions.
.logopen /t leak-trace.log
$$ Symbol path — local PDB only, no symbol server.
.sympath C:\leak-hunt\pdb
.symopt+ 0x40
.reload /f acclient.exe
$$ Verify the symbol we care about resolves (replace as needed)
$$ x acclient!CChatManager::AddLine
$$ ============================================================
$$ Counters
$$ ============================================================
r $t0 = 0 $$ alloc-site hits
r $t1 = 0 $$ free-site hits
r $t2 = 0 $$ unmatched (leak candidate) hits
$$ ============================================================
$$ Breakpoint pattern: increment counter, log every Nth, auto-detach at M
$$ ============================================================
$$ Replace <ALLOC_FN> and <FREE_FN> with the suspected function names.
bp acclient!<ALLOC_FN> "r $t0 = @$t0 + 1; .if (@$t0 % 1000 == 0) { .printf \"alloc hits: %d\\n\", @$t0 }; .if (@$t0 >= 100000) { .printf \"AUTO-DETACH at %d\\n\", @$t0; qd } .else { gc }"
bp acclient!<FREE_FN> "r $t1 = @$t1 + 1; .if (@$t1 % 1000 == 0) { .printf \"free hits: %d\\n\", @$t1 }; gc"
$$ Optional: dump `this` struct on first hit
$$ bp acclient!<ALLOC_FN> "r $t0 = @$t0 + 1; .if (@$t0 == 1) { dt acclient!<ClassName> @ecx }; gc"
g
.logclose

239
tools/analyze_dump.py Normal file
View file

@ -0,0 +1,239 @@
"""
analyze_dump.py <dump.dmp>
Parses a Windows minidump and computes VA-region stats with no PDB
dependency:
* total committed memory, broken down by Type (Private/Mapped/Image)
* top-N largest committed regions with module/path attribution
* size-bucket histogram of committed regions
* module list with image base and size
Output: writes <dump.dmp>.stats.json next to the dump and prints a
short human summary to stdout.
"""
import json
import os
import sys
from collections import Counter, defaultdict
from minidump.minidumpfile import MinidumpFile
MEM_COMMIT = 0x1000
MEM_RESERVE = 0x2000
MEM_FREE = 0x10000
MEM_PRIVATE = 0x20000
MEM_MAPPED = 0x40000
MEM_IMAGE = 0x1000000
def _enum_int(v):
"""minidump library may return State/Type as Enum or int — normalize to int."""
if v is None:
return 0
if hasattr(v, 'value'):
return int(v.value)
return int(v)
PROT_NAMES = {
0x01: "NOACCESS", 0x02: "READONLY", 0x04: "READWRITE", 0x08: "WRITECOPY",
0x10: "EXECUTE", 0x20: "EXECUTE_READ", 0x40: "EXECUTE_READWRITE",
0x80: "EXECUTE_WRITECOPY",
}
def fmt_prot(p):
base = p & 0xFF
name = PROT_NAMES.get(base, f"0x{base:02x}")
if p & 0x100: name += "|GUARD"
if p & 0x200: name += "|NOCACHE"
if p & 0x400: name += "|WRITECOMBINE"
return name
def fmt_state(s):
if s == MEM_COMMIT: return "COMMIT"
if s == MEM_RESERVE: return "RESERVE"
if s == MEM_FREE: return "FREE"
return f"0x{s:x}"
def fmt_type(t):
if t == MEM_PRIVATE: return "Private"
if t == MEM_MAPPED: return "Mapped"
if t == MEM_IMAGE: return "Image"
return f"0x{t:x}"
def power_of_2_bucket(sz):
"""Return string like '64KB-128KB'."""
if sz <= 0: return "0"
p = sz.bit_length() - 1
lo = 1 << p
hi = lo << 1
def fmt(n):
if n >= 1024*1024*1024: return f"{n//(1024*1024*1024)}GB"
if n >= 1024*1024: return f"{n//(1024*1024)}MB"
if n >= 1024: return f"{n//1024}KB"
return f"{n}B"
return f"{fmt(lo)}-{fmt(hi)}"
def main():
if len(sys.argv) < 2:
print("usage: analyze_dump.py <dump.dmp>", file=sys.stderr); sys.exit(1)
path = sys.argv[1]
if not os.path.exists(path):
print(f"not found: {path}", file=sys.stderr); sys.exit(1)
md = MinidumpFile.parse(path)
out = {
"path": path,
"file_size_mb": round(os.path.getsize(path)/(1024*1024), 1),
}
# System info
si = md.sysinfo
if si is not None:
out["sysinfo"] = {
"ProcessorArchitecture": str(si.ProcessorArchitecture),
"ProductType": str(si.ProductType),
"MajorVersion": si.MajorVersion,
"MinorVersion": si.MinorVersion,
"BuildNumber": si.BuildNumber,
}
# Modules
mods = []
if md.modules:
for m in md.modules.modules:
mods.append({
"name": os.path.basename(m.name),
"base": m.baseaddress,
"size": m.size,
"ts": m.timestamp,
})
out["modules"] = mods
out["modules_count"] = len(mods)
# Build a "what module owns this address" lookup
def mod_owning(addr):
for m in mods:
if m["base"] <= addr < m["base"] + m["size"]:
return m["name"]
return None
# Memory info — the VAD-like list (state/type/protection per region)
regions = []
by_state_type = Counter() # (state, type) -> bytes
by_state_type_count = Counter() # (state, type) -> count
bucket_committed = Counter()
if md.memory_info and md.memory_info.infos:
for r in md.memory_info.infos:
base = r.BaseAddress
sz = r.RegionSize
st = _enum_int(r.State)
ty = _enum_int(r.Type)
pr = _enum_int(r.Protect)
regions.append({
"base": base,
"size": sz,
"state": st,
"type": ty,
"protect": pr,
"owner": mod_owning(base),
})
by_state_type[(st, ty)] += sz
by_state_type_count[(st, ty)] += 1
if st == MEM_COMMIT:
bucket_committed[power_of_2_bucket(sz)] += sz
# Largest committed regions
committed = sorted([r for r in regions if r["state"] == MEM_COMMIT],
key=lambda r: r["size"], reverse=True)
out["top20_committed"] = [
{
"base": f"0x{r['base']:08x}",
"size": r["size"],
"size_h": _h(r["size"]),
"type": fmt_type(r["type"]),
"prot": fmt_prot(r["protect"]),
"owner": r["owner"],
}
for r in committed[:20]
]
out["regions_count"] = len(regions)
out["committed_total"] = sum(r["size"] for r in regions if r["state"] == MEM_COMMIT)
out["committed_private_total"] = sum(r["size"] for r in regions
if r["state"] == MEM_COMMIT and r["type"] == MEM_PRIVATE)
out["committed_image_total"] = sum(r["size"] for r in regions
if r["state"] == MEM_COMMIT and r["type"] == MEM_IMAGE)
out["committed_mapped_total"] = sum(r["size"] for r in regions
if r["state"] == MEM_COMMIT and r["type"] == MEM_MAPPED)
# Per-module image commit (sums all committed Image regions per owner module)
by_module_image = defaultdict(int)
for r in regions:
if r["state"] == MEM_COMMIT and r["type"] == MEM_IMAGE and r["owner"]:
by_module_image[r["owner"]] += r["size"]
out["top_image_modules"] = sorted(
[{"module": k, "image_bytes": v} for k, v in by_module_image.items()],
key=lambda x: x["image_bytes"], reverse=True
)[:15]
# Per-bucket committed (mostly interesting for private)
out["committed_size_buckets"] = [
{"bucket": k, "bytes": v, "count": sum(1 for r in regions if r["state"] == MEM_COMMIT and power_of_2_bucket(r["size"]) == k)}
for k, v in sorted(bucket_committed.items(), key=lambda x: x[1], reverse=True)
]
# Specifically: large private committed regions w/ exec/rw protect (heap suspects)
heap_suspects = [r for r in regions
if r["state"] == MEM_COMMIT
and r["type"] == MEM_PRIVATE
and (r["protect"] & 0xFF) in (0x04, 0x40) # RW / EXECUTE_READWRITE
and r["size"] >= 64*1024] # at least 64 KB
heap_suspects.sort(key=lambda r: r["size"], reverse=True)
out["heap_suspect_regions"] = [
{
"base": f"0x{r['base']:08x}",
"size": r["size"],
"size_h": _h(r["size"]),
"prot": fmt_prot(r["protect"]),
}
for r in heap_suspects[:50]
]
out["heap_suspect_total"] = sum(r["size"] for r in heap_suspects)
out["heap_suspect_count"] = len(heap_suspects)
# Write JSON
out_path = path + ".stats.json"
with open(out_path, "w", encoding="utf8") as f:
json.dump(out, f, indent=2)
# Pretty summary to stdout
print(f"=== {os.path.basename(path)} ===")
print(f"file: {out['file_size_mb']} MB regions: {out['regions_count']} modules: {out['modules_count']}")
print(f" committed_total {_h(out['committed_total'])}")
print(f" private {_h(out['committed_private_total'])}")
print(f" image {_h(out['committed_image_total'])}")
print(f" mapped {_h(out['committed_mapped_total'])}")
print(f" heap_suspect (private RW, >=64KB): {_h(out['heap_suspect_total'])} across {out['heap_suspect_count']} regions")
print(f"")
print(f" top 10 image modules by committed size:")
for m in out["top_image_modules"][:10]:
print(f" {_h(m['image_bytes']):>10} {m['module']}")
print(f"")
print(f" top 10 committed regions:")
for r in out["top20_committed"][:10]:
own = r["owner"] or ""
print(f" {r['size_h']:>10} {r['base']} {r['type']:>8} {r['prot']:<28} {own}")
print(f"")
print(f" wrote {out_path}")
def _h(n):
if n >= 1024*1024*1024: return f"{n/(1024*1024*1024):.2f} GB"
if n >= 1024*1024: return f"{n/(1024*1024):.2f} MB"
if n >= 1024: return f"{n/1024:.1f} KB"
return f"{n} B"
if __name__ == "__main__":
main()

View file

@ -0,0 +1,144 @@
"""patch_v15_positionhash_audit.py <pid>
READ-ONLY audit. Does NOT modify the target process.
Goal: confirm that the dominant Position-leak path is through
CBaseQualities::_posStatsTable (PackableHashTable<uint, Position>),
and identify which weenie types host the most retained Positions.
Method:
1. Scan live private memory for Position vt 0x00797910 instances.
2. For each, walk back through nearby heap headers to find the
containing PositionPropertyValue (vt write at offset -0x08 +
m_cRef at -0x04). PositionPropertyValue is the per-property
ref-counted holder that lives inside the hash table.
3. From the PositionPropertyValue, look for back-pointers into a
PackableHashTable node, then up to the owning CBaseQualities.
4. Print: count per (weenie-vt, dominant property key).
Caveats: heap-layout heuristics are fragile. Many Positions are
stack-locals, not heap-allocated those should be filtered out by
checking the alignment-stride of the containing region.
Use this BEFORE designing any v14-style ctor trace, because it
narrows the search: if 95% of leaked Positions are inside one
weenie type's quality table, the patch target is that weenie's
unload path, not Position itself.
"""
import ctypes
import ctypes.wintypes as wt
import struct
import sys
from collections import Counter
POSITION_VT = 0x00797910
# PositionPropertyValue vtable EoR address — DERIVE before use.
# 2013 was at &PositionPropertyValue::vftable near 0x00796928.
# In EoR the address is near 0x00797928 (placeholder; verify).
POSITION_PROP_VAL_VT = 0x00797928 # PLACEHOLDER — verify
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
k.OpenProcess.restype = wt.HANDLE
k.CloseHandle.argtypes = [wt.HANDLE]
k.CloseHandle.restype = wt.BOOL
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID,
ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p,
ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
def audit(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION,
False, pid)
if not h:
print(f"OpenProcess failed (err={ctypes.get_last_error()})")
return 1
position_addrs = []
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == 0x1000 and mbi.Type == 0x20000
and pr in (0x04, 0x40)):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf,
mbi.RegionSize, ctypes.byref(sz)):
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
for off in range(0, end, 4):
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
position_addrs.append((mbi.BaseAddress + off,
mbi.BaseAddress,
mbi.RegionSize))
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
print(f"Total Position instances found: {len(position_addrs)}")
# Stage 2: classify by containing region size + density to
# separate stack/embedded from heap allocations
region_density = Counter()
for addr_, base, size in position_addrs:
region_density[(base, size)] += 1
print("\nDensity histogram (region_size_kb, hits_per_region, "
"count):")
bucket = Counter()
for (base, size), n in region_density.items():
kb = size // 1024
bucket[(kb, n)] += 1
for (kb, n), c in sorted(bucket.items())[:30]:
if c > 1:
print(f" region={kb:>5}KB hits={n:>4} count={c:>4}")
# Stage 3: for first 200 Position hits, look 8 bytes backwards
# for PositionPropertyValue vtable (PPV layout: [vt][m_cRef][position])
ppv_hits = 0
for i, (paddr, base, size) in enumerate(position_addrs[:1000]):
ppv_vt_addr = paddr - 8
if ppv_vt_addr < base:
continue
# Read 4 bytes
buf4 = (ctypes.c_ubyte * 4)()
sz4 = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, ppv_vt_addr, buf4, 4,
ctypes.byref(sz4)):
continue
vt = struct.unpack("<I", bytes(buf4))[0]
if vt == POSITION_PROP_VAL_VT:
ppv_hits += 1
print(f"\nOf first 1000 Position hits, {ppv_hits} are inside "
f"PositionPropertyValue (vt+m_cRef at -8).")
print("If this number is high (>500), the leak is in the "
"property-value layer.")
print("If low, the leak is in raw Position heap allocations "
"(check PositionPack / position_queue paths).")
k.CloseHandle(h)
return 0
if __name__ == "__main__":
sys.exit(audit(int(sys.argv[1])))

38
tools/auto_v5_watcher.sh Normal file
View file

@ -0,0 +1,38 @@
#!/usr/bin/env bash
# Persistent watcher: every 5 min, finds acclient PIDs that have v3b applied
# but NOT v5, applies v5 to them. Skips any PID whose window title contains "Jerry".
# Emits one line per applied PID (event-style for Monitor).
set -u
PY="C:/Users/acbot/AppData/Local/Programs/Python/Python312/python.exe"
cd /c/Users/acbot/leakhunt
while true; do
# Get all acclient PIDs + window titles via PowerShell
pid_titles=$(powershell.exe -NoProfile -Command \
"Get-Process acclient -EA SilentlyContinue | ForEach-Object { \"\$(\$_.Id)|\$(\$_.MainWindowTitle)\" }" \
2>/dev/null | tr -d '\r')
while IFS='|' read -r pid title; do
[ -z "$pid" ] && continue
# Skip Jerry (control)
if echo "$title" | grep -qi "Jerry"; then continue; fi
# Check patch state — only proceed if v3b is applied AND v5 is not
state=$("$PY" tools/check_patch_state.py "$pid" 2>/dev/null \
| awk -v p="$pid" '$1==p {print $2,$3,$5,$6}')
[ -z "$state" ] && continue
read v3b1 v3b2 v5rs v5rt <<<"$state"
# v3b must be P/P, v5-RS must be "." (no thunk yet)
if [ "$v3b1" = "P" ] && [ "$v3b2" = "P" ] && [ "$v5rs" = "." ]; then
result=$("$PY" tools/patch_purge_v5_test.py "$pid" 2>&1)
if echo "$result" | grep -q "OK"; then
echo "AUTO-V5 PID=$pid title=\"$title\" applied $(date +%H:%M:%S)"
else
echo "AUTO-V5-FAIL PID=$pid title=\"$title\" output: $(echo "$result" | tail -2 | tr '\n' ' ')"
fi
fi
done <<< "$pid_titles"
sleep 300
done

View file

@ -0,0 +1,96 @@
"""broader_vtable_sweep.py <larsson.dmp> <time.dmp>
Find vtables in the acclient image range that have very high count
in larsson and low count in time, EXCLUDING the already-tracked ones.
Aim: surface previously-untracked leak classes.
"""
import struct, sys
from collections import Counter
from minidump.minidumpfile import MinidumpFile
# Known/already-investigated vtables
KNOWN = {
0x007c0498: "UIElement_UIItem",
0x007caa08: "Palette",
0x007c78ec: "CPhysicsObj",
0x0079a67c: "RenderSurface",
0x00801a94: "RenderSurfaceD3D",
0x00801a18: "RenderTextureD3D",
0x007ca4dc: "CSurface",
0x007cab04: "ImgTex",
0x007ca418: "CGfxObj",
0x007ed3b0: "D3DXMesh",
0x0079bf64: "GraphicsResource",
0x007ccb60: "NoticeHandler_subvt",
0x007c98e8: "CObjCell_primary",
0x007c9a60: "CEnvCell_primary",
0x0079385c: "CObjCell_subvt",
0x00400c08: "CPhys_data_sentinel",
0x007c9b58: "CPhys_inner", # speculative
0x0079c198: "RenderTexture",
}
# Acclient image VA range (32-bit)
IMG_LO = 0x00400000
IMG_HI = 0x00880000
def _ei(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
def scan(dmp_path):
md = MinidumpFile.parse(dmp_path)
reader = md.get_reader().get_buffered_reader()
counts = Counter()
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40):
continue
try:
reader.move(r.BaseAddress)
buf = reader.read(r.RegionSize)
except Exception:
continue
if not buf: continue
end = (len(buf) // 4) * 4
for off in range(0, end, 4):
v = struct.unpack_from("<I", buf, off)[0]
if IMG_LO <= v < IMG_HI:
# filter UTF-16 noise: low byte 0x00 of every other byte
# rough check: byte 1 is 0 and byte 3 is 0 with letters in bytes 0,2
b0, b1, b2, b3 = v & 0xff, (v >> 8) & 0xff, (v >> 16) & 0xff, (v >> 24) & 0xff
if b1 == 0 and b3 == 0 and 0x20 <= b0 <= 0x7e and 0x20 <= b2 <= 0x7e:
continue # UTF-16 string fragment
counts[v] += 1
return counts
def main():
larsson = sys.argv[1]
time_path = sys.argv[2]
print(f"scanning larsson: {larsson}")
lc = scan(larsson)
print(f" unique vtables: {len(lc)}")
print(f"scanning time: {time_path}")
tc = scan(time_path)
print(f" unique vtables: {len(tc)}")
# Compute diff
rows = []
for vt, c in lc.items():
if vt in KNOWN: continue
if c < 20: continue
t = tc.get(vt, 0)
ratio = c / max(t, 0.5)
delta = c - t
rows.append((vt, t, c, ratio, delta))
rows.sort(key=lambda x: -x[4])
print()
print("UN-tracked acclient vtables, sorted by absolute delta (larsson - time):")
print(f"{'vtable':12} {'time':>6} {'larsson':>8} {'ratio':>7} {'delta':>7}")
for vt, t, c, ratio, delta in rows[:40]:
print(f"0x{vt:08x} {t:>6} {c:>8} {ratio:>7.1f} {delta:>7}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,103 @@
"""build_patched_binary.py
Build acclient.eor.patched.exe by applying the v3b byte patches to a
backed-up copy of the original EoR acclient.exe.
Patches (file offsets = VA - 0x400 since base=0x400000 and .text RVA is +0x400):
- VA 0x0053effe ff 40 24 -> 90 90 90 ; NOP makeModifiedPalette() over-increment
- VA 0x0053f19c ff 46 24 -> 90 90 90 ; NOP makeModifiedPalette(id,sub) over-increment
For 32-bit PE with base=0x00400000 and .text starting at RVA 0x1000
mapped to file offset 0x400 (standard MSVC layout), file_offset =
VA - 0x00400000 - 0x1000 + 0x400 = VA - 0x00400C00.
"""
import hashlib, os, shutil, struct, sys
SRC = r"C:\Turbine\Asheron's Call\acclient.exe"
ORIG = r"C:\Turbine\Asheron's Call\acclient.eor.orig.exe"
PATCHED = r"C:\Turbine\Asheron's Call\acclient.eor.patched.exe"
# VA-based patches (we resolve file offsets via PE parsing)
PATCHES = [
(0x0053effe, bytes([0xff, 0x40, 0x24]), bytes([0x90, 0x90, 0x90])),
(0x0053f19c, bytes([0xff, 0x46, 0x24]), bytes([0x90, 0x90, 0x90])),
]
def parse_pe_sections(data):
"""Return list of (rva_start, rva_size, file_offset) tuples."""
e_lfanew = struct.unpack_from("<I", data, 0x3c)[0]
sig = data[e_lfanew:e_lfanew+4]
assert sig == b"PE\0\0", f"bad PE sig: {sig}"
# COFF header at e_lfanew + 4
num_sections = struct.unpack_from("<H", data, e_lfanew + 6)[0]
size_opt_hdr = struct.unpack_from("<H", data, e_lfanew + 0x14)[0]
sections_off = e_lfanew + 0x18 + size_opt_hdr
image_base = struct.unpack_from("<I", data, e_lfanew + 0x34)[0] # PE32 ImageBase
out = []
for i in range(num_sections):
sh = sections_off + i * 0x28
virtual_size = struct.unpack_from("<I", data, sh + 0x08)[0]
virtual_addr = struct.unpack_from("<I", data, sh + 0x0c)[0]
raw_size = struct.unpack_from("<I", data, sh + 0x10)[0]
raw_off = struct.unpack_from("<I", data, sh + 0x14)[0]
name = data[sh:sh+8].rstrip(b"\0").decode("ascii", "replace")
out.append((name, image_base + virtual_addr, virtual_size, raw_off, raw_size))
return image_base, out
def va_to_file_offset(va, sections):
for name, va_start, vsize, raw_off, raw_size in sections:
if va_start <= va < va_start + max(vsize, raw_size):
return raw_off + (va - va_start)
raise ValueError(f"VA 0x{va:08x} not in any section")
def main():
if not os.path.exists(SRC):
print(f"src not found: {SRC}"); sys.exit(1)
# Backup
if not os.path.exists(ORIG):
shutil.copy2(SRC, ORIG)
print(f"backup written: {ORIG}")
else:
print(f"backup already exists: {ORIG}")
with open(ORIG, "rb") as f:
data = bytearray(f.read())
sha_orig = hashlib.sha256(data).hexdigest()
print(f"orig sha256: {sha_orig}")
print(f"orig size: {len(data)}")
image_base, sections = parse_pe_sections(data)
print(f"image base: 0x{image_base:08x}")
for s in sections[:3]:
print(f" section {s[0]:<8} va=0x{s[1]:08x} vsize=0x{s[2]:x} raw=0x{s[3]:08x} rsize=0x{s[4]:x}")
# Apply patches
for va, orig_bytes, patched_bytes in PATCHES:
off = va_to_file_offset(va, sections)
actual = bytes(data[off:off+len(orig_bytes)])
if actual == orig_bytes:
data[off:off+len(patched_bytes)] = patched_bytes
print(f" patched VA 0x{va:08x} (file off 0x{off:x}): "
f"{' '.join(f'{b:02x}' for b in orig_bytes)} -> "
f"{' '.join(f'{b:02x}' for b in patched_bytes)}")
elif actual == patched_bytes:
print(f" VA 0x{va:08x} already patched, skipping")
else:
print(f" VA 0x{va:08x} UNEXPECTED bytes: {' '.join(f'{b:02x}' for b in actual)}")
sys.exit(2)
with open(PATCHED, "wb") as f:
f.write(data)
sha_new = hashlib.sha256(bytes(data)).hexdigest()
print(f"\npatched sha256: {sha_new}")
print(f"written: {PATCHED}")
if __name__ == "__main__":
main()

179
tools/byte_accounting.py Normal file
View file

@ -0,0 +1,179 @@
"""byte_accounting.py <pid>
Walk all committed memory in a target process and categorize bytes by:
- VAD region type (private vs mapped vs image)
- Protection (RW vs RX vs RWX)
- Size bucket
- Known-class signature scan (vtable bytes within the region)
Output: per-category totals so we can see where the working set lives."""
import ctypes, ctypes.wintypes as wt, sys, struct
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t, ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, wt.LPCVOID, ctypes.c_void_p, ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
class MBI(ctypes.Structure):
_fields_ = [
("BaseAddress", ctypes.c_void_p),
("AllocationBase", ctypes.c_void_p),
("AllocationProtect", wt.DWORD),
("RegionSize", ctypes.c_size_t),
("State", wt.DWORD),
("Protect", wt.DWORD),
("Type", wt.DWORD),
]
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
MEM_MAPPED = 0x40000
MEM_IMAGE = 0x1000000
# Known class vtables from instr.cpp
KNOWN_VTABLES = {
0x007C78EC: "CPhysicsObj",
0x0079A67C: "RenderSurface",
0x0079C198: "RenderTexture",
0x00801A94: "RenderSurfaceD3D",
0x00801A18: "RenderTextureD3D",
0x007CA4DC: "CSurface(GR)",
0x007CAB04: "ImgTex(GR)",
0x007CA418: "CGfxObj",
0x007ED3B0: "GXTri3Mesh",
0x007E4F70: "ACCWeenieObject",
0x007E4ED8: "CWeenieObject",
}
def rd(h, va, n):
buf = (ctypes.c_ubyte * n)(); sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, va, buf, n, ctypes.byref(sz)): return None
return bytes(buf[:sz.value])
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: print(f"OpenProcess err={ctypes.get_last_error()}"); sys.exit(2)
# Totals by region type
total_committed = 0
by_type = {"private_rw": 0, "private_rx": 0, "private_rwx": 0,
"mapped": 0, "image": 0, "other": 0}
# Bytes attributed to each known class (rough estimate: vtable_count × likely class size)
class_size_estimate = {
"CPhysicsObj": 376, # +0x178 from constructor allocation
"RenderSurface": 288,
"RenderTexture": 152,
"RenderSurfaceD3D": 304,
"RenderTextureD3D": 176,
"CSurface(GR)": 144,
"ImgTex(GR)": 136,
"CGfxObj": 200, # estimate
"GXTri3Mesh": 1000, # estimate; large mesh class
"ACCWeenieObject": 336,
"CWeenieObject": 200, # estimate
}
class_instance_count = {name: 0 for name in KNOWN_VTABLES.values()}
# Histogram of private-RW region sizes
size_buckets = [
(0, 4096, "<4K"),
(4096, 65536, "4K-64K"),
(65536, 262144, "64K-256K"),
(262144, 1048576, "256K-1M"),
(1048576, 4194304, "1M-4M"),
(4194304, 16777216, "4M-16M"),
(16777216, 67108864, "16M-64M"),
(67108864, 1<<31, "64M+"),
]
bucket_count = [0] * len(size_buckets)
bucket_bytes = [0] * len(size_buckets)
def classify_size(n):
for i, (lo, hi, _) in enumerate(size_buckets):
if lo <= n < hi: return i
return len(size_buckets) - 1
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
region_base = mbi.BaseAddress or 0
region_size = mbi.RegionSize
if mbi.State == MEM_COMMIT:
total_committed += region_size
prot = mbi.Protect & 0xFF
# Classify region type
if mbi.Type == MEM_IMAGE:
by_type["image"] += region_size
elif mbi.Type == MEM_MAPPED:
by_type["mapped"] += region_size
elif mbi.Type == MEM_PRIVATE:
if prot == 0x40: by_type["private_rwx"] += region_size
elif prot == 0x20: by_type["private_rx"] += region_size
elif prot == 0x04: by_type["private_rw"] += region_size
else: by_type["other"] += region_size
else:
by_type["other"] += region_size
# For private RW/RWX: bucket by size + scan for known vtables
if mbi.Type == MEM_PRIVATE and prot in (0x04, 0x40):
bi = classify_size(region_size)
bucket_count[bi] += 1
bucket_bytes[bi] += region_size
# Scan for known vtable bytes (skip huge regions to bound time)
if region_size <= 64 * 1024 * 1024:
try:
data = rd(h, region_base, region_size)
if data:
for vt, name in KNOWN_VTABLES.items():
vt_bytes = struct.pack('<I', vt)
count = 0
off = 0
while True:
off = data.find(vt_bytes, off)
if off < 0: break
if (off & 3) == 0: count += 1
off += 4
class_instance_count[name] += count
except Exception:
pass
next_addr = region_base + region_size
if next_addr <= addr: break
addr = next_addr
if addr >= 0x80000000: break
k.CloseHandle(h)
def mb(n): return f"{n/(1024*1024):,.1f}"
print(f"=== pid {pid} byte accounting ===")
print(f"Total committed: {mb(total_committed)} MB")
print()
print("By region type:")
for label, n in by_type.items():
pct = (n*100/total_committed) if total_committed else 0
print(f" {label:14s} {mb(n):>9} MB ({pct:5.1f}%)")
print()
print("Private RW/RWX region size distribution:")
print(f" {'bucket':<12} {'count':>6} {'total MB':>10}")
for i, (lo, hi, label) in enumerate(size_buckets):
if bucket_count[i] == 0: continue
print(f" {label:<12} {bucket_count[i]:>6} {mb(bucket_bytes[i]):>10}")
print()
print("Known-class vtable counts (and estimated bytes):")
print(f" {'class':<22} {'count':>6} {'est bytes':>12} {'est MB':>8}")
total_class_bytes = 0
for name in sorted(class_instance_count, key=lambda x: -class_instance_count[x]):
n = class_instance_count[name]
if n == 0: continue
sz = class_size_estimate.get(name, 200)
bytes_total = n * sz
total_class_bytes += bytes_total
print(f" {name:<22} {n:>6} {bytes_total:>12,} {mb(bytes_total):>8}")
print()
print(f" Identified-class total: ~{mb(total_class_bytes)} MB")
print(f" Of private RW: ~{mb(by_type['private_rw'])} MB")
unidentified = by_type['private_rw'] - total_class_bytes
print(f" UNIDENTIFIED in priv RW: ~{mb(unidentified)} MB ← what we don't account for")

2
tools/cdb_dump.txt Normal file
View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps\target.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps${NAME}.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps${NAME}.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps${NAME}.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps\nyckel_lowleak2.dmp
qd

View file

@ -0,0 +1,2 @@
.dump /ma /o C:\Users\acbot\leakhunt\artifacts\dumps\time_lowleak.dmp
qd

View file

@ -0,0 +1,65 @@
"""check_acclient_imports.py
Read acclient.exe imports and report whether leakfix.dll is already loaded."""
import struct, sys, os
EXE = r"C:\Turbine\Asheron's Call\acclient.exe"
if len(sys.argv) > 1: EXE = sys.argv[1]
with open(EXE, 'rb') as f:
data = f.read()
print(f"Loaded {len(data):,} bytes from {EXE}")
pe_off = struct.unpack_from('<I', data, 0x3C)[0]
print(f"PE header at 0x{pe_off:x}, signature: {data[pe_off:pe_off+4]}")
num_sections = struct.unpack_from('<H', data, pe_off + 4 + 2)[0]
opt_header_size = struct.unpack_from('<H', data, pe_off + 4 + 16)[0]
opt_off = pe_off + 4 + 20
magic = struct.unpack_from('<H', data, opt_off)[0]
print(f"sections={num_sections}, opt_hdr={opt_header_size}, magic=0x{magic:x}")
# Read sections to enable RVA→file translation
sect_off = opt_off + opt_header_size
sections = []
for i in range(num_sections):
so = sect_off + i*40
name = data[so:so+8].rstrip(b'\0').decode(errors='replace')
vsize = struct.unpack_from('<I', data, so+8)[0]
vaddr = struct.unpack_from('<I', data, so+12)[0]
rsize = struct.unpack_from('<I', data, so+16)[0]
rawoff = struct.unpack_from('<I', data, so+20)[0]
chars = struct.unpack_from('<I', data, so+36)[0]
sections.append((name, vaddr, vsize, rawoff, rsize, chars))
print(f" [{i}] {name:8s} vaddr=0x{vaddr:08x} vsize=0x{vsize:08x} raw=0x{rawoff:08x} rsize=0x{rsize:08x} chars=0x{chars:08x}")
def rva_to_off(rva):
for name, vaddr, vsize, rawoff, rsize, chars in sections:
if vaddr <= rva < vaddr + vsize:
return rawoff + (rva - vaddr)
return None
# DataDirectory[1] = Import Table
dd_off = opt_off + 96
import_rva = struct.unpack_from('<I', data, dd_off + 8)[0]
import_sz = struct.unpack_from('<I', data, dd_off + 12)[0]
print(f"\nImport directory: RVA=0x{import_rva:x} size={import_sz}")
import_foff = rva_to_off(import_rva)
print(f"Import directory file offset: 0x{import_foff:x}")
print("\nImports:")
off = import_foff
dlls = []
while True:
name_rva = struct.unpack_from('<I', data, off + 12)[0]
if name_rva == 0: break
name_foff = rva_to_off(name_rva)
name_end = data.index(b'\0', name_foff)
dll_name = data[name_foff:name_end].decode(errors='replace')
dlls.append((dll_name, off))
off += 20
for name, descriptor_off in dlls:
print(f" {name} (descriptor @ file 0x{descriptor_off:x})")
print(f"\nTotal: {len(dlls)} imports")
print(f"leakfix.dll already in imports? {any('leakfix' in n.lower() for n, _ in dlls)}")

119
tools/check_exe_pdb.py Normal file
View file

@ -0,0 +1,119 @@
"""Check an .exe's CodeView debug info to see what PDB GUID + age it
expects. Used to verify whether a candidate acclient.exe matches our
acclient.pdb without running the binary.
Usage:
py tools/pdb-extract/check_exe_pdb.py <path-to-exe>
"""
import struct
import sys
import uuid
def main():
if len(sys.argv) < 2:
print("usage: check_exe_pdb.py <path-to-exe>")
sys.exit(1)
with open(sys.argv[1], "rb") as f:
data = f.read()
# DOS header -> e_lfanew @ offset 0x3C points to PE header
pe_off = struct.unpack_from("<I", data, 0x3C)[0]
assert data[pe_off:pe_off + 4] == b"PE\x00\x00", "not a PE file"
# COFF header
machine = struct.unpack_from("<H", data, pe_off + 4)[0]
n_sections = struct.unpack_from("<H", data, pe_off + 6)[0]
timestamp = struct.unpack_from("<I", data, pe_off + 8)[0]
opt_size = struct.unpack_from("<H", data, pe_off + 20)[0]
print(f"machine = 0x{machine:04x}")
print(f"timestamp = 0x{timestamp:08x} ({timestamp})")
import datetime
ts = datetime.datetime.fromtimestamp(timestamp, tz=datetime.timezone.utc)
print(f" -> linker UTC: {ts.isoformat()}")
# Optional header — magic indicates 32 vs 64 bit
opt_off = pe_off + 24
magic = struct.unpack_from("<H", data, opt_off)[0]
is_pe32_plus = (magic == 0x20B)
print(f"opt magic = 0x{magic:04x} ({'PE32+' if is_pe32_plus else 'PE32'})")
# Data directories: PE32 has them at opt_off + 96; PE32+ at opt_off + 112
dd_off = opt_off + (112 if is_pe32_plus else 96)
# Debug directory is data dir [6]
debug_va = struct.unpack_from("<I", data, dd_off + 6 * 8)[0]
debug_size = struct.unpack_from("<I", data, dd_off + 6 * 8 + 4)[0]
print(f"debug dir = VA=0x{debug_va:08x} size={debug_size}")
# We need to map the VA back to a file offset via section headers
sec_off = opt_off + opt_size
sections = []
for i in range(n_sections):
s = sec_off + i * 40
name = data[s:s + 8].rstrip(b"\x00").decode("ascii", errors="replace")
vsize = struct.unpack_from("<I", data, s + 8)[0]
vaddr = struct.unpack_from("<I", data, s + 12)[0]
rsize = struct.unpack_from("<I", data, s + 16)[0]
roff = struct.unpack_from("<I", data, s + 20)[0]
sections.append((name, vaddr, vsize, roff, rsize))
def va_to_file(va):
for (name, vaddr, vsize, roff, rsize) in sections:
if vaddr <= va < vaddr + vsize:
return roff + (va - vaddr)
return None
debug_off = va_to_file(debug_va)
if debug_off is None:
print("debug directory VA does not map into any section")
return
# Each debug directory entry is 28 bytes
n_entries = debug_size // 28
print(f"# debug entries = {n_entries}")
for i in range(n_entries):
e = debug_off + i * 28
characteristics = struct.unpack_from("<I", data, e)[0]
ts_e = struct.unpack_from("<I", data, e + 4)[0]
major = struct.unpack_from("<H", data, e + 8)[0]
minor = struct.unpack_from("<H", data, e + 10)[0]
type_e = struct.unpack_from("<I", data, e + 12)[0]
sz = struct.unpack_from("<I", data, e + 16)[0]
rva = struct.unpack_from("<I", data, e + 20)[0]
ptr = struct.unpack_from("<I", data, e + 24)[0]
type_name = {2: "CODEVIEW", 4: "MISC", 12: "VC_FEATURE", 13: "POGO", 16: "REPRO"}.get(type_e, f"type_{type_e}")
print(f" entry {i}: type={type_name} sz={sz} fileOff=0x{ptr:08x}")
if type_e == 2 and sz >= 24:
cv = data[ptr:ptr + sz]
sig = cv[:4]
print(f" cv signature = {sig!r}")
if sig == b"RSDS":
guid_bytes = cv[4:20]
age = struct.unpack_from("<I", cv, 20)[0]
pdb_name = cv[24:].rstrip(b"\x00").decode("utf-8", errors="replace")
pdb_guid = uuid.UUID(bytes_le=guid_bytes)
print(f" GUID = {{{pdb_guid}}}")
print(f" age = {age}")
print(f" PDB filename = {pdb_name}")
expected_guid = uuid.UUID("9e847e2f-777c-4bd9-886c-22256bb87f32")
expected_age = 1
if pdb_guid == expected_guid and age == expected_age:
print()
print("=== MATCH: this exe pairs with our acclient.pdb ===")
else:
print()
print("=== MISMATCH ===")
print(f" expected GUID = {{{expected_guid}}}")
print(f" expected age = {expected_age}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,104 @@
"""check_orphan_refcounts.py <pid>
For D3DXMesh instances with NO pointers in heap memory, read their
internal refcount (COM-style at +0x?? let's check several offsets).
If refcount > 0, something outside heap (stack/static globals)
references them. If refcount == 0, they're truly leaked.
"""
import ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
VTABLE = 0x007ed3b0
PROCESS_VM_READ=0x10; PROCESS_QUERY_INFORMATION=0x400
MEM_COMMIT=0x1000; MEM_PRIVATE=0x20000
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress',ctypes.c_void_p),('AllocationBase',ctypes.c_void_p),
('AllocationProtect',wt.DWORD),('PartitionId',wt.WORD),('RegionSize',ctypes.c_size_t),
('State',wt.DWORD),('Protect',wt.DWORD),('Type',wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes=[wt.DWORD,wt.BOOL,wt.DWORD]; k.OpenProcess.restype=wt.HANDLE
k.ReadProcessMemory.argtypes=[wt.HANDLE,wt.LPCVOID,wt.LPVOID,ctypes.c_size_t,ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype=wt.BOOL
k.VirtualQueryEx.argtypes=[wt.HANDLE,ctypes.c_void_p,ctypes.POINTER(MBI),ctypes.c_size_t]
k.VirtualQueryEx.restype=ctypes.c_size_t
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
# Pass 1: enumerate all RW regions and find mesh addrs + collect data
rw_regions = []
mbi=MBI(); addr=0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
if mbi.State==MEM_COMMIT and mbi.Type==MEM_PRIVATE and (mbi.Protect&0xff) in (0x04, 0x40):
buf=(ctypes.c_ubyte*mbi.RegionSize)(); sz=ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
rw_regions.append((mbi.BaseAddress, bytes(buf[:sz.value])))
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
if addr>=0x80000000: break
# Find mesh addresses + their data
mesh_data = {} # addr -> first 64 bytes
for base, data in rw_regions:
end = (len(data)//4)*4
for off in range(0, end-0x40, 4):
if struct.unpack_from('<I', data, off)[0] == VTABLE:
mesh_data[base + off] = data[off:off+0x40]
print(f'D3DXMesh instances: {len(mesh_data)}')
# Pass 2: for each mesh address, count pointers in heap memory
mesh_addr_set = set(mesh_data.keys())
ref_counts = {a: 0 for a in mesh_data}
for base, data in rw_regions:
end = (len(data)//4)*4
for off in range(0, end-4, 4):
v = struct.unpack_from('<I', data, off)[0]
if v in mesh_addr_set:
ref_addr = base + off
if ref_addr in mesh_addr_set: continue # self
ref_counts[v] += 1
orphans = [a for a in mesh_data if ref_counts[a] == 0]
held = [a for a in mesh_data if ref_counts[a] > 0]
print(f'orphans (0 heap refs): {len(orphans)}')
print(f'held: {len(held)}')
# For each orphan, dump the first 0x40 bytes and try to find a refcount-looking field
# COM objects typically have a refcount at +0x04 or +0x08
print()
print('=== Orphan mesh refcount candidates (DWORDs at +0x04, +0x08, +0x0c, +0x10, +0x14) ===')
hist_off04 = Counter()
hist_off08 = Counter()
hist_off0c = Counter()
hist_off10 = Counter()
hist_off14 = Counter()
for a in orphans:
d = mesh_data[a]
v04 = struct.unpack_from('<I', d, 0x04)[0]
v08 = struct.unpack_from('<I', d, 0x08)[0]
v0c = struct.unpack_from('<I', d, 0x0c)[0]
v10 = struct.unpack_from('<I', d, 0x10)[0]
v14 = struct.unpack_from('<I', d, 0x14)[0]
hist_off04[min(v04, 100)] += 1
hist_off08[min(v08, 100)] += 1
hist_off0c[min(v0c, 100)] += 1
hist_off10[min(v10, 100)] += 1
hist_off14[min(v14, 100)] += 1
print(f'+0x04 distribution (top 5): {hist_off04.most_common(5)}')
print(f'+0x08 distribution (top 5): {hist_off08.most_common(5)}')
print(f'+0x0c distribution (top 5): {hist_off0c.most_common(5)}')
print(f'+0x10 distribution (top 5): {hist_off10.most_common(5)}')
print(f'+0x14 distribution (top 5): {hist_off14.most_common(5)}')
# Sample 5 orphans — dump full 0x40 bytes
print()
print('=== Sample 5 orphan dumps ===')
for a in orphans[:5]:
print(f' mesh @ 0x{a:08x}:')
d = mesh_data[a]
for i in range(0, 0x40, 16):
row = d[i:i+16]
hex_str = ' '.join(f'{b:02x}' for b in row)
print(f' +0x{i:02x}: {hex_str}')

102
tools/check_patch_state.py Normal file
View file

@ -0,0 +1,102 @@
"""check_patch_state.py [pid1 pid2 ...]
Check which patches are applied to each AC client. If no PIDs given,
scans all acclient processes.
"""
import argparse, ctypes, ctypes.wintypes as wt, subprocess, sys
SITES = {
"v3b-1": (0x0053effe, bytes([0xff, 0x40, 0x24]), bytes([0x90, 0x90, 0x90])),
"v3b-2": (0x0053f19c, bytes([0xff, 0x46, 0x24]), bytes([0x90, 0x90, 0x90])),
"v4": (0x007ca444, bytes([0xa0, 0x54, 0x41, 0x00]), None), # CGfxObj vtable slot 11
"v5-RS": (0x0079a684, bytes([0xa0, 0x54, 0x41, 0x00]), None), # RenderSurface vtable slot 2
"v5-RT": (0x0079c1a0, bytes([0xa0, 0x54, 0x41, 0x00]), None), # RenderTexture vtable slot 2
"v8-1": (0x004e439d, bytes([0x0f, 0x84, 0xf3, 0x01, 0x00, 0x00]), bytes([0x90, 0x90, 0x90, 0x90, 0x90, 0x90])),
"v8-2": (0x004e43c0, bytes([0x0f, 0x85, 0xcd, 0x01, 0x00, 0x00]), bytes([0x90, 0x90, 0x90, 0x90, 0x90, 0x90])),
"v8-3": (0x004e4496, bytes([0x75, 0x0d]), bytes([0x75, 0x08])),
}
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.CloseHandle.argtypes = [wt.HANDLE]; k.CloseHandle.restype = wt.BOOL
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
def read_bytes(h, addr, n):
buf = (ctypes.c_ubyte * n)()
sz = ctypes.c_size_t(0)
if not k.ReadProcessMemory(h, addr, buf, n, ctypes.byref(sz)):
return None
return bytes(buf[:sz.value])
def check_pid(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
return None
result = {}
for name, (addr, orig, patched) in SITES.items():
cur = read_bytes(h, addr, len(orig))
if cur is None:
result[name] = "?"
elif cur == orig:
result[name] = "." # unpatched / original
elif patched is not None and cur == patched:
result[name] = "P"
else:
result[name] = "X" # different (could be runtime-allocated thunk addr for v4/v5)
k.CloseHandle(h)
return result
def get_window_title(pid):
try:
out = subprocess.check_output(
["powershell.exe", "-NoProfile", "-Command",
f"(Get-Process -Id {pid} -ErrorAction SilentlyContinue).MainWindowTitle"],
text=True, stderr=subprocess.DEVNULL).strip()
return out.split("-")[-1].strip() if out else ""
except Exception:
return ""
def list_acclient_pids():
try:
out = subprocess.check_output(
["powershell.exe", "-NoProfile", "-Command",
"(Get-Process acclient -ErrorAction SilentlyContinue).Id"],
text=True, stderr=subprocess.DEVNULL).strip()
return sorted(int(line) for line in out.splitlines() if line.strip())
except Exception:
return []
ap = argparse.ArgumentParser()
ap.add_argument("pids", type=int, nargs="*")
args = ap.parse_args()
pids = args.pids if args.pids else list_acclient_pids()
cols = list(SITES.keys())
print(f"{'pid':>6} {' '.join(c[:6] for c in cols)} character")
print(f"{'---':>6} {' '.join('------' for c in cols)} -----------")
for pid in pids:
r = check_pid(pid)
if r is None:
print(f"{pid:>6} <dead>")
continue
name = get_window_title(pid)
row = " ".join(f"{r[c]:>6}" for c in cols)
print(f"{pid:>6} {row} {name}")
print()
print("Legend: . = unpatched/original P = patched (NOP/byte change)")
print(" X = different (runtime-allocated thunk, e.g. v4/v5 -> custom code page)")

View file

@ -0,0 +1,169 @@
"""classify_0x0079385c_hits.py <pid>
For each occurrence of the DWORD 0x0079385c in private RW memory of <pid>:
- Record the absolute address of the hit
- Walk BACKWARDS 16-byte aligned, looking for a plausible vtable pointer
in the 0x00400000-0x00900000 (acclient .rdata) range with a small
nonzero offset distance (<= 0x400 bytes). That's the object's start
and offset-of-the-marker.
- Group hits by:
* offset-of-marker (e.g. +0x30, +0x54 confirms CObjCell)
* the head vtable found (what class the object actually is)
- Then independently bucket by REGION size of the containing
VirtualAlloc region (informational, not allocation size).
Output:
* Total hits
* Offset histogram (top 10)
* Head-vtable histogram (top 10) includes vtable address + count
* Sample hex dumps (first 64 bytes) for top 3 head-vtable groups
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
from collections import Counter, defaultdict
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
TARGET = 0x0079385c
# acclient.exe is loaded around 0x00400000 with .rdata vtables typically
# in the 0x00700000-0x00880000 range. Accept slightly wider for safety.
VTABLE_LO = 0x00400000
VTABLE_HI = 0x00900000
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
# Pass 1: enumerate all readable private RW regions; remember snapshots in memory
# so we can back-scan WITHOUT extra remote reads.
regions = [] # list of (base, data:bytes, region_size)
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
and pr in (0x04, 0x40)):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
regions.append((int(mbi.BaseAddress), bytes(buf[:sz.value]), int(mbi.RegionSize)))
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
total_hits = 0
offset_hist = Counter() # offset from inferred object head
head_vt_hist = Counter() # vtable found at inferred object head
region_size_hist = Counter()
head_vt_samples = defaultdict(list) # vt -> list of (addr, first 64 bytes)
# Bucket region sizes for histogram
def bucket(sz):
if sz < 1024: return "<1KB"
if sz < 4*1024: return "1-4KB"
if sz < 64*1024: return "4-64KB"
if sz < 256*1024: return "64-256KB"
if sz < 512*1024: return "256-512KB"
if sz < 1024*1024: return "512KB-1MB"
return ">=1MB"
# For each hit, search backward for a vtable head.
# Walk back up to 0x400 bytes (CObjCell-class size guess), aligned to 4.
MAX_BACKSCAN = 0x400
for base, data, rsize in regions:
end = (len(data) // 4) * 4
# Find all DWORD positions equal to TARGET
# struct.iter_unpack is fast enough
pos = 0
target_bytes = struct.pack("<I", TARGET)
while True:
idx = data.find(target_bytes, pos)
if idx < 0: break
if idx % 4 != 0:
pos = idx + 1
continue
total_hits += 1
region_size_hist[bucket(rsize)] += 1
hit_addr = base + idx
# Back-scan: try each 4-byte aligned offset 0, -4, -8, ... up to MAX_BACKSCAN
found_vt = None
found_off = None
for back in range(0, MAX_BACKSCAN + 4, 4):
probe = idx - back
if probe < 0: break
v = struct.unpack_from("<I", data, probe)[0]
if VTABLE_LO <= v < VTABLE_HI and v != TARGET:
# Heuristic: this is likely the head vtable.
# The first plausible one we find (smallest back-distance) wins.
found_vt = v
found_off = back
break
if found_vt is not None:
offset_hist[found_off] += 1
head_vt_hist[found_vt] += 1
if len(head_vt_samples[found_vt]) < 3:
head_addr = hit_addr - found_off
head_idx = idx - found_off
snippet = data[head_idx:head_idx + 64]
head_vt_samples[found_vt].append((head_addr, snippet))
else:
offset_hist[-1] += 1 # marker for "no head found"
pos = idx + 4
print(f"PID={args.pid} scanned {len(regions)} private RW regions")
print(f"Total 0x{TARGET:08x} hits: {total_hits}\n")
print("=== Region-size histogram (where the hit lives) ===")
for b, c in region_size_hist.most_common():
print(f" {b:>10} {c:>7}")
print()
print("=== Offset of marker from inferred object head (top 15) ===")
for off, c in offset_hist.most_common(15):
if off == -1:
print(f" (no head found) {c:>7}")
else:
print(f" +0x{off:04x} {c:>7}")
print()
print("=== Head vtable histogram (top 15) ===")
for vt, c in head_vt_hist.most_common(15):
print(f" 0x{vt:08x} {c:>7}")
print()
print("=== Sample first-64-byte dumps for top 3 head vtables ===")
for vt, _c in head_vt_hist.most_common(3):
print(f"\n--- head vtable 0x{vt:08x} ---")
for ha, snip in head_vt_samples[vt]:
hexs = " ".join(f"{b:02x}" for b in snip)
print(f" at 0x{ha:08x}: {hexs}")

View file

@ -0,0 +1,163 @@
"""classify_0x0079385c_v2.py <pid>
V2 of the classifier. Two new approaches:
1. Test the "CObjCell shell at +0x30 and +0x54" hypothesis: for each hit,
check if there's ALSO a 0x0079385c marker exactly 0x24 (36) bytes away
(i.e. the OTHER offset of the same CObjCell). If yes likely a real
CObjCell shell pair. Count those.
2. Check immediate-neighborhood context. A "real leaked object" looks like:
- Object head at some 8/16-byte-aligned address
- First DWORD is a vtable pointer in .rdata range
- Most of object is zeros or sensible field values
A "compiler-baked constant" looks like:
- Surrounded by code/anim data, not separable as an object
- May appear right after a function pointer (in a vtable construction)
or in a const-data array
Approach: for each hit, look at the 16 bytes BEFORE the hit. If the
preceding DWORDs contain ANY value in the executable-range
0x00400000-0x00700000 (which would be CODE pointer), this is likely an
embedded constant in compiled data, not a runtime object field.
Real heap object fields would not have code-range pointers RIGHT
before the marker.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
TARGET = 0x0079385c
# Code is in .text typically 0x00401000 - 0x006xxxxx
CODE_LO = 0x00401000
CODE_HI = 0x006d0000
# Read-only data (.rdata) typically follows .text
RDATA_LO = 0x006d0000
RDATA_HI = 0x008c0000
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
regions = []
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
and pr in (0x04, 0x40)):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
regions.append((int(mbi.BaseAddress), bytes(buf[:sz.value]), int(mbi.RegionSize)))
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
target_bytes = struct.pack("<I", TARGET)
# Stats
total_hits = 0
n_pair_at_24 = 0 # has a paired 0x0079385c at +/- 0x24 (CObjCell layout hint)
n_solo = 0 # alone
prev_dword_kind = Counter() # what's the DWORD immediately before the hit
prev16_has_code = 0 # has a code-range pointer in the prior 16 bytes (suggests baked-in)
prev16_all_zero = 0 # surrounded by zeros (suggests cleared-but-not-freed object field)
# Per-region density: hits-per-region
region_hit_counter = []
for base, data, rsize in regions:
hits_here = []
pos = 0
while True:
idx = data.find(target_bytes, pos)
if idx < 0: break
if idx % 4 != 0:
pos = idx + 1
continue
hits_here.append(idx)
pos = idx + 4
if not hits_here:
continue
region_hit_counter.append((base, rsize, len(hits_here)))
hits_set = set(hits_here)
for idx in hits_here:
total_hits += 1
# CObjCell pair check
if (idx + 0x24) in hits_set or (idx - 0x24) in hits_set:
n_pair_at_24 += 1
else:
n_solo += 1
# Immediate prior DWORD class
if idx >= 4:
prev = struct.unpack_from("<I", data, idx - 4)[0]
if prev == 0:
prev_dword_kind["zero"] += 1
elif CODE_LO <= prev < CODE_HI:
prev_dword_kind["code_ptr"] += 1
elif RDATA_LO <= prev < RDATA_HI:
prev_dword_kind["rdata_ptr"] += 1
elif 0x01000000 <= prev < 0x80000000:
prev_dword_kind["heap_ptr"] += 1
elif prev == TARGET:
prev_dword_kind["self_marker"] += 1
else:
prev_dword_kind["scalar/other"] += 1
# 16-byte window before
if idx >= 16:
window = data[idx-16:idx]
wd = struct.unpack("<IIII", window)
if all(d == 0 for d in wd):
prev16_all_zero += 1
if any(CODE_LO <= d < CODE_HI for d in wd):
prev16_has_code += 1
print(f"Total 0x{TARGET:08x} hits: {total_hits}")
print(f" Paired at +/-0x24 (CObjCell layout candidate): {n_pair_at_24}")
print(f" Solo: {n_solo}")
print()
print("Immediate prior DWORD classification:")
for k_, v in prev_dword_kind.most_common():
print(f" {k_:<15} {v:>7}")
print()
print(f"Prior 16 bytes all zero (likely object field): {prev16_all_zero}")
print(f"Prior 16 bytes has code-ptr (likely baked data): {prev16_has_code}")
print()
print("=== Top 15 regions by hit count ===")
region_hit_counter.sort(key=lambda x: -x[2])
for base, rsize, c in region_hit_counter[:15]:
print(f" base=0x{base:08x} size={rsize/1024:>8.1f}KB hits={c:>5} hits/KB={c*1024/rsize:.2f}")

192
tools/clone_dump.py Normal file
View file

@ -0,0 +1,192 @@
"""clone_dump.py <pid> <out.dmp>
Take a non-disruptive full memory dump of a live process using
process reflection (PssCaptureSnapshot) the same mechanism procdump
uses with -r 1 -ma. The target is only paused for ~1ms while the
COW snapshot is created; the dump itself runs against the snapshot,
not the live process.
This avoids the multi-second pause that MiniDumpWriteDump on a live
PID would cause (and which disconnects AC clients from Coldeve).
"""
import argparse
import ctypes
import ctypes.wintypes as wt
import sys
import os
# PSS flags
PSS_CAPTURE_VA_CLONE = 0x00000001
PSS_CAPTURE_HANDLES = 0x00000004
PSS_CAPTURE_HANDLE_NAME_INFORMATION = 0x00000008
PSS_CAPTURE_HANDLE_BASIC_INFORMATION= 0x00000010
PSS_CAPTURE_HANDLE_TYPE_SPECIFIC_INFORMATION = 0x00000020
PSS_CAPTURE_HANDLE_TRACE = 0x00000040
PSS_CAPTURE_THREADS = 0x00000080
PSS_CAPTURE_THREAD_CONTEXT = 0x00000100
PSS_CAPTURE_THREAD_CONTEXT_EXTENDED = 0x00000200
PSS_CAPTURE_VA_SPACE = 0x00000800
PSS_CAPTURE_VA_SPACE_SECTION_INFORMATION = 0x00001000
PSS_CREATE_RELEASE_SECTION = 0x80000000
PSS_CREATE_FORCE_BREAKAWAY = 0x40000000
PSS_CREATE_USE_VM_ALLOCATIONS = 0x20000000
# Process access
PROCESS_ALL_ACCESS = 0x1F0FFF
# MiniDumpType
MINI_DUMP_WITH_FULL_MEMORY = 0x00000002
MINI_DUMP_WITH_HANDLE_DATA = 0x00000004
MINI_DUMP_WITH_UNLOADED_MODULES = 0x00000020
MINI_DUMP_WITH_FULL_MEMORY_INFO = 0x00000800
MINI_DUMP_WITH_THREAD_INFO = 0x00001000
MINI_DUMP_WITH_TOKEN_INFORMATION = 0x00040000
DUMP_TYPE = (
MINI_DUMP_WITH_FULL_MEMORY
| MINI_DUMP_WITH_HANDLE_DATA
| MINI_DUMP_WITH_UNLOADED_MODULES
| MINI_DUMP_WITH_FULL_MEMORY_INFO
| MINI_DUMP_WITH_THREAD_INFO
)
k32 = ctypes.WinDLL('kernel32', use_last_error=True)
dbghelp = ctypes.WinDLL('dbghelp', use_last_error=True)
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]
CloseHandle.restype = wt.BOOL
PssCaptureSnapshot = k32.PssCaptureSnapshot
PssCaptureSnapshot.argtypes = [wt.HANDLE, wt.DWORD, wt.DWORD, ctypes.POINTER(wt.HANDLE)]
PssCaptureSnapshot.restype = wt.DWORD
PssFreeSnapshot = k32.PssFreeSnapshot
PssFreeSnapshot.argtypes = [wt.HANDLE, wt.HANDLE]
PssFreeSnapshot.restype = wt.DWORD
CreateFileW = k32.CreateFileW
CreateFileW.argtypes = [wt.LPCWSTR, wt.DWORD, wt.DWORD, ctypes.c_void_p,
wt.DWORD, wt.DWORD, wt.HANDLE]
CreateFileW.restype = wt.HANDLE
MiniDumpWriteDump = dbghelp.MiniDumpWriteDump
MiniDumpWriteDump.argtypes = [wt.HANDLE, wt.DWORD, wt.HANDLE,
wt.DWORD, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p]
MiniDumpWriteDump.restype = wt.BOOL
# MINIDUMP_CALLBACK_INFORMATION + IsProcessSnapshotCallback support
# to tell dbghelp the hProcess is actually a snapshot handle.
class MINIDUMP_CALLBACK_INFORMATION(ctypes.Structure):
_fields_ = [
("CallbackRoutine", ctypes.c_void_p),
("CallbackParam", ctypes.c_void_p),
]
# Callback type: BOOL CALLBACK MiniDumpCallback(PVOID CallbackParam, const PMINIDUMP_CALLBACK_INPUT, PMINIDUMP_CALLBACK_OUTPUT)
MiniDumpCallback_T = ctypes.WINFUNCTYPE(wt.BOOL, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p)
IS_PROCESS_SNAPSHOT_CALLBACK = 16
def _callback(callback_param, input_ptr, output_ptr):
# CallbackType is at offset 4 in MINIDUMP_CALLBACK_INPUT (after ProcessId DWORD)
if not input_ptr:
return True
cb_type = ctypes.cast(input_ptr + 4, ctypes.POINTER(wt.ULONG))[0]
if cb_type == IS_PROCESS_SNAPSHOT_CALLBACK:
# Set ULONG at start of MINIDUMP_CALLBACK_OUTPUT to MiniDumpValidCallback (1)
if output_ptr:
ctypes.cast(output_ptr, ctypes.POINTER(wt.ULONG))[0] = 1
return True
_callback_inst = MiniDumpCallback_T(_callback)
GetCurrentProcess = k32.GetCurrentProcess
GetCurrentProcess.argtypes = []
GetCurrentProcess.restype = wt.HANDLE
GENERIC_WRITE = 0x40000000
CREATE_ALWAYS = 2
FILE_ATTRIBUTE_NORMAL = 0x80
INVALID_HANDLE_VALUE = wt.HANDLE(-1).value
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
ap.add_argument("out", help="output dump path (.dmp)")
args = ap.parse_args()
out_path = os.path.abspath(args.out)
h_proc = OpenProcess(PROCESS_ALL_ACCESS, False, args.pid)
if not h_proc:
err = ctypes.get_last_error()
print(f"OpenProcess({args.pid}) failed err={err}")
sys.exit(2)
print(f"PID {args.pid}: opened, capturing COW snapshot...")
capture_flags = (
PSS_CAPTURE_VA_CLONE
| PSS_CAPTURE_HANDLES
| PSS_CAPTURE_HANDLE_NAME_INFORMATION
| PSS_CAPTURE_HANDLE_BASIC_INFORMATION
| PSS_CAPTURE_HANDLE_TYPE_SPECIFIC_INFORMATION
| PSS_CAPTURE_HANDLE_TRACE
| PSS_CAPTURE_THREADS
| PSS_CAPTURE_THREAD_CONTEXT
| PSS_CAPTURE_THREAD_CONTEXT_EXTENDED
| PSS_CAPTURE_VA_SPACE
| PSS_CAPTURE_VA_SPACE_SECTION_INFORMATION
)
h_snap = wt.HANDLE()
# ContextFlags param: 0x0010001F = CONTEXT_FULL | CONTEXT_i386
rc = PssCaptureSnapshot(h_proc, capture_flags, 0x0010001F, ctypes.byref(h_snap))
if rc != 0:
print(f"PssCaptureSnapshot failed rc={rc}")
CloseHandle(h_proc)
sys.exit(3)
print(f" snapshot handle 0x{h_snap.value:x}")
h_file = CreateFileW(out_path, GENERIC_WRITE, 0, None,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, None)
if h_file == INVALID_HANDLE_VALUE or h_file is None:
err = ctypes.get_last_error()
print(f"CreateFile {out_path} failed err={err}")
PssFreeSnapshot(GetCurrentProcess(), h_snap)
CloseHandle(h_proc)
sys.exit(4)
print(f" writing dump to {out_path}...")
cb_info = MINIDUMP_CALLBACK_INFORMATION()
cb_info.CallbackRoutine = ctypes.cast(_callback_inst, ctypes.c_void_p).value
cb_info.CallbackParam = None
ok = MiniDumpWriteDump(h_snap, args.pid, h_file, DUMP_TYPE, None, None, ctypes.byref(cb_info))
if not ok:
err = ctypes.get_last_error()
print(f"MiniDumpWriteDump failed err={err}")
CloseHandle(h_file)
PssFreeSnapshot(GetCurrentProcess(), h_snap)
CloseHandle(h_proc)
sys.exit(5)
CloseHandle(h_file)
PssFreeSnapshot(GetCurrentProcess(), h_snap)
CloseHandle(h_proc)
sz = os.path.getsize(out_path)
print(f" OK: {sz/1e6:.1f} MB written")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,102 @@
"""compare_mesh_templates.py <pid>
Compare the byte signature (+0x04 to +0x18) of orphan vs held meshes
to figure out what +0x04 means and whether orphans share a template.
"""
import ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
VTABLE = 0x007ed3b0
PROCESS_VM_READ=0x10; PROCESS_QUERY_INFORMATION=0x400
MEM_COMMIT=0x1000; MEM_PRIVATE=0x20000
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress',ctypes.c_void_p),('AllocationBase',ctypes.c_void_p),
('AllocationProtect',wt.DWORD),('PartitionId',wt.WORD),('RegionSize',ctypes.c_size_t),
('State',wt.DWORD),('Protect',wt.DWORD),('Type',wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes=[wt.DWORD,wt.BOOL,wt.DWORD]; k.OpenProcess.restype=wt.HANDLE
k.ReadProcessMemory.argtypes=[wt.HANDLE,wt.LPCVOID,wt.LPVOID,ctypes.c_size_t,ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype=wt.BOOL
k.VirtualQueryEx.argtypes=[wt.HANDLE,ctypes.c_void_p,ctypes.POINTER(MBI),ctypes.c_size_t]
k.VirtualQueryEx.restype=ctypes.c_size_t
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ|PROCESS_QUERY_INFORMATION, False, pid)
rw_regions = []
mbi=MBI(); addr=0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
if mbi.State==MEM_COMMIT and mbi.Type==MEM_PRIVATE and (mbi.Protect&0xff) in (0x04, 0x40):
buf=(ctypes.c_ubyte*mbi.RegionSize)(); sz=ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
rw_regions.append((mbi.BaseAddress, bytes(buf[:sz.value])))
addr=(mbi.BaseAddress or 0)+mbi.RegionSize
if addr>=0x80000000: break
mesh_data = {}
for base, data in rw_regions:
end = (len(data)//4)*4
for off in range(0, end-0x40, 4):
if struct.unpack_from('<I', data, off)[0] == VTABLE:
mesh_data[base + off] = data[off:off+0x40]
mesh_addrs = set(mesh_data.keys())
ref_counts = {a: 0 for a in mesh_data}
for base, data in rw_regions:
end = (len(data)//4)*4
for off in range(0, end-4, 4):
v = struct.unpack_from('<I', data, off)[0]
if v in mesh_addrs:
ra = base + off
if ra in mesh_addrs: continue
ref_counts[v] += 1
orphans = [a for a in mesh_data if ref_counts[a] == 0]
held = [a for a in mesh_data if ref_counts[a] > 0]
print(f'orphans: {len(orphans)} held: {len(held)}')
# Histogram +0x04 for orphans vs held
def hist_off(addrs, offset, top_n=10):
c = Counter()
for a in addrs:
v = struct.unpack_from('<I', mesh_data[a], offset)[0]
c[v] += 1
return c.most_common(top_n)
for off in [0x04, 0x08, 0x0c, 0x10, 0x14, 0x18, 0x1c, 0x20]:
o = hist_off(orphans, off, 5)
h_ = hist_off(held, off, 5)
print(f'+0x{off:02x} orphans top5: {[(hex(v), n) for v,n in o]}')
print(f'+0x{off:02x} held top5: {[(hex(v), n) for v,n in h_]}')
# Show: do MULTIPLE distinct held meshes share the same +0x04 = 0x252?
held_with_252 = [a for a in held if struct.unpack_from('<I', mesh_data[a], 0x04)[0] == 0x252]
orphans_with_252 = [a for a in orphans if struct.unpack_from('<I', mesh_data[a], 0x04)[0] == 0x252]
print(f'+0x04=0x252: orphans={len(orphans_with_252)} held={len(held_with_252)}')
# Now: among orphans, find any "interior" field that differentiates (sample several orphans, look for non-identical bytes)
print()
print('=== Are all orphans byte-identical for first 0x40? ===')
if orphans:
first = mesh_data[orphans[0]]
distinct = sum(1 for a in orphans if mesh_data[a] != first)
print(f'orphans with bytes != first: {distinct} / {len(orphans)}')
# Find positions where bytes differ
if distinct > 0:
for i in range(0x40):
byte_set = set(mesh_data[a][i] for a in orphans)
if len(byte_set) > 1:
print(f' +0x{i:02x}: varies — sample values: {sorted(byte_set)[:5]}')
# Pick an orphan and read its +0x2c field — that's the buffer pointer per earlier analysis
print()
print('=== Buffer pointer (+0x2c) of orphans ===')
buf_addrs = []
for a in orphans:
bp = struct.unpack_from('<I', mesh_data[a], 0x2c)[0]
buf_addrs.append(bp)
unique_bufs = len(set(buf_addrs))
print(f'orphans: {len(orphans)}, unique +0x2c values: {unique_bufs}')
print(f'sample: {[hex(x) for x in buf_addrs[:5]]}')

View file

@ -0,0 +1,95 @@
"""count_gr_subclasses_live.py <pid>
Count live instances of each GraphicsResource subclass in a running
process by scanning RW heap for vtable pointers. Used to measure
whether v5 PurgeResource patch actually drains the leaked instances
over time.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
VTABLES = {
"RenderSurface": 0x0079a67c,
"RenderTexture": 0x0079c198,
"CSurface": 0x007ca4dc,
"ImgTex": 0x007cab04,
"RenderVertexBufferD3D": 0x007e6520,
"RenderTextureD3D": 0x00801a18,
"RenderSurfaceD3D": 0x00801a94,
"RenderIndexStreamD3D": 0x00801b64,
}
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]
k.OpenProcess.restype = wt.HANDLE
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
def main():
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}")
sys.exit(2)
counts = {name: 0 for name in VTABLES}
vt_set = {vt: name for name, vt in VTABLES.items()}
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
and pr in (0x04, 0x40)): # RW or RWX
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
for off in range(0, end, 4):
v = struct.unpack_from("<I", data, off)[0]
if v in vt_set:
counts[vt_set[v]] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
ts = time.strftime("%H:%M:%S")
print(f"PID {args.pid} @ {ts}")
print(f"{'class':<25} {'vtable':<12} {'count':>6}")
for name, vt in VTABLES.items():
marker = " <- LEAKING (v5 patches this)" if name in ("RenderSurface", "RenderTexture") else ""
print(f"{name:<25} 0x{vt:08x} {counts[name]:>6}{marker}")
total = sum(counts.values())
leakers = counts["RenderSurface"] + counts["RenderTexture"]
print(f"{'total':<25} {'':<12} {total:>6}")
print(f"{'leakers (RS+RT)':<25} {'':<12} {leakers:>6}")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,93 @@
"""count_leak_classes.py <pid>
Count live instances of all known leak-candidate classes for diagnostic
delta analysis. Emit timestamped, machine-parseable output.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
VTABLES = {
# GraphicsResource family (texture/surface cache)
"GraphicsResource": 0x0079bf64,
"RenderSurface": 0x0079a67c,
"RenderTexture": 0x0079c198,
"CSurface": 0x007ca4dc,
"ImgTex": 0x007cab04,
"RenderTextureD3D": 0x00801a18,
"RenderSurfaceD3D": 0x00801a94,
# UI
"UIElement_UIItem": 0x007c0498,
"NoticeHandler_subvt": 0x007ccb60,
# CObjCell family
"CObjCell_primary": 0x007c98e8,
"CObjCell_subvt": 0x0079385c,
"CEnvCell_primary": 0x007c9a60,
# Physics
"CPhysicsObj": 0x007c78ec,
# Already-patched (reference)
"Palette": 0x007caa08,
"CGfxObj": 0x007ca418,
"D3DXMesh": 0x007ed3b0,
}
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
counts = {name: 0 for name in VTABLES}
vt_to_name = {vt: name for name, vt in VTABLES.items()}
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
and pr in (0x04, 0x40)):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
for off in range(0, end, 4):
v = struct.unpack_from("<I", data, off)[0]
if v in vt_to_name:
counts[vt_to_name[v]] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
ts = time.strftime("%H:%M:%S")
print(f"PID={args.pid} @ {ts}")
for name, vt in VTABLES.items():
print(f" {name:<22} 0x{vt:08x} {counts[name]:>6}")

76
tools/count_one_pid.py Normal file
View file

@ -0,0 +1,76 @@
"""count_one_pid.py <pid>
Quick scan of a single PID for all leak-class vtable counts.
"""
import ctypes, ctypes.wintypes as wt, struct, sys
VTABLES = {
"uiitem": 0x007c0498,
"palette": 0x007caa08,
"cphysicsobj": 0x007c78ec,
"renderSurf": 0x0079a67c,
"renderSurfD3D": 0x00801a94,
"renderTexD3D": 0x00801a18,
"csurface": 0x007ca4dc,
"imgtex": 0x007cab04,
"cgfxobj": 0x007ca418,
"d3dxmesh": 0x007ed3b0,
}
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.CloseHandle.argtypes = [wt.HANDLE]; k.CloseHandle.restype = wt.BOOL
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
pid = int(sys.argv[1])
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess({pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
counts = {n: 0 for n in VTABLES}
vt_to_name = {vt: name for name, vt in VTABLES.items()}
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE and pr in (0x04, 0x40)):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
for off in range(0, end, 4):
v = struct.unpack_from("<I", data, off)[0]
if v in vt_to_name:
counts[vt_to_name[v]] += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000: break
print(f"PID {pid}")
for n in VTABLES:
print(f" {n:14s} = {counts[n]:6d}")
k.CloseHandle(h)

View file

@ -0,0 +1,86 @@
"""count_palettes_live.py <pid>
Count Palette instances (vtable 0x007caa08) in a live process and
break down by refcount.
"""
import ctypes, ctypes.wintypes as wt, struct, sys
from collections import Counter
VTABLE = 0x007caa08
PROCESS_VM_READ = 0x0010
PROCESS_QUERY_INFORMATION = 0x0400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
class MEMORY_BASIC_INFORMATION(ctypes.Structure):
_fields_ = [
("BaseAddress", ctypes.c_void_p),
("AllocationBase", ctypes.c_void_p),
("AllocationProtect", wt.DWORD),
("PartitionId", wt.WORD),
("RegionSize", ctypes.c_size_t),
("State", wt.DWORD),
("Protect", wt.DWORD),
("Type", wt.DWORD),
]
k32 = ctypes.windll.kernel32
OpenProcess = k32.OpenProcess
OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; OpenProcess.restype = wt.HANDLE
CloseHandle = k32.CloseHandle
CloseHandle.argtypes = [wt.HANDLE]; CloseHandle.restype = wt.BOOL
ReadProcessMemory = k32.ReadProcessMemory
ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
ReadProcessMemory.restype = wt.BOOL
VirtualQueryEx = k32.VirtualQueryEx
VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p,
ctypes.POINTER(MEMORY_BASIC_INFORMATION), ctypes.c_size_t]
VirtualQueryEx.restype = ctypes.c_size_t
pid = int(sys.argv[1])
h = OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
print(f"OpenProcess({pid}) err={ctypes.get_last_error()}"); sys.exit(2)
mbi = MEMORY_BASIC_INFORMATION()
addr = 0
n_total = 0
rc_hist = Counter()
maintainer_hist = Counter()
m_numlinks_hist = Counter()
while VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
base = mbi.BaseAddress or 0
size = mbi.RegionSize
st = mbi.State
ty = mbi.Type
pr = mbi.Protect & 0xFF
if st == MEM_COMMIT and ty == MEM_PRIVATE and pr in (0x04, 0x40):
buf = (ctypes.c_ubyte * size)()
sz = ctypes.c_size_t(0)
if ReadProcessMemory(h, base, buf, size, ctypes.byref(sz)) and sz.value > 0x48:
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
for off in range(0, end - 0x48, 4):
if struct.unpack_from("<I", data, off)[0] == VTABLE:
rc = struct.unpack_from("<I", data, off + 0x24)[0]
main = struct.unpack_from("<I", data, off + 0x20)[0]
numl = struct.unpack_from("<I", data, off + 0x04)[0]
rc_hist[rc if rc < 100 else 100] += 1
maintainer_hist[1 if main else 0] += 1
m_numlinks_hist[numl & 0xff] += 1
n_total += 1
addr = base + size
if addr >= 0x80000000: break
print(f"PID {pid}: {n_total} Palette instances")
print("refcount distribution (top 8):")
for rc, n in rc_hist.most_common(8):
print(f" rc={rc:<4} {n}")
print(f"m_pMaintainer NULL: {maintainer_hist[0]}, non-NULL: {maintainer_hist[1]}")
print(f"m_numLinks distribution (top 6):")
for ml, n in m_numlinks_hist.most_common(6):
print(f" ml={ml:<4} {n}")
CloseHandle(h)

View file

@ -0,0 +1,100 @@
"""Count CPhysicsObj vs CPhysicsPart vs Position in given PIDs."""
import ctypes, ctypes.wintypes as wt, struct, sys, subprocess
from collections import Counter
POSITION_VT = 0x00797910
CPHYSICSOBJ_VT = 0x007c78e0 # EoR CPhysicsObj vtable
# CPhysicsPart doesn't have its own vtable (no virtual methods); we
# detect via its embedded Position pair at offsets 48 + 120.
# The signature is: two Position vts 72 bytes apart, with 0x3f800000 at -4.
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.CloseHandle.argtypes = [wt.HANDLE]; k.CloseHandle.restype = wt.BOOL
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
def scan_pid(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h:
return None
positions = 0
physobjs = 0
parts_two_pos = 0 # length-2 Position arrays = CPhysicsPart signature
parts_with_scale_1 = 0 # with 0x3f800000 in scale-z slot
mbi = MBI(); addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if mbi.State == 0x1000 and mbi.Type == 0x20000 and pr in (0x04, 0x40):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
pos_offs = set()
for off in range(0, end, 4):
v = struct.unpack_from("<I", data, off)[0]
if v == POSITION_VT:
positions += 1
pos_offs.add(off)
elif v == CPHYSICSOBJ_VT:
physobjs += 1
# CPhysicsPart: a position at offset N AND another at N+72 AND off>=48
for off in pos_offs:
if (off + 72) in pos_offs and (off - 72) not in pos_offs:
# this is the array head — is this CPhysicsPart?
# check -4 byte = scale-z float
if off >= 4:
m4 = struct.unpack_from("<I", data, off - 4)[0]
parts_two_pos += 1
if m4 == 0x3f800000: # 1.0f
parts_with_scale_1 += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
k.CloseHandle(h)
return positions, physobjs, parts_two_pos, parts_with_scale_1
# Gather PIDs + titles
out = subprocess.check_output(
["powershell.exe", "-NoProfile", "-Command",
"Get-Process acclient -EA SilentlyContinue | "
"ForEach-Object { \"$($_.Id)|$($_.MainWindowTitle)\" }"],
text=True).strip()
pids_with_titles = []
for ln in out.splitlines():
if "|" not in ln: continue
a,b = ln.split("|",1)
try:
pids_with_titles.append((int(a), b))
except ValueError: pass
if len(sys.argv) > 1:
want = set(int(p) for p in sys.argv[1:])
pids_with_titles = [(p,t) for (p,t) in pids_with_titles if p in want]
print(f"{'pid':>6} {'positions':>10} {'physobjs':>9} {'parts(2pos)':>12} {'parts(scale1)':>14} title")
for pid, title in pids_with_titles:
res = scan_pid(pid)
if res is None:
print(f"{pid:>6} NOACCESS")
else:
positions, physobjs, parts, parts_s1 = res
print(f"{pid:>6} {positions:>10} {physobjs:>9} {parts:>12} {parts_s1:>14} {title}")

View file

@ -0,0 +1,80 @@
"""count_position_live.py [pid...]
Count Position-class instances in live acclient processes.
Position vtable = 0x00797910 (EoR).
"""
import ctypes, ctypes.wintypes as wt, struct, sys, subprocess
POSITION_VT = 0x00797910
if len(sys.argv) > 1:
pids = [int(p) for p in sys.argv[1:]]
else:
out = subprocess.check_output(
["powershell.exe", "-NoProfile", "-Command",
"Get-Process acclient -EA SilentlyContinue | "
"ForEach-Object { \"$($_.Id)|$($_.MainWindowTitle)\" }"],
text=True).strip()
pids = []
titles = {}
for ln in out.splitlines():
if "|" not in ln: continue
a, b = ln.split("|", 1)
try:
pid = int(a)
pids.append(pid)
titles[pid] = b
except ValueError:
pass
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.CloseHandle.argtypes = [wt.HANDLE]; k.CloseHandle.restype = wt.BOOL
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
def count_pid(pid):
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, pid)
if not h: return None
cnt = 0
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if mbi.State == 0x1000 and mbi.Type == 0x20000 and pr in (0x04, 0x40):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
for off in range(0, end, 4):
if struct.unpack_from("<I", data, off)[0] == POSITION_VT:
cnt += 1
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000: break
k.CloseHandle(h)
return cnt
print(f"{'pid':>6} {'positions':>10} title")
for pid in sorted(pids):
n = count_pid(pid)
if n is None:
print(f"{pid:>6} NOACCESS")
else:
try:
print(f"{pid:>6} {n:>10} {titles.get(pid, '')}")
except NameError:
print(f"{pid:>6} {n:>10}")

View file

@ -0,0 +1,83 @@
"""count_uiitem_live.py <pid>
Count UIElement_UIItem instances in a live process by scanning RW heap
for the primary vtable pointer 0x007c0498.
"""
import argparse, ctypes, ctypes.wintypes as wt, struct, sys, time
UIITEM_VTABLE = 0x007c0498
PROCESS_VM_READ = 0x10
PROCESS_QUERY_INFORMATION = 0x400
MEM_COMMIT = 0x1000
MEM_PRIVATE = 0x20000
class MBI(ctypes.Structure):
_fields_ = [('BaseAddress', ctypes.c_void_p),
('AllocationBase', ctypes.c_void_p),
('AllocationProtect', wt.DWORD),
('PartitionId', wt.WORD),
('RegionSize', ctypes.c_size_t),
('State', wt.DWORD),
('Protect', wt.DWORD),
('Type', wt.DWORD)]
k = ctypes.windll.kernel32
k.OpenProcess.argtypes = [wt.DWORD, wt.BOOL, wt.DWORD]; k.OpenProcess.restype = wt.HANDLE
k.ReadProcessMemory.argtypes = [wt.HANDLE, wt.LPCVOID, wt.LPVOID, ctypes.c_size_t,
ctypes.POINTER(ctypes.c_size_t)]
k.ReadProcessMemory.restype = wt.BOOL
k.VirtualQueryEx.argtypes = [wt.HANDLE, ctypes.c_void_p, ctypes.POINTER(MBI), ctypes.c_size_t]
k.VirtualQueryEx.restype = ctypes.c_size_t
ap = argparse.ArgumentParser()
ap.add_argument("pid", type=int)
args = ap.parse_args()
h = k.OpenProcess(PROCESS_VM_READ | PROCESS_QUERY_INFORMATION, False, args.pid)
if not h:
print(f"OpenProcess({args.pid}) failed err={ctypes.get_last_error()}"); sys.exit(2)
# Count UIITEM_VTABLE pointers AND count cleared cells (item-GUID == 0 at +0x5fc)
all_instances = []
mbi = MBI()
addr = 0
while k.VirtualQueryEx(h, addr, ctypes.byref(mbi), ctypes.sizeof(mbi)):
pr = mbi.Protect & 0xff
if (mbi.State == MEM_COMMIT and mbi.Type == MEM_PRIVATE
and pr in (0x04, 0x40)):
buf = (ctypes.c_ubyte * mbi.RegionSize)()
sz = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, mbi.BaseAddress, buf, mbi.RegionSize, ctypes.byref(sz)):
data = bytes(buf[:sz.value])
end = (len(data) // 4) * 4
for off in range(0, end, 4):
v = struct.unpack_from("<I", data, off)[0]
if v == UIITEM_VTABLE:
all_instances.append(mbi.BaseAddress + off)
addr = (mbi.BaseAddress or 0) + mbi.RegionSize
if addr >= 0x80000000:
break
# For each instance, read +0x5fc (item GUID)
zero_guid = 0
nonzero_guid = 0
for inst_addr in all_instances:
guid_addr = inst_addr + 0x5fc
buf4 = (ctypes.c_ubyte * 4)()
sz4 = ctypes.c_size_t(0)
if k.ReadProcessMemory(h, guid_addr, buf4, 4, ctypes.byref(sz4)):
guid = struct.unpack("<I", bytes(buf4))[0]
if guid == 0:
zero_guid += 1
else:
nonzero_guid += 1
ts = time.strftime("%H:%M:%S")
print(f"PID {args.pid} @ {ts}")
print(f" UIElement_UIItem instances: {len(all_instances)}")
print(f" cleared (item-GUID = 0): {zero_guid} <- leak signature")
print(f" active (item-GUID != 0): {nonzero_guid}")

View file

@ -0,0 +1,41 @@
"""count_vtable_instances.py <dump.dmp> <vtable_va>
Count how many objects in the dump have <vtable_va> as their first DWORD.
Print the count and a few sample addresses.
"""
import struct, sys
from minidump.minidumpfile import MinidumpFile
def _ei(v):
if v is None: return 0
if hasattr(v, 'value'): return int(v.value)
return int(v)
md = MinidumpFile.parse(sys.argv[1])
vt = int(sys.argv[2], 16)
rdr = md.get_reader().get_buffered_reader()
scan = []
for r in md.memory_info.infos:
st, ty, pr = _ei(r.State), _ei(r.Type), _ei(r.Protect) & 0xff
if st != 0x1000 or ty == 0x1000000 or pr not in (0x04, 0x40): continue
scan.append((r.BaseAddress, r.RegionSize))
count = 0
samples = []
for base, size in scan:
try:
rdr.move(base); buf = rdr.read(size)
except Exception: continue
if not buf: continue
end = (len(buf) // 4) * 4
for off in range(0, end - 4, 4):
if struct.unpack_from("<I", buf, off)[0] == vt:
count += 1
if len(samples) < 5:
samples.append(base + off)
print(f"instances of vtable 0x{vt:08x}: {count}")
for s in samples:
print(f" 0x{s:08x}")

Some files were not shown because too many files have changed in this diff Show more