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