Initial commit — leak-hunt project complete

Five bugs identified and patched in retail Asheron's Call client:
- v3b: palette refcount over-increment (3-byte NOP at two sites)
- v5: RenderSurface PurgeResource no-op stub (vtable slot 2 thunk)
- v11: two dangling-pointer crash guards (NULL-check + reorder)
- v14: CEnvCell::Destroy ClipPlaneList leak (18-byte JMP to cleanup thunk)
- v22: unpacker stale-pointer SEH guard (whole-function __try/__except)

All five ship in leakfix.dll (117 KB, SHA d282f23c…) which is loaded
by acclient.exe at process start via PE import table patching by
tools/install_leakfix.py.

Controlled 15-client fleet soak: unpatched control died at 26h with
palette exhaustion; all 14 patched clients survived past that point
and reached ≥5-day uptime.

Residual ~15 MB/h growth traced to d3d9.dll's internal slab allocator
(260KB surface backing buffers retained after Release). See REPORT.md
§10 for the full investigation; conclusion is that it's unfixable from
outside d3d9.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
acbot 2026-05-23 21:05:17 +02:00
commit 57b5e43d0e
199 changed files with 1648333 additions and 0 deletions

5
bin/_uac_probe.ps1 Normal file
View file

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

7
bin/_user32.ps1 Normal file
View file

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

57
bin/admin_hklm_only.ps1 Normal file
View file

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

91
bin/admin_setup.ps1 Normal file
View file

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

27
bin/admin_uac_silent.ps1 Normal file
View file

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

71
bin/bench_roundtrip.ahk Normal file
View file

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

51
bin/cdb_probe.ps1 Normal file
View file

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

18
bin/heap_summary.cdb Normal file
View file

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

48
bin/keepalive_f5.ahk Normal file
View file

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

72
bin/keepalive_f5.ps1 Normal file
View file

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

View file

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

33
bin/sample_fleet.ps1 Normal file
View file

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

105
bin/snapshot_leakers.ps1 Normal file
View file

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

63
bin/take_snapshot.ps1 Normal file
View file

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