53 views +0 -0

Disk usage analysis by reading MFT

Run:

$ProgressPreference = "SilentlyContinue"
$Link = "https://kbhost.nl/powershell-cmd/disk-usage-analysis-by-reading-mft/snippet/2"
$Path = "C:\Users\Public\Downloads"
$File = "Get-DiskUsage.ps1"
Invoke-WebRequest -Uri $Link -OutFile $Path\$File -UseBasicParsing
& "$Path\$File"

Full Script:

[CmdletBinding()]
param(
    [Parameter(Position = 0)]
    [string]$Path = ($env:SystemDrive + '\'),

    [ValidateRange(0, 10000)]
    [int]$Top = 25,

    [ValidateRange(0, 64)]
    [int]$Depth = 0,

    [string]$CsvPath,

    [switch]$LogicalSize,
    [switch]$ExcludeMetafiles,
    [switch]$ExcludeAds,

    [string[]]$Exclude,
    [switch]$NoDefaultExclude,
    [switch]$ShowExcluded,
    [switch]$Gui,
    [switch]$Recycle,
    [switch]$PassThru,
    [switch]$Quiet,

    [ValidateRange(1, 64)]
    [int]$BufferMB = 8,

    [switch]$Select,
    [switch]$NoSelect,
    [string[]]$Delete,
    [string]$DeleteListPath,
    [switch]$Commit,
    [string[]]$DeleteScope,
    [switch]$NoDenyList,

    [ValidateRange(1, 100000)]
    [int]$MaxDeleteFiles = 50,

    [ValidateRange(1, 10485760)]
    [int]$MaxDeleteMB = 102400,

    [string]$ReceiptPath,

    [ValidateRange(5, 3600)]
    [int]$SelectTimeoutSeconds = 120
)

$ErrorActionPreference = 'Stop'

$Script:BannerWidth  = 80
$Script:OrphanPrefix = '<orphaned>\'

$CSharpSource = @'
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Win32.SafeHandles;

namespace SectorDiskUsage
{
    internal static class NativeMethods
    {
        internal const uint GENERIC_READ               = 0x80000000;
        internal const uint FILE_SHARE_READ            = 0x00000001;
        internal const uint FILE_SHARE_WRITE           = 0x00000002;
        internal const uint FILE_SHARE_DELETE          = 0x00000004;
        internal const uint OPEN_EXISTING              = 3;
        internal const uint FILE_FLAG_SEQUENTIAL_SCAN  = 0x08000000;
        internal const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
        internal const uint FILE_BEGIN                 = 0;

        [DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
        internal static extern SafeFileHandle CreateFileW(
            string lpFileName, uint dwDesiredAccess, uint dwShareMode,
            IntPtr lpSecurityAttributes, uint dwCreationDisposition,
            uint dwFlagsAndAttributes, IntPtr hTemplateFile);

        [DllImport("kernel32.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        internal static extern bool SetFilePointerEx(
            SafeFileHandle hFile, long liDistanceToMove,
            out long lpNewFilePointer, uint dwMoveMethod);

        [DllImport("kernel32.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        internal static extern bool ReadFile(
            SafeFileHandle hFile, IntPtr lpBuffer, uint nNumberOfBytesToRead,
            out uint lpNumberOfBytesRead, IntPtr lpOverlapped);

        [StructLayout(LayoutKind.Sequential)]
        internal struct BY_HANDLE_FILE_INFORMATION
        {
            public uint FileAttributes;
            public uint CreationTimeLow;
            public uint CreationTimeHigh;
            public uint LastAccessTimeLow;
            public uint LastAccessTimeHigh;
            public uint LastWriteTimeLow;
            public uint LastWriteTimeHigh;
            public uint VolumeSerialNumber;
            public uint FileSizeHigh;
            public uint FileSizeLow;
            public uint NumberOfLinks;
            public uint FileIndexHigh;
            public uint FileIndexLow;
        }

        [DllImport("kernel32.dll", SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        internal static extern bool GetFileInformationByHandle(
            SafeFileHandle hFile, out BY_HANDLE_FILE_INFORMATION lpFileInformation);
    }

    public struct VolumeGeometry
    {
        public int   BytesPerSector;
        public int   SectorsPerCluster;
        public long  ClusterSize;
        public long  MftStartLcn;
        public int   RecordSize;
    }

    public struct Extent
    {
        public long Lcn;
        public long Count;
    }

    public sealed class TreeNode
    {
        public int Index;
        public int RelDepth;
    }

    public sealed class ExcludeResult
    {
        public bool[] Mask;
        public int    Files;
        public int    Dirs;
        public long   Size;
        public long   Alloc;
        public string[] Rules;
        public int[]    RuleRoots;
        public long[]   RuleSize;
        public long[]   RuleAlloc;
        public int      Suppressed;
    }

    public sealed class ScanResult
    {
        public string[] Name;
        public int[]    Parent;
        public long[]   Size;
        public long[]   Alloc;
        public long[]   Subtree;
        public long[]   SubtreeAlloc;
        public int[]    Depth;
        public byte[]   Flags;
        public int[]    Order;

        public int  RecordCount;
        public int  MaxDepth;
        public int  FileCount;
        public int  DirCount;
        public int  OrphanCount;
        public int  AttrListCount;
        public int  ExtensionRecords;
        public int  AdsCount;
        public int  SparseCount;
        public int  TornRecords;

        public long OrphanSize;
        public long OrphanAlloc;

        public long MftBytesRead;
        public long MftExtentBytes;
        public int  MftFragments;

        public double ReadSeconds;
        public double ParseSeconds;
        public double AggregateSeconds;

        public VolumeGeometry Geometry;
        public string VolumeRoot;
    }

    public static class MftScanner
    {
        public const byte FLAG_INUSE      = 0x01;
        public const byte FLAG_DIR        = 0x02;
        public const byte FLAG_ATTRLIST   = 0x08;
        public const byte FLAG_COMPRESSED = 0x20;
        public const byte FLAG_ADS        = 0x40;

        public const string Contract = "__CONTRACT__";

        public const int ROOT_RECORD   = 5;
        public const int FIRST_USER_RECORD = 16;

        public const string ORPHAN_PREFIX = "<orphaned>\\";

        private const int MAX_DEPTH   = 512;
        private const uint SIG_FILE   = 0x454C4946;
        private const long REF_MASK   = 0x0000FFFFFFFFFFFF;

        public static SafeFileHandle OpenVolume(char driveLetter, out int win32Error)
        {
            string device = "\\\\.\\" + Char.ToUpperInvariant(driveLetter) + ":";
            SafeFileHandle h = NativeMethods.CreateFileW(
                device,
                NativeMethods.GENERIC_READ,
                NativeMethods.FILE_SHARE_READ | NativeMethods.FILE_SHARE_WRITE,
                IntPtr.Zero,
                NativeMethods.OPEN_EXISTING,
                NativeMethods.FILE_FLAG_SEQUENTIAL_SCAN,
                IntPtr.Zero);
            win32Error = Marshal.GetLastWin32Error();
            if (h.IsInvalid)
            {
                h.Dispose();
                return null;
            }
            win32Error = 0;
            return h;
        }

        private static void ReadExact(SafeFileHandle h, long offset, byte[] buf, int bufOffset, int count)
        {
            long newPos;
            if (!NativeMethods.SetFilePointerEx(h, offset, out newPos, NativeMethods.FILE_BEGIN))
            {
                throw new IOException(String.Format(
                    "Seek to volume offset {0} failed (Win32 error {1}).",
                    offset, Marshal.GetLastWin32Error()));
            }

            GCHandle pin = GCHandle.Alloc(buf, GCHandleType.Pinned);
            try
            {
                long basePtr = pin.AddrOfPinnedObject().ToInt64();
                int got = 0;
                while (got < count)
                {
                    uint read;
                    IntPtr dst = new IntPtr(basePtr + bufOffset + got);
                    if (!NativeMethods.ReadFile(h, dst, (uint)(count - got), out read, IntPtr.Zero))
                    {
                        throw new IOException(String.Format(
                            "Read of {0} bytes at volume offset {1} failed (Win32 error {2}).",
                            count, offset, Marshal.GetLastWin32Error()));
                    }
                    if (read == 0)
                    {
                        throw new IOException(String.Format(
                            "Unexpected end of volume at offset {0}: wanted {1} bytes, got {2}.",
                            offset, count, got));
                    }
                    got += (int)read;
                }
            }
            finally
            {
                pin.Free();
            }
        }

        public static VolumeGeometry ReadBootSector(SafeFileHandle h)
        {
            byte[] boot = new byte[4096];
            ReadExact(h, 0, boot, 0, 4096);
            return ParseBootSector(boot);
        }

        public static VolumeGeometry ParseBootSector(byte[] boot)
        {
            string oem = Encoding.ASCII.GetString(boot, 0x03, 8);
            if (oem != "NTFS    ")
            {
                throw new InvalidDataException(
                    "Volume is not NTFS: boot sector OEM id is '" + oem.Replace("\0", " ").Trim() + "'.");
            }

            VolumeGeometry g = new VolumeGeometry();
            g.BytesPerSector = BitConverter.ToUInt16(boot, 0x0B);

            int spc = boot[0x0D];
            if (spc > 0x80) { spc = 1 << (256 - spc); }
            g.SectorsPerCluster = spc;
            g.ClusterSize   = (long)g.BytesPerSector * spc;
            g.MftStartLcn   = BitConverter.ToInt64(boot, 0x30);

            sbyte cpr = (sbyte)boot[0x40];
            g.RecordSize = (cpr >= 0) ? (int)(cpr * g.ClusterSize) : (1 << (-cpr));

            if (g.BytesPerSector < 256 || g.BytesPerSector > 65536 ||
                (g.BytesPerSector & (g.BytesPerSector - 1)) != 0)
            {
                throw new InvalidDataException(String.Format(
                    "Implausible bytes per sector in the boot sector: {0}.", g.BytesPerSector));
            }
            if (spc < 1 || g.ClusterSize < g.BytesPerSector)
            {
                throw new InvalidDataException(String.Format(
                    "Implausible cluster size in the boot sector: {0}.", g.ClusterSize));
            }
            if (g.RecordSize < 256 || g.RecordSize > 65536 ||
                (g.RecordSize & (g.RecordSize - 1)) != 0 ||
                g.RecordSize % g.BytesPerSector != 0)
            {
                throw new InvalidDataException(String.Format(
                    "Implausible MFT record size in the boot sector: {0}.", g.RecordSize));
            }
            if (g.MftStartLcn <= 0)
            {
                throw new InvalidDataException("Boot sector reports an invalid $MFT start cluster.");
            }
            return g;
        }

        public static bool ApplyFixups(byte[] buf, int off, int recSize)
        {
            int usaOff   = BitConverter.ToUInt16(buf, off + 0x04);
            int usaCount = BitConverter.ToUInt16(buf, off + 0x06);
            if (usaCount < 2) { return false; }

            int fixupCount = usaCount - 1;
            if (usaOff < 0x2A || usaOff + usaCount * 2 > recSize) { return false; }

            int stride = recSize / fixupCount;
            if (stride < 2 || stride * fixupCount != recSize) { return false; }

            ushort usn = BitConverter.ToUInt16(buf, off + usaOff);
            for (int i = 0; i < fixupCount; i++)
            {
                int tail = off + (i + 1) * stride - 2;
                if (BitConverter.ToUInt16(buf, tail) != usn) { return false; }
                int src = off + usaOff + (i + 1) * 2;
                buf[tail]     = buf[src];
                buf[tail + 1] = buf[src + 1];
            }
            return true;
        }

        public static long SumAllocatedClusters(byte[] buf, int pos, int end)
        {
            long clusters = 0;
            while (pos < end)
            {
                int header = buf[pos];
                if (header == 0) { break; }
                pos++;

                int lenSize = header & 0x0F;
                int offSize = (header >> 4) & 0x0F;
                if (lenSize == 0 || lenSize > 8 || offSize > 8) { return -1; }
                if (pos + lenSize + offSize > end) { return -1; }

                long runLength = 0;
                for (int i = 0; i < lenSize; i++)
                {
                    runLength |= (long)buf[pos + i] << (i * 8);
                }
                pos += lenSize;

                if (offSize == 0) { continue; }
                clusters += runLength;
                pos += offSize;
            }
            return clusters;
        }

        public static List<Extent> DecodeRunList(byte[] buf, int pos, int end)
        {
            List<Extent> runs = new List<Extent>();
            long lcn = 0;

            while (pos < end)
            {
                int header = buf[pos];
                if (header == 0) { break; }
                pos++;

                int lenSize = header & 0x0F;
                int offSize = (header >> 4) & 0x0F;
                if (lenSize == 0 || lenSize > 8 || offSize > 8)
                {
                    throw new InvalidDataException(String.Format(
                        "Malformed data run header 0x{0:X2}.", header));
                }
                if (pos + lenSize + offSize > end)
                {
                    throw new InvalidDataException("Data run list is truncated.");
                }

                long runLength = 0;
                for (int i = 0; i < lenSize; i++)
                {
                    runLength |= (long)buf[pos + i] << (i * 8);
                }
                pos += lenSize;

                if (offSize == 0)
                {
                    continue;
                }

                long delta = ((buf[pos + offSize - 1] & 0x80) != 0) ? -1L : 0L;
                for (int i = 0; i < offSize; i++)
                {
                    delta = (delta & ~(0xFFL << (i * 8))) | ((long)buf[pos + i] << (i * 8));
                }
                pos += offSize;

                lcn += delta;
                if (lcn < 0)
                {
                    throw new InvalidDataException("Data run resolved to a negative cluster number.");
                }

                Extent e;
                e.Lcn = lcn;
                e.Count = runLength;
                runs.Add(e);
            }
            return runs;
        }

        public static List<Extent> ReadMftExtents(SafeFileHandle h, VolumeGeometry g,
                                                  out long realSize, out long allocatedSize,
                                                  out bool hasAttrList)
        {
            hasAttrList = false;

            byte[] rec = new byte[g.RecordSize];
            ReadExact(h, g.MftStartLcn * g.ClusterSize, rec, 0, g.RecordSize);

            if (BitConverter.ToUInt32(rec, 0) != SIG_FILE)
            {
                throw new InvalidDataException(
                    "MFT record 0 does not carry a FILE signature. The boot sector's $MFT start " +
                    "cluster looks wrong, or this is not an NTFS volume.");
            }
            if (!ApplyFixups(rec, 0, g.RecordSize))
            {
                throw new InvalidDataException("Fixup validation failed on MFT record 0.");
            }

            int attrOff = BitConverter.ToUInt16(rec, 0x14);
            int used    = (int)BitConverter.ToUInt32(rec, 0x18);
            if (used <= 0 || used > g.RecordSize) { used = g.RecordSize; }

            int p = attrOff;
            while (p + 8 <= used)
            {
                uint type = BitConverter.ToUInt32(rec, p);
                if (type == 0xFFFFFFFF) { break; }
                int len = (int)BitConverter.ToUInt32(rec, p + 4);
                if (len < 8 || (len & 7) != 0 || p + len > used) { break; }

                if (len < 0x10) { p += len; continue; }

                bool nonRes  = rec[p + 8] != 0;
                int  nameLen = rec[p + 9];

                if (type == 0x20) { hasAttrList = true; }

                if (type == 0x80 && nonRes && nameLen == 0 && len >= 0x40)
                {
                    long startVcn = BitConverter.ToInt64(rec, p + 0x10);
                    if (startVcn == 0)
                    {
                        allocatedSize = BitConverter.ToInt64(rec, p + 0x28);
                        realSize      = BitConverter.ToInt64(rec, p + 0x30);
                        int runOff    = BitConverter.ToUInt16(rec, p + 0x20);
                        if (runOff < 0x40 || p + runOff > p + len)
                        {
                            throw new InvalidDataException("$MFT $DATA has an invalid data run offset.");
                        }
                        return DecodeRunList(rec, p + runOff, p + len);
                    }
                }
                p += len;
            }
            throw new InvalidDataException(
                "No unnamed non-resident $DATA attribute found in MFT record 0.");
        }

        public static void ParseRecord(byte[] buf, int off, int recNo, ScanResult r,
                                       int recordSize, long clusterSize, bool includeAds)
        {
            if (BitConverter.ToUInt32(buf, off) != SIG_FILE) { return; }

            if (!ApplyFixups(buf, off, recordSize)) { r.TornRecords++; return; }

            int hdrFlags = BitConverter.ToUInt16(buf, off + 0x16);
            if ((hdrFlags & 0x0001) == 0) { return; }
            bool isDir = (hdrFlags & 0x0002) != 0;

            long baseRef = BitConverter.ToInt64(buf, off + 0x20) & REF_MASK;
            bool isExtension = baseRef != 0;

            int target;
            if (isExtension)
            {
                if (baseRef >= r.RecordCount) { return; }
                target = (int)baseRef;
                r.ExtensionRecords++;
                if ((r.Flags[target] & FLAG_ATTRLIST) == 0)
                {
                    r.Flags[target] |= FLAG_ATTRLIST;
                    r.AttrListCount++;
                }
            }
            else
            {
                target = recNo;
                r.Flags[recNo] |= FLAG_INUSE;
                if (isDir) { r.Flags[recNo] |= FLAG_DIR; }
            }

            int attrOff = BitConverter.ToUInt16(buf, off + 0x14);
            int used    = (int)BitConverter.ToUInt32(buf, off + 0x18);
            if (used <= 0 || used > recordSize) { used = recordSize; }

            int bestNameScore = 0;
            int p = attrOff;

            while (p + 8 <= used)
            {
                uint type = BitConverter.ToUInt32(buf, off + p);
                if (type == 0xFFFFFFFF) { break; }

                int len = (int)BitConverter.ToUInt32(buf, off + p + 4);

                if (len < 8 || (len & 7) != 0 || p + len > used) { break; }

                if (len < 0x10) { p += len; continue; }

                bool nonRes    = buf[off + p + 8] != 0;
                int  nameLen   = buf[off + p + 9];
                int  attrFlags = BitConverter.ToUInt16(buf, off + p + 0x0C);

                if (type == 0x30 && !nonRes && !isExtension && len >= 0x18)
                {
                    int vLen = (int)BitConverter.ToUInt32(buf, off + p + 0x10);
                    int vOff = BitConverter.ToUInt16(buf, off + p + 0x14);
                    if (vLen >= 0x42 && vOff >= 0x18 && p + vOff + vLen <= used)
                    {
                        int v      = off + p + vOff;
                        int fnLen  = buf[v + 0x40];
                        int ns     = buf[v + 0x41];
                        if (fnLen > 0 && 0x42 + fnLen * 2 <= vLen)
                        {
                            int score = (ns == 1 || ns == 3) ? 3 : ((ns == 0) ? 2 : 1);
                            if (score > bestNameScore)
                            {
                                bestNameScore = score;
                                r.Name[target] = Encoding.Unicode.GetString(buf, v + 0x42, fnLen * 2);

                                long parentRef = BitConverter.ToInt64(buf, v) & REF_MASK;
                                r.Parent[target] = (parentRef < r.RecordCount) ? (int)parentRef : -1;
                            }
                        }
                    }
                }
                else if (type == 0x80)
                {
                    long addSize  = 0;
                    long addAlloc = 0;

                    if (nonRes && len >= 0x40)
                    {
                        long startVcn = BitConverter.ToInt64(buf, off + p + 0x10);

                        if (startVcn == 0)
                        {
                            addSize = BitConverter.ToInt64(buf, off + p + 0x30);
                        }
                        if ((attrFlags & 0x8001) != 0) { r.Flags[target] |= FLAG_COMPRESSED; }

                        int runOff = BitConverter.ToUInt16(buf, off + p + 0x20);
                        long clusters = -1;
                        if (runOff >= 0x40 && runOff < len)
                        {
                            clusters = SumAllocatedClusters(buf, off + p + runOff, off + p + len);
                        }
                        if (clusters >= 0)
                        {
                            addAlloc = clusters * clusterSize;
                        }
                        else if (startVcn == 0)
                        {
                            addAlloc = BitConverter.ToInt64(buf, off + p + 0x28);
                        }
                    }
                    else if (!nonRes && len >= 0x18)
                    {
                        addSize = (long)BitConverter.ToUInt32(buf, off + p + 0x10);
                    }

                    if (addSize < 0)  { addSize = 0; }
                    if (addAlloc < 0) { addAlloc = 0; }

                    if (nameLen == 0)
                    {
                        r.Size[target]  += addSize;
                        r.Alloc[target] += addAlloc;
                    }
                    else
                    {
                        if ((r.Flags[target] & FLAG_ADS) == 0)
                        {
                            r.Flags[target] |= FLAG_ADS;
                            r.AdsCount++;
                        }
                        if (includeAds)
                        {
                            r.Size[target]  += addSize;
                            r.Alloc[target] += addAlloc;
                        }
                    }
                }

                p += len;
            }
        }

        public static ScanResult Scan(SafeFileHandle h, char driveLetter, int bufferBytes,
                                      bool includeAds, bool includeMetafiles)
        {
            Stopwatch swRead  = new Stopwatch();
            Stopwatch swParse = new Stopwatch();

            VolumeGeometry g = ReadBootSector(h);

            long mftReal, mftAlloc;
            bool mftHasAttrList;
            List<Extent> extents = ReadMftExtents(h, g, out mftReal, out mftAlloc, out mftHasAttrList);
            if (extents.Count == 0)
            {
                throw new InvalidDataException("$MFT has an empty data run list.");
            }

            long extentClusters = 0;
            for (int i = 0; i < extents.Count; i++) { extentClusters += extents[i].Count; }
            long extentBytes = extentClusters * g.ClusterSize;

            if (mftAlloc > 0 && extentBytes != mftAlloc)
            {
                if (mftHasAttrList && extentBytes < mftAlloc)
                {
                    throw new InvalidDataException(String.Format(
                        "This volume's $MFT is so fragmented that its own $DATA attribute has " +
                        "spilled into extension records ($ATTRIBUTE_LIST): record 0 describes " +
                        "{0} bytes of {1}. This script does not resolve attribute lists and " +
                        "cannot scan this volume. Running a defrag may consolidate the $MFT.",
                        extentBytes, mftAlloc));
                }
                throw new InvalidDataException(String.Format(
                    "$MFT runlist self check failed: the runs describe {0} bytes but the $DATA " +
                    "header reports {1} bytes allocated. The data run decode is wrong.",
                    extentBytes, mftAlloc));
            }

            int recordCount = (int)(extentBytes / g.RecordSize);
            if (recordCount <= 0 || recordCount > 200000000)
            {
                throw new InvalidDataException(String.Format(
                    "Implausible MFT record count: {0}.", recordCount));
            }

            ScanResult r = new ScanResult();
            r.RecordCount      = recordCount;
            r.Geometry         = g;
            r.VolumeRoot       = Char.ToUpperInvariant(driveLetter) + ":\\";
            r.MftExtentBytes   = extentBytes;
            r.MftFragments     = extents.Count;

            r.Name         = new string[recordCount];
            r.Parent       = new int[recordCount];
            r.Size         = new long[recordCount];
            r.Alloc        = new long[recordCount];
            r.Subtree      = new long[recordCount];
            r.SubtreeAlloc = new long[recordCount];
            r.Depth        = new int[recordCount];
            r.Flags        = new byte[recordCount];
            for (int i = 0; i < recordCount; i++) { r.Parent[i] = -1; }

            int chunkSize = bufferBytes;
            if (chunkSize < g.RecordSize * 16) { chunkSize = g.RecordSize * 16; }
            byte[] chunk = new byte[chunkSize];

            int sector = g.BytesPerSector;
            int recSize = g.RecordSize;
            int carry = 0;
            int recNo = 0;

            for (int ei = 0; ei < extents.Count && recNo < recordCount; ei++)
            {
                long pos       = extents[ei].Lcn * g.ClusterSize;
                long remaining = extents[ei].Count * g.ClusterSize;

                while (remaining > 0 && recNo < recordCount)
                {
                    int want = (int)Math.Min((long)(chunkSize - carry), remaining);
                    want -= want % sector;
                    if (want <= 0) { break; }

                    swRead.Start();
                    ReadExact(h, pos, chunk, carry, want);
                    swRead.Stop();
                    r.MftBytesRead += want;

                    pos       += want;
                    remaining -= want;

                    int have  = carry + want;
                    int whole = have / recSize;

                    swParse.Start();
                    for (int k = 0; k < whole && recNo < recordCount; k++)
                    {
                        ParseRecord(chunk, k * recSize, recNo, r, recSize, g.ClusterSize, includeAds);
                        recNo++;
                    }
                    swParse.Stop();

                    carry = have - whole * recSize;
                    if (carry > 0)
                    {
                        Buffer.BlockCopy(chunk, whole * recSize, chunk, 0, carry);
                    }
                }
            }

            if (!includeMetafiles)
            {
                for (int i = 0; i < FIRST_USER_RECORD && i < recordCount; i++)
                {
                    if (i == ROOT_RECORD) { continue; }
                    r.Size[i]  = 0;
                    r.Alloc[i] = 0;
                }
            }

            r.ReadSeconds  = swRead.Elapsed.TotalSeconds;
            r.ParseSeconds = swParse.Elapsed.TotalSeconds;

            Stopwatch swAgg = Stopwatch.StartNew();
            Aggregate(r);
            swAgg.Stop();
            r.AggregateSeconds = swAgg.Elapsed.TotalSeconds;

            return r;
        }

        public static void Aggregate(ScanResult r)
        {
            int n = r.RecordCount;
            int[] depth = r.Depth;
            for (int i = 0; i < n; i++) { depth[i] = -2; }

            if (n > ROOT_RECORD && (r.Flags[ROOT_RECORD] & FLAG_INUSE) != 0)
            {
                r.Name[ROOT_RECORD]   = r.VolumeRoot;
                r.Parent[ROOT_RECORD] = -1;
                r.Flags[ROOT_RECORD] |= FLAG_DIR;
                depth[ROOT_RECORD]    = 0;
            }

            int[] stack = new int[MAX_DEPTH + 2];
            int maxDepth = 0;

            for (int i = 0; i < n; i++)
            {
                if ((r.Flags[i] & FLAG_INUSE) == 0) { depth[i] = -1; continue; }
                if (depth[i] != -2) { continue; }

                int sp = 0;
                int cur = i;
                int resolved = -1;

                while (true)
                {
                    if (sp >= MAX_DEPTH) { resolved = -1; break; }
                    stack[sp++] = cur;

                    int par = r.Parent[cur];
                    if (par < 0 || par >= n)            { resolved = -1; break; }
                    if (par == cur)                     { resolved = -1; break; }
                    if ((r.Flags[par] & FLAG_INUSE) == 0) { resolved = -1; break; }
                    if ((r.Flags[par] & FLAG_DIR) == 0)   { resolved = -1; break; }
                    if (depth[par] >= 0)                { resolved = depth[par]; break; }
                    if (depth[par] == -1)               { resolved = -1; break; }

                    cur = par;
                }

                if (resolved < 0)
                {
                    for (int k = 0; k < sp; k++)
                    {
                        depth[stack[k]] = -1;
                    }
                }
                else
                {
                    for (int k = sp - 1; k >= 0; k--)
                    {
                        resolved++;
                        depth[stack[k]] = resolved;
                        if (resolved > maxDepth) { maxDepth = resolved; }
                    }
                }
            }
            r.MaxDepth = maxDepth;

            int[] counts = new int[maxDepth + 2];
            int inTree = 0;
            for (int i = 0; i < n; i++)
            {
                if (depth[i] >= 0) { counts[depth[i]]++; inTree++; }
            }
            int[] cursor = new int[maxDepth + 2];
            int acc = 0;
            for (int d = 0; d <= maxDepth; d++) { cursor[d] = acc; acc += counts[d]; }

            int[] order = new int[inTree];
            for (int i = 0; i < n; i++)
            {
                if (depth[i] >= 0) { order[cursor[depth[i]]++] = i; }
            }
            r.Order = order;

            for (int i = 0; i < n; i++)
            {
                r.Subtree[i]      = r.Size[i];
                r.SubtreeAlloc[i] = r.Alloc[i];
            }

            for (int k = order.Length - 1; k >= 0; k--)
            {
                int i = order[k];
                int par = r.Parent[i];
                if (par >= 0 && par < n && par != i && depth[par] >= 0)
                {
                    r.Subtree[par]      += r.Subtree[i];
                    r.SubtreeAlloc[par] += r.SubtreeAlloc[i];
                }
            }

            for (int i = 0; i < n; i++)
            {
                if ((r.Flags[i] & FLAG_INUSE) == 0) { continue; }
                if ((r.Flags[i] & FLAG_DIR) != 0) { r.DirCount++; } else { r.FileCount++; }
                if ((r.Flags[i] & FLAG_COMPRESSED) != 0) { r.SparseCount++; }
                if (depth[i] < 0)
                {
                    r.OrphanCount++;
                    r.OrphanSize  += r.Size[i];
                    r.OrphanAlloc += r.Alloc[i];
                }
            }
        }

        public static long GetRecordNumberForPath(string path, out int win32Error)
        {
            uint ignored;
            return GetRecordNumberForPath(path, out win32Error, out ignored);
        }

        public static long GetRecordNumberForPath(string path, out int win32Error, out uint volumeSerial)
        {
            volumeSerial = 0;

            SafeFileHandle h = NativeMethods.CreateFileW(
                path, 0,
                NativeMethods.FILE_SHARE_READ | NativeMethods.FILE_SHARE_WRITE | NativeMethods.FILE_SHARE_DELETE,
                IntPtr.Zero,
                NativeMethods.OPEN_EXISTING,
                NativeMethods.FILE_FLAG_BACKUP_SEMANTICS,
                IntPtr.Zero);
            win32Error = Marshal.GetLastWin32Error();
            if (h.IsInvalid) { h.Dispose(); return -1; }

            try
            {
                NativeMethods.BY_HANDLE_FILE_INFORMATION info;
                if (!NativeMethods.GetFileInformationByHandle(h, out info))
                {
                    win32Error = Marshal.GetLastWin32Error();
                    return -1;
                }
                win32Error = 0;
                volumeSerial = info.VolumeSerialNumber;
                return (((long)info.FileIndexHigh << 32) | (long)info.FileIndexLow) & REF_MASK;
            }
            finally
            {
                h.Dispose();
            }
        }

        public static bool[] MarkSubtree(ScanResult r, int anchor)
        {
            bool[] inScope = new bool[r.RecordCount];
            if (anchor < 0 || anchor >= r.RecordCount) { return inScope; }
            inScope[anchor] = true;

            int[] order = r.Order;
            for (int k = 0; k < order.Length; k++)
            {
                int i = order[k];
                if (i == anchor) { continue; }
                int par = r.Parent[i];
                if (par >= 0 && par < r.RecordCount && inScope[par]) { inScope[i] = true; }
            }
            return inScope;
        }

        public static string BuildPath(ScanResult r, int index)
        {
            if (index < 0 || index >= r.RecordCount) { return null; }
            if (index == ROOT_RECORD) { return r.VolumeRoot; }

            List<string> parts = new List<string>();
            int cur = index;
            int guard = 0;
            bool reachedRoot = false;

            int guardLimit = MAX_DEPTH;
            if (r.MaxDepth + 1 > guardLimit) { guardLimit = r.MaxDepth + 1; }

            while (cur >= 0 && cur < r.RecordCount && guard <= guardLimit)
            {
                if (cur == ROOT_RECORD) { reachedRoot = true; break; }
                string nm = r.Name[cur];
                if (nm == null) { break; }
                parts.Add(nm);
                cur = r.Parent[cur];
                guard++;
            }
            parts.Reverse();

            StringBuilder sb = new StringBuilder(reachedRoot ? r.VolumeRoot : ORPHAN_PREFIX);
            for (int i = 0; i < parts.Count; i++)
            {
                if (i > 0) { sb.Append('\\'); }
                sb.Append(parts[i]);
            }
            return sb.ToString();
        }

        public static string[] BuildPaths(ScanResult r, int[] indices)
        {
            string[] paths = new string[indices.Length];
            for (int i = 0; i < indices.Length; i++) { paths[i] = BuildPath(r, indices[i]); }
            return paths;
        }

        private static bool WildMatch(string text, string pattern)
        {
            if (pattern == null) { return false; }
            if (text == null) { text = ""; }

            int t = 0, p = 0, starP = -1, starT = 0;
            while (t < text.Length)
            {
                if (p < pattern.Length && (pattern[p] == '?' ||
                    char.ToUpperInvariant(pattern[p]) == char.ToUpperInvariant(text[t])))
                {
                    t++; p++;
                }
                else if (p < pattern.Length && pattern[p] == '*')
                {
                    starP = p; starT = t; p++;
                }
                else if (starP >= 0)
                {
                    p = starP + 1; starT++; t = starT;
                }
                else { return false; }
            }
            while (p < pattern.Length && pattern[p] == '*') { p++; }
            return p == pattern.Length;
        }

        public static ExcludeResult MarkExcluded(ScanResult r, string[] patterns, int protectIndex)
        {
            int n = r.RecordCount;
            ExcludeResult res = new ExcludeResult();
            res.Mask = new bool[n];

            int pc = (patterns == null) ? 0 : patterns.Length;
            res.Rules     = new string[pc];
            res.RuleRoots = new int[pc];
            res.RuleSize  = new long[pc];
            res.RuleAlloc = new long[pc];
            if (pc == 0) { return res; }

            string[] norm     = new string[pc];
            string[] leaf     = new string[pc];
            bool[]   nameOnly = new bool[pc];
            bool[]   leafWild = new bool[pc];
            HashSet<string> exactLeaves = new HashSet<string>(StringComparer.OrdinalIgnoreCase);

            for (int p = 0; p < pc; p++)
            {
                string s = (patterns[p] == null) ? "" : patterns[p].Trim();
                res.Rules[p] = s;

                while (s.Length > 3 && s.EndsWith("\\")) { s = s.Substring(0, s.Length - 1); }
                norm[p] = s;

                int slash = s.LastIndexOf('\\');
                leaf[p]     = (slash >= 0) ? s.Substring(slash + 1) : s;
                nameOnly[p] = (slash < 0);
                leafWild[p] = leaf[p].IndexOf('*') >= 0 || leaf[p].IndexOf('?') >= 0;
                if (!leafWild[p] && leaf[p].Length > 0) { exactLeaves.Add(leaf[p]); }
            }

            HashSet<int> protectedSet = new HashSet<int>();
            if (protectIndex >= 0 && protectIndex < n)
            {
                int cur = protectIndex;
                int hops = 0;
                while (cur >= 0 && cur < n && hops <= MAX_DEPTH)
                {
                    if (!protectedSet.Add(cur)) { break; }
                    cur = r.Parent[cur];
                    hops++;
                }
            }

            for (int i = 0; i < n; i++)
            {
                if ((r.Flags[i] & FLAG_INUSE) == 0) { continue; }
                string nm = r.Name[i];
                if (nm == null) { continue; }

                bool maybe = exactLeaves.Contains(nm);
                for (int p = 0; p < pc && !maybe; p++)
                {
                    if (leafWild[p] && WildMatch(nm, leaf[p])) { maybe = true; }
                }
                if (!maybe) { continue; }

                string full = null;
                for (int p = 0; p < pc; p++)
                {
                    bool leafOk = leafWild[p]
                        ? WildMatch(nm, leaf[p])
                        : string.Equals(nm, leaf[p], StringComparison.OrdinalIgnoreCase);
                    if (!leafOk) { continue; }

                    if (!nameOnly[p])
                    {
                        if (full == null) { full = BuildPath(r, i); if (full == null) { full = ""; } }
                        if (!WildMatch(full, norm[p])) { continue; }
                    }

                    if (protectedSet.Contains(i)) { res.Suppressed++; break; }

                    res.Mask[i] = true;
                    res.RuleRoots[p]++;
                    res.RuleSize[p]  += r.Subtree[i];
                    res.RuleAlloc[p] += r.SubtreeAlloc[i];
                    break;
                }
            }

            int[] order = r.Order;
            for (int k = 0; k < order.Length; k++)
            {
                int i = order[k];
                if (res.Mask[i]) { continue; }
                int par = r.Parent[i];
                if (par >= 0 && par < n && res.Mask[par]) { res.Mask[i] = true; }
            }

            for (int i = 0; i < n; i++)
            {
                if (!res.Mask[i]) { continue; }
                if ((r.Flags[i] & FLAG_INUSE) == 0) { continue; }
                if ((r.Flags[i] & FLAG_DIR) != 0) { res.Dirs++; } else { res.Files++; }
                res.Size  += r.Size[i];
                res.Alloc += r.Alloc[i];
            }
            return res;
        }

        public static bool[] CombineScope(bool[] inScope, bool[] excluded, int n)
        {
            if (excluded == null) { return inScope; }
            bool[] res = new bool[n];
            for (int i = 0; i < n; i++)
            {
                res[i] = (inScope == null || inScope[i]) && !excluded[i];
            }
            return res;
        }

        private static long MetricOf(ScanResult r, int i, bool wantDirs, bool useAlloc)
        {
            if (wantDirs) { return useAlloc ? r.SubtreeAlloc[i] : r.Subtree[i]; }
            return useAlloc ? r.Alloc[i] : r.Size[i];
        }

        private static long ReportMetric(ScanResult r, bool[] inScope, bool wantDirs,
                                         bool useAlloc, int i)
        {
            if ((r.Flags[i] & FLAG_INUSE) == 0) { return -1; }
            if (((r.Flags[i] & FLAG_DIR) != 0) != wantDirs) { return -1; }
            if (inScope != null && !inScope[i]) { return -1; }
            if (r.Name[i] == null) { return -1; }
            long v = MetricOf(r, i, wantDirs, useAlloc);
            if (v <= 0) { return -1; }
            return v;
        }

        private static void SiftDown(long[] val, int[] idx, int start, int count)
        {
            int root = start;
            while (true)
            {
                int child = 2 * root + 1;
                if (child >= count) { break; }
                if (child + 1 < count && val[child + 1] < val[child]) { child++; }
                if (val[root] <= val[child]) { break; }
                long tv = val[root]; val[root] = val[child]; val[child] = tv;
                int ti = idx[root]; idx[root] = idx[child]; idx[child] = ti;
                root = child;
            }
        }

        public static int[] TopIndices(ScanResult r, bool[] inScope, bool wantDirs,
                                       bool useAlloc, int top, int excludeIndex)
        {
            if (top <= 0) { return new int[0]; }

            long[] hv = new long[top];
            int[]  hi = new int[top];
            int count = 0;
            int n = r.RecordCount;

            for (int i = 0; i < n; i++)
            {
                if (i == excludeIndex) { continue; }
                long v = ReportMetric(r, inScope, wantDirs, useAlloc, i);
                if (v < 0) { continue; }

                if (count < top)
                {
                    hv[count] = v; hi[count] = i; count++;
                    if (count == top)
                    {
                        for (int s = count / 2 - 1; s >= 0; s--) { SiftDown(hv, hi, s, count); }
                    }
                }
                else if (v > hv[0])
                {
                    hv[0] = v; hi[0] = i;
                    SiftDown(hv, hi, 0, count);
                }
            }

            Array.Sort(hv, hi, 0, count);
            int[] res = new int[count];
            for (int i = 0; i < count; i++) { res[i] = hi[count - 1 - i]; }
            return res;
        }

        public static int[] AllIndices(ScanResult r, bool[] inScope, bool wantDirs, bool useAlloc)
        {
            int n = r.RecordCount;
            int count = 0;
            for (int i = 0; i < n; i++)
            {
                if (ReportMetric(r, inScope, wantDirs, useAlloc, i) >= 0) { count++; }
            }

            long[] keys = new long[count];
            int[] idx = new int[count];
            int w = 0;
            for (int i = 0; i < n && w < count; i++)
            {
                long v = ReportMetric(r, inScope, wantDirs, useAlloc, i);
                if (v < 0) { continue; }
                keys[w] = v; idx[w] = i; w++;
            }

            Array.Sort(keys, idx);
            int[] res = new int[count];
            for (int i = 0; i < count; i++) { res[i] = idx[count - 1 - i]; }
            return res;
        }

        public static TreeNode[] BuildTree(ScanResult r, int anchor, int maxRelDepth,
                                           int topPerLevel, bool useAlloc, bool[] excluded)
        {
            List<TreeNode> outp = new List<TreeNode>();
            if (anchor < 0 || anchor >= r.RecordCount || maxRelDepth < 0) { return outp.ToArray(); }

            int n = r.RecordCount;

            int[] childStart = new int[n + 1];
            int total = 0;
            for (int i = 0; i < n; i++)
            {
                if (!IsTreeChild(r, i, n, excluded)) { continue; }
                childStart[r.Parent[i] + 1]++;
                total++;
            }
            for (int i = 0; i < n; i++) { childStart[i + 1] += childStart[i]; }

            int[] childList = new int[total];
            int[] cursor = new int[n];
            Array.Copy(childStart, cursor, n);
            for (int i = 0; i < n; i++)
            {
                if (!IsTreeChild(r, i, n, excluded)) { continue; }
                childList[cursor[r.Parent[i]]++] = i;
            }

            Walk(r, anchor, 0, maxRelDepth, topPerLevel, useAlloc, childStart, childList, outp);
            return outp.ToArray();
        }

        private static bool IsTreeChild(ScanResult r, int i, int n, bool[] excluded)
        {
            if ((r.Flags[i] & FLAG_INUSE) == 0) { return false; }
            if ((r.Flags[i] & FLAG_DIR) == 0) { return false; }
            if (r.Depth[i] < 0) { return false; }
            if (excluded != null && excluded[i]) { return false; }
            int par = r.Parent[i];
            return par >= 0 && par < n && par != i;
        }

        private static void Walk(ScanResult r, int node, int relDepth, int maxRelDepth,
                                 int topPerLevel, bool useAlloc,
                                 int[] childStart, int[] childList, List<TreeNode> outp)
        {
            TreeNode tn = new TreeNode();
            tn.Index = node;
            tn.RelDepth = relDepth;
            outp.Add(tn);

            if (relDepth >= maxRelDepth) { return; }

            int lo = childStart[node];
            int hi = childStart[node + 1];
            int cnt = hi - lo;
            if (cnt <= 0) { return; }

            long[] keys = new long[cnt];
            int[] kids = new int[cnt];
            for (int i = 0; i < cnt; i++)
            {
                kids[i] = childList[lo + i];
                keys[i] = useAlloc ? r.SubtreeAlloc[kids[i]] : r.Subtree[kids[i]];
            }
            Array.Sort(keys, kids);

            int shown = 0;
            for (int i = cnt - 1; i >= 0 && shown < topPerLevel; i--)
            {
                if (keys[i] <= 0) { break; }
                Walk(r, kids[i], relDepth + 1, maxRelDepth, topPerLevel, useAlloc,
                     childStart, childList, outp);
                shown++;
            }
        }
    }
}
'@

$Script:CSharpContract = & {
    $sha = [System.Security.Cryptography.SHA256]::Create()
    try {
        $bytes = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($CSharpSource))
        ([BitConverter]::ToString($bytes) -replace '-', '').Substring(0, 16)
    }
    finally { $sha.Dispose() }
}

$loadedScanner = 'SectorDiskUsage.MftScanner' -as [type]
if ($loadedScanner) {
    $loadedContract = $null
    try { $loadedContract = [SectorDiskUsage.MftScanner]::Contract } catch { }
    if ($loadedContract -ne $Script:CSharpContract) {
        Write-Host "[X] A different build of this script's compiled helper is already loaded in this PowerShell session." -ForegroundColor Red
        Write-Host "    .NET cannot replace or unload it, so the edited script cannot take effect here." -ForegroundColor Yellow
        Write-Host "    Open a new PowerShell window and run it again." -ForegroundColor Yellow
        exit 1
    }
}
else {
    try {
        Add-Type -TypeDefinition ($CSharpSource.Replace('__CONTRACT__', $Script:CSharpContract)) -Language CSharp
    }
    catch {
        Write-Host "[X] Failed to compile the inline C# scanner: $($_.Exception.Message)" -ForegroundColor Red
        exit 1
    }
}

function Write-Log {
    param([string]$Message, [string]$Level = 'INFO')

    if ($Quiet) { return }
    switch ($Level) {
        'WARN'    { Write-Host "[!] $Message"  -ForegroundColor Yellow }
        'SUCCESS' { Write-Host "[OK] $Message" -ForegroundColor Green }
        default   { Write-Host "[i] $Message"  -ForegroundColor Cyan }
    }
}

function Write-Fatal {
    param([string]$Message, [int]$Code)

    Write-Host "[X] $Message" -ForegroundColor Red
    exit $Code
}

function Write-Banner {
    param([string]$Title)

    if ($Quiet) { return }
    Write-Host ('=' * $Script:BannerWidth) -ForegroundColor Cyan
    Write-Host $Title -ForegroundColor Cyan
    Write-Host ('=' * $Script:BannerWidth) -ForegroundColor Cyan
}

function Format-Bytes {
    param([long]$Bytes, [int]$Width = 11)

    $units = @('B  ', 'KB ', 'MB ', 'GB ', 'TB ', 'PB ')
    $value = [double]$Bytes
    $unit = 0
    while ([Math]::Abs($value) -ge 1024 -and $unit -lt ($units.Count - 1)) {
        $value = $value / 1024
        $unit++
    }
    $text = if ($unit -eq 0) { '{0:N0} {1}' -f $value, $units[$unit] }
            else             { '{0:N2} {1}' -f $value, $units[$unit] }
    $text.PadLeft($Width)
}

function Test-Elevated {
    $id = [Security.Principal.WindowsIdentity]::GetCurrent()
    (New-Object Security.Principal.WindowsPrincipal($id)).IsInRole(
        [Security.Principal.WindowsBuiltInRole]::Administrator)
}

function Resolve-ScanTarget {
    param([string]$InputPath)

    if ($InputPath -match '^([A-Za-z]):?$') { $InputPath = $Matches[1] + ':\' }

    $full = $null
    try {
        $resolved = Resolve-Path -LiteralPath $InputPath -ErrorAction Stop
        $full = $resolved.ProviderPath
    }
    catch {
        if ($InputPath -match '^([A-Za-z]):?\\?$') { $full = $Matches[1] + ':\' }
    }
    if (-not $full) {
        Write-Fatal "Path '$InputPath' does not exist or is not a filesystem path." 3
    }

    $root = [System.IO.Path]::GetPathRoot($full)
    if (-not $root -or $root -notmatch '^[A-Za-z]:\\$') {
        Write-Fatal "Path '$full' is not on a local drive letter. UNC paths and network shares are not supported." 3
    }

    [pscustomobject]@{
        FullPath = $full
        Root     = $root.ToUpperInvariant()
        Letter   = $root.Substring(0, 1).ToUpperInvariant()
        IsRoot   = ($full.TrimEnd('\') + '\') -eq $root
    }
}

function Test-DeletableUsagePath {
    param([string]$Path)

    if ([string]::IsNullOrWhiteSpace($Path))              { return 'empty path' }
    if ($Path.StartsWith($Script:OrphanPrefix))           { return 'no resolvable path, parent chain is broken' }
    if ($Path -notmatch '^[A-Za-z]:\\')                   { return 'not an absolute local path' }
    if ($Path.Length -le 3)                               { return 'volume root' }

    $rootRelative = $Path.Substring(3)
    $firstSegment = ($rootRelative -split '\\')[0]

    $metafiles = @('$MFT', '$MFTMirr', '$LogFile', '$Volume', '$AttrDef', '$Bitmap', '$Boot',
                   '$BadClus', '$Secure', '$UpCase', '$Extend')
    if ($metafiles -contains $firstSegment) { return "NTFS metafile ($firstSegment)" }

    if ($rootRelative -notmatch '\\') {
        $systemFiles = @('pagefile.sys', 'hiberfil.sys', 'swapfile.sys',
                         'DumpStack.log', 'DumpStack.log.tmp')
        if ($systemFiles -contains $firstSegment) { return "Windows system file ($firstSegment)" }
    }
    return $null
}

function Remove-RecycleBinSibling {
    param([string]$Path)

    if ($Path -notmatch '\\\$Recycle\.Bin\\') { return }
    $leaf = [System.IO.Path]::GetFileName($Path)
    if (-not $leaf.StartsWith('$R', [System.StringComparison]::OrdinalIgnoreCase)) { return }

    try {
        $sibling = [System.IO.Path]::Combine(
            [System.IO.Path]::GetDirectoryName($Path), '$I' + $leaf.Substring(2))
        if (Test-Path -LiteralPath $sibling -PathType Leaf) {
            Remove-Item -LiteralPath $sibling -Force -ErrorAction Stop
        }
    }
    catch { }
}

function Remove-UsageFiles {
    param(
        [string[]]$Paths,
        [switch]$Recycle
    )

    if ($Recycle) { Add-Type -AssemblyName Microsoft.VisualBasic }

    $deleted = New-Object System.Collections.Generic.List[string]
    $failed  = New-Object System.Collections.Generic.List[string]
    $blocked = New-Object System.Collections.Generic.List[string]

    foreach ($path in $Paths) {
        $reason = Test-DeletableUsagePath -Path $path
        if ($reason) { $blocked.Add(('{0}  ({1})' -f $path, $reason)); continue }

        try {
            if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
                if (Test-Path -LiteralPath $path) {
                    $failed.Add(('{0}  (is a directory, not deleted)' -f $path))
                }
                else {
                    $failed.Add(('{0}  (no longer present)' -f $path))
                }
                continue
            }
            if ($Recycle) {
                [Microsoft.VisualBasic.FileIO.FileSystem]::DeleteFile(
                    $path,
                    [Microsoft.VisualBasic.FileIO.UIOption]::OnlyErrorDialogs,
                    [Microsoft.VisualBasic.FileIO.RecycleOption]::SendToRecycleBin)
            }
            else {
                Remove-Item -LiteralPath $path -Force -ErrorAction Stop
                Remove-RecycleBinSibling -Path $path
            }
            $deleted.Add($path)
        }
        catch {
            $failed.Add(('{0}  ({1})' -f $path, $_.Exception.Message))
        }
    }

    [pscustomobject]@{
        Deleted = $deleted
        Failed  = $failed
        Blocked = $blocked
    }
}

$Script:DeleteDenyDirs = @('\Windows', '\Program Files', '\Program Files (x86)',
                           '\System Volume Information')

$Script:DeleteDenyExts = @('.pst', '.vhd', '.vhdx', '.vmdk', '.avhdx',
                           '.edb', '.mdf', '.ldf', '.bak')

$Script:DefaultExcludes = @(
    'pagefile.sys', 'hiberfil.sys', 'swapfile.sys', 'DumpStack.log', 'DumpStack.log.tmp',
    '$MFT', '$MFTMirr', '$LogFile', '$Volume', '$AttrDef', '$Bitmap', '$Boot',
    '$BadClus', '$Secure', '$UpCase', '$Extend',
    'System Volume Information',
    'Windows\Installer'
)

if ($NoDenyList) {
    $Script:DeleteDenyDirs = @()
    $Script:DeleteDenyExts = @()
}

function Test-DeleteScope {
    param([string]$Path, [string[]]$Scopes)

    $full = $null
    try { $full = [System.IO.Path]::GetFullPath($Path) } catch { return $false }

    foreach ($scope in $Scopes) {
        $root = $null
        try { $root = [System.IO.Path]::GetFullPath($scope) } catch { continue }
        if (-not $root.EndsWith([System.IO.Path]::DirectorySeparatorChar)) {
            $root += [System.IO.Path]::DirectorySeparatorChar
        }
        if ($full.StartsWith($root, [System.StringComparison]::OrdinalIgnoreCase)) { return $true }
    }
    return $false
}

function Test-ReparseInPath {
    param([string]$Path)

    $dir = $null
    try { $dir = [System.IO.Path]::GetDirectoryName($Path) } catch { return $true }

    $guard = 0
    while ($dir -and $guard -lt 64) {
        $guard++
        try {
            $info = New-Object System.IO.DirectoryInfo($dir)
            if ($info.Exists -and
                ($info.Attributes -band [System.IO.FileAttributes]::ReparsePoint)) { return $true }
        }
        catch { return $true }
        $parent = [System.IO.Path]::GetDirectoryName($dir)
        if ($parent -eq $dir) { break }
        $dir = $parent
    }
    return $false
}

function Test-FileInUse {
    param([string]$Path)

    try {
        $fs = [System.IO.File]::Open($Path, 'Open', 'ReadWrite', 'None')
        $fs.Close(); $fs.Dispose()
        return $false
    }
    catch [System.IO.FileNotFoundException] { return $false }
    catch { return $true }
}

function Test-DeletePlanEntry {
    param(
        [string]$Path,
        [string[]]$Scopes,
        [long]$ExpectedRecord = -1,
        [uint32]$ExpectedSerial = 0
    )

    if ([string]::IsNullOrWhiteSpace($Path))       { return 'EMPTY' }
    if ($Path -match '[\*\?\[]')                   { return 'WILDCARD' }
    if ($Path -notmatch '^[A-Za-z]:\\')            { return 'NOT_ABSOLUTE' }

    $guard = Test-DeletableUsagePath -Path $Path
    if ($guard) { return 'PROTECTED' }

    $full = $null
    try { $full = [System.IO.Path]::GetFullPath($Path) } catch { return 'BAD_PATH' }

    foreach ($deny in $Script:DeleteDenyDirs) {
        $needle = $deny + [System.IO.Path]::DirectorySeparatorChar
        if ($full.IndexOf($needle, [System.StringComparison]::OrdinalIgnoreCase) -ge 0) {
            return 'DENYLIST_DIR'
        }
    }
    $ext = [System.IO.Path]::GetExtension($full)
    if ($ext -and ($Script:DeleteDenyExts -contains $ext.ToLowerInvariant())) { return 'DENYLIST_EXT' }

    if (-not (Test-DeleteScope -Path $full -Scopes $Scopes)) { return 'OUT_OF_SCOPE' }

    if (-not (Test-Path -LiteralPath $full -PathType Leaf)) {
        if (Test-Path -LiteralPath $full) { return 'IS_DIRECTORY' }
        return 'NOT_FOUND'
    }
    if (Test-ReparseInPath -Path $full) { return 'REPARSE_IN_PATH' }

    if ($ExpectedRecord -ge 0) {
        $err = 0
        $serial = [uint32]0
        $rec = [SectorDiskUsage.MftScanner]::GetRecordNumberForPath($full, [ref]$err, [ref]$serial)
        if ($rec -lt 0) { return 'STAT_FAILED' }
        if ($rec -ne $ExpectedRecord) { return 'IDENTITY_MISMATCH' }
        if ($ExpectedSerial -ne 0 -and $serial -ne $ExpectedSerial) { return 'VOLUME_MISMATCH' }
    }
    if (Test-FileInUse -Path $full) { return 'IN_USE' }

    return $null
}

function Read-TimedLine {
    param([int]$TimeoutSeconds)

    if ([Console]::IsInputRedirected) { return [Console]::In.ReadLine() }

    $sb = New-Object System.Text.StringBuilder
    $deadline = (Get-Date).AddSeconds($TimeoutSeconds)
    while ((Get-Date) -lt $deadline) {
        if ([Console]::KeyAvailable) {
            $key = [Console]::ReadKey($true)
            if ($key.Key -eq [ConsoleKey]::Enter) { Write-Host ''; return $sb.ToString() }
            if ($key.Key -eq [ConsoleKey]::Backspace) {
                if ($sb.Length -gt 0) {
                    [void]$sb.Remove($sb.Length - 1, 1)
                    Write-Host "`b `b" -NoNewline
                }
                continue
            }
            if ($key.Key -eq [ConsoleKey]::Escape) { Write-Host ''; return '' }
            if ($key.KeyChar) {
                [void]$sb.Append($key.KeyChar)
                Write-Host $key.KeyChar -NoNewline
            }
        }
        else { Start-Sleep -Milliseconds 50 }
    }
    return $null
}

function Expand-SelectionSpec {
    param([string]$Spec, [int]$Max)

    $out = New-Object System.Collections.Generic.List[int]
    foreach ($piece in ($Spec -split ',')) {
        $p = $piece.Trim()
        if (-not $p) { continue }
        if ($p -match '^([0-9]+)$') {
            $n = [int]$Matches[1]
            if ($n -lt 1 -or $n -gt $Max) { return $null }
            if (-not $out.Contains($n)) { $out.Add($n) }
        }
        elseif ($p -match '^([0-9]+)\s*-\s*([0-9]+)$') {
            $a = [int]$Matches[1]; $b = [int]$Matches[2]
            if ($a -lt 1 -or $b -gt $Max -or $a -gt $b) { return $null }
            foreach ($n in $a..$b) { if (-not $out.Contains($n)) { $out.Add($n) } }
        }
        else { return $null }
    }
    , $out
}

function Invoke-HeadlessDelete {
    param(
        [object[]]$Candidates,
        [string[]]$Scopes,
        [bool]$DoCommit,
        [bool]$UseRecycle,
        [int]$MaxFiles,
        [long]$MaxBytes,
        [string]$Receipt,
        [bool]$InteractiveConfirm,
        [int]$ConfirmTimeoutSeconds = 120
    )

    $ConfirmPreference = 'None'
    $WhatIfPreference  = $false

    $lines = New-Object System.Collections.Generic.List[string]
    $emit = {
        param([string]$Text)
        $lines.Add($Text)
        if (-not $Quiet) { Write-Host $Text }
    }

    $stamp = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
    & $emit ('HDR mode={0} commit={1} recycle={2} utc={3} host={4} user={5}' -f `
        'delete', $DoCommit, $UseRecycle, $stamp, $env:COMPUTERNAME, $env:USERNAME)
    foreach ($s in $Scopes) { & $emit ('HDR scope=' + $s) }
    & $emit ('HDR caps files={0} bytes={1}' -f $MaxFiles, $MaxBytes)

    if ($NoDenyList) { & $emit 'HDR denylist=DISABLED' }

    $plan    = New-Object System.Collections.Generic.List[object]
    $refused = New-Object System.Collections.Generic.List[string]
    foreach ($c in $Candidates) {
        $record = if ($null -ne $c.Record) { [long]$c.Record } else { [long](-1) }
        $serial = if ($null -ne $c.Serial) { [uint32]$c.Serial } else { [uint32]0 }
        $reason = Test-DeletePlanEntry -Path $c.Path -Scopes $Scopes `
            -ExpectedRecord $record -ExpectedSerial $serial
        if ($reason) {
            $refused.Add(('SKIP {0}|{1}' -f $reason, $c.Path))
            continue
        }

        $bytes = if ($null -ne $c.Bytes) { [long]$c.Bytes } else { [long](-1) }
        if ($bytes -lt 0) {
            try { $bytes = [long](Get-Item -LiteralPath $c.Path -Force).Length } catch { $bytes = 0 }
        }
        $plan.Add([pscustomobject]@{ Path = $c.Path; Bytes = $bytes; Record = $record; Serial = $serial })
    }
    foreach ($r in $refused) { & $emit $r }

    $planBytes = 0L
    foreach ($p in $plan) { if ($p.Bytes -gt 0) { $planBytes += $p.Bytes } }
    foreach ($p in $plan) { & $emit ('PLAN {0}|{1}' -f $p.Bytes, $p.Path) }

    if ($plan.Count -eq 0) {
        & $emit 'SUM RESULT=NOTHING_TO_DELETE deleted=0 reclaimed=0'
        Write-DeleteReceipt -Receipt $Receipt -Lines $lines
        return 6
    }

    if ($plan.Count -gt $MaxFiles) {
        & $emit ('SUM RESULT=DELETE_REFUSED reason=MAX_FILES planned={0} cap={1}' -f $plan.Count, $MaxFiles)
        Write-DeleteReceipt -Receipt $Receipt -Lines $lines
        return 8
    }
    if ($planBytes -gt $MaxBytes) {
        & $emit ('SUM RESULT=DELETE_REFUSED reason=MAX_BYTES planned={0} cap={1}' -f $planBytes, $MaxBytes)
        Write-DeleteReceipt -Receipt $Receipt -Lines $lines
        return 8
    }

    if (-not $DoCommit) {
        if (-not $InteractiveConfirm) {
            & $emit ('SUM RESULT=DELETE_PLANNED files={0} bytes={1} (dry run, add -Commit to delete)' -f `
                $plan.Count, $planBytes)
            Write-DeleteReceipt -Receipt $Receipt -Lines $lines
            return 5
        }

        Write-Host ''
        Write-Host ('About to PERMANENTLY delete {0} file(s), {1}.' -f `
            $plan.Count, (Format-Bytes $planBytes 0)) -ForegroundColor Yellow
        if ($UseRecycle) {
            Write-Host 'These go to the Recycle Bin, so the space is not reclaimed until it is emptied.' -ForegroundColor Yellow
        }
        else {
            Write-Host 'This does NOT use the Recycle Bin. They cannot be recovered.' -ForegroundColor Yellow
        }
        Write-Host -NoNewline "Type 'yes' to confirm, anything else cancels: " -ForegroundColor Yellow
        $answer = Read-TimedLine -TimeoutSeconds $ConfirmTimeoutSeconds

        if ($null -eq $answer) {
            Write-Host ''
            & $emit 'SUM RESULT=CONFIRM_TIMEOUT deleted=0 reclaimed=0'
            Write-DeleteReceipt -Receipt $Receipt -Lines $lines
            return 7
        }
        if ($answer.Trim().ToLowerInvariant() -ne 'yes') {
            & $emit 'SUM RESULT=DELETE_CANCELLED deleted=0 reclaimed=0'
            Write-DeleteReceipt -Receipt $Receipt -Lines $lines
            return 6
        }
        & $emit 'HDR confirmed=typed-yes'
    }

    $deleted = 0
    $freed = 0L
    $failed = 0
    foreach ($p in $plan) {
        $recheck = Test-DeletePlanEntry -Path $p.Path -Scopes $Scopes `
            -ExpectedRecord $p.Record -ExpectedSerial $p.Serial
        if ($recheck) {
            & $emit ('SKIP {0}|{1}' -f $recheck, $p.Path)
            $failed++
            continue
        }
        $outcome = Remove-UsageFiles -Paths @($p.Path) -Recycle:$UseRecycle
        if ($outcome.Deleted.Count -eq 1) {
            $deleted++
            if ($p.Bytes -gt 0) { $freed += $p.Bytes }
            & $emit ('DEL {0}|{1}' -f $p.Bytes, $p.Path)
        }
        else {
            $failed++
            $why = if ($outcome.Failed.Count) { $outcome.Failed[0] }
                   elseif ($outcome.Blocked.Count) { $outcome.Blocked[0] }
                   else { $p.Path + '  (unknown)' }
            & $emit ('FAIL ' + $why)
        }
    }

    $verb = if ($UseRecycle) { 'RECYCLED' } else { 'DELETED' }
    $result = if ($failed -eq 0) { 'DELETE_OK' } else { 'DELETE_PARTIAL' }
    & $emit ('SUM RESULT={0} {1}={2} reclaimed={3} failed={4}' -f $result, $verb, $deleted, $freed, $failed)
    Write-DeleteReceipt -Receipt $Receipt -Lines $lines

    if ($failed -gt 0) { return 9 }
    return 0
}

function Write-DeleteReceipt {
    param([string]$Receipt, [object]$Lines)

    if (-not $Receipt) { return }
    try {
        $dir = [System.IO.Path]::GetDirectoryName($Receipt)
        if ($dir -and -not (Test-Path -LiteralPath $dir)) {
            [void](New-Item -ItemType Directory -Path $dir -Force)
        }
        Set-Content -LiteralPath $Receipt -Value $Lines -Encoding UTF8
        if (-not $Quiet) { Write-Host ('RECEIPT=' + $Receipt) }
    }
    catch {
        Write-Host ('[!] Receipt could not be written to {0}: {1}' -f $Receipt, $_.Exception.Message) `
            -ForegroundColor Yellow
        Write-Host 'DEGRADED_AUDIT=1' -ForegroundColor Yellow
    }
}

function Show-UsageGrid {
    param(
        [object[]]$Rows,
        [string]$Title,
        [long]$Reference,
        [bool]$UseAllocated,
        [bool]$UseRecycleBin
    )

    Add-Type -AssemblyName System.Windows.Forms
    Add-Type -AssemblyName System.Drawing

    $NL = [Environment]::NewLine

    $activeName = if ($UseAllocated) { 'Size on disk' } else { 'Size' }
    $otherName  = if ($UseAllocated) { 'Logical' }      else { 'On disk' }

    $table = New-Object System.Data.DataTable
    [void]$table.Columns.Add('Type',    [string])
    [void]$table.Columns.Add('Size',    [long])
    [void]$table.Columns.Add('Percent', [double])
    [void]$table.Columns.Add('Other',   [long])
    [void]$table.Columns.Add('Path',    [string])
    [void]$table.Columns.Add('Key',     [int])

    $rowKey = 0
    foreach ($row in $Rows) {
        $pct = if ($Reference -gt 0) { 100 * $row.Size / $Reference } else { 0 }
        $other = if ($UseAllocated) { $row.Logical } else { $row.Allocated }
        [void]$table.Rows.Add($row.Type, $row.Size, $pct, $other, $row.Path, $rowKey)
        $rowKey++
    }
    $table.DefaultView.Sort = 'Size DESC'

    $selectedKeys = New-Object System.Collections.Generic.HashSet[int]

    $form = New-Object System.Windows.Forms.Form
    $form.Text          = $Title
    $form.Size          = New-Object System.Drawing.Size(1150, 720)
    $form.MinimumSize   = New-Object System.Drawing.Size(700, 300)
    $form.StartPosition = 'CenterScreen'

    $top = New-Object System.Windows.Forms.Panel
    $top.Dock = 'Top'; $top.Height = 40

    $label = New-Object System.Windows.Forms.Label
    $label.Text = 'Filter path:'
    $label.Location = New-Object System.Drawing.Point(8, 12)
    $label.AutoSize = $true

    $filter = New-Object System.Windows.Forms.TextBox
    $filter.Location = New-Object System.Drawing.Point(78, 9)
    $filter.Width = 300

    $newButton = {
        param([string]$Text, [int]$X, [int]$Width)
        $b = New-Object System.Windows.Forms.Button
        $b.Text = $Text
        $b.Location = New-Object System.Drawing.Point($X, 8)
        $b.Width = $Width
        $b
    }
    $selectAll = & $newButton 'Select all shown'   392 115
    $clearAll  = & $newButton 'Clear selection'    513 110
    $deleteBtn = & $newButton 'Delete selected...' 631 160
    $deleteBtn.Enabled = $false

    $top.Controls.AddRange(@($label, $filter, $selectAll, $clearAll, $deleteBtn))

    $status = New-Object System.Windows.Forms.Label
    $status.Dock = 'Bottom'
    $status.Height = 22
    $status.TextAlign = 'MiddleLeft'

    $grid = New-Object System.Windows.Forms.DataGridView
    $grid.Dock                       = 'Fill'
    $grid.ReadOnly                   = $true
    $grid.AllowUserToAddRows         = $false
    $grid.AllowUserToDeleteRows      = $false
    $grid.AllowUserToResizeRows      = $false
    $grid.RowHeadersVisible          = $false
    $grid.SelectionMode              = 'FullRowSelect'
    $grid.MultiSelect                = $true
    $grid.AutoSizeColumnsMode        = 'None'
    $grid.BackgroundColor            = [System.Drawing.SystemColors]::Window
    $grid.BorderStyle                = 'None'
    $grid.EnableHeadersVisualStyles  = $true
    $grid.AlternatingRowsDefaultCellStyle.BackColor = [System.Drawing.Color]::FromArgb(246, 248, 250)

    $grid.Add_CellFormatting({
        param($sender, $e)
        $col = $grid.Columns[$e.ColumnIndex].Name
        if (($col -eq 'Size' -or $col -eq 'Other') -and $e.Value -isnot [System.DBNull]) {
            $e.Value = (Format-Bytes ([long]$e.Value) 0)
            $e.FormattingApplied = $true
        }
    })

    $grid.Add_CellPainting({
        param($sender, $e)
        if ($e.RowIndex -lt 0 -or $e.ColumnIndex -lt 0) { return }
        if ($grid.Columns[$e.ColumnIndex].Name -ne 'Percent') { return }
        $raw = $grid.Rows[$e.RowIndex].Cells[$e.ColumnIndex].Value
        if ($raw -is [System.DBNull]) { return }

        $e.PaintBackground($e.CellBounds, $true)
        $value = [double]$raw
        $span  = $e.CellBounds.Width - 8
        $width = [int]($span * [Math]::Min([Math]::Max($value, 0), 100) / 100)
        if ($width -gt 0) {
            $rect  = New-Object System.Drawing.Rectangle(
                ($e.CellBounds.X + 4), ($e.CellBounds.Y + 4), $width, ($e.CellBounds.Height - 8))
            $brush = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(150, 70, 130, 180))
            $e.Graphics.FillRectangle($brush, $rect)
            $brush.Dispose()
        }
        [System.Windows.Forms.TextRenderer]::DrawText(
            $e.Graphics, ('{0:N2}%' -f $value), $e.CellStyle.Font, $e.CellBounds,
            [System.Drawing.SystemColors]::ControlText,
            ([System.Windows.Forms.TextFormatFlags]::Right -bor
             [System.Windows.Forms.TextFormatFlags]::VerticalCenter))
        $e.Handled = $true
    })

    $selectedRowsAll = {
        $out = New-Object System.Collections.Generic.List[object]
        foreach ($dr in $table.Rows) {
            if ($dr.RowState -ne 'Deleted' -and $selectedKeys.Contains([int]$dr['Key'])) {
                $out.Add($dr)
            }
        }
        , $out
    }

    $syncSelection = {
        foreach ($gridRow in $grid.Rows) {
            $want = $selectedKeys.Contains([int]$gridRow.Cells['Key'].Value)
            if ($gridRow.Selected -ne $want) { $gridRow.Selected = $want }
        }
    }

    $updateStatus = {
        $shown = $table.DefaultView.Count
        $sum = 0L
        foreach ($v in $table.DefaultView) { $sum += [long]$v['Size'] }

        $chosen = & $selectedRowsAll
        $chosenSum = 0L
        foreach ($dr in $chosen) { $chosenSum += [long]$dr['Size'] }

        $status.Text = '  {0:N0} of {1:N0} rows    {2} in view' -f `
            $shown, $table.Rows.Count, (Format-Bytes $sum 0)
        if ($chosen.Count -gt 0) {
            $status.Text += '        {0:N0} selected, {1}' -f $chosen.Count, (Format-Bytes $chosenSum 0)
            $deleteBtn.Text = 'Delete selected ({0})...' -f $chosen.Count
            $deleteBtn.Enabled = $true
        }
        else {
            $deleteBtn.Text = 'Delete selected...'
            $deleteBtn.Enabled = $false
        }
    }

    $grid.Add_CellMouseClick({
        param($sender, $e)
        if ($e.Button -ne [System.Windows.Forms.MouseButtons]::Left) { return }
        if ($e.RowIndex -lt 0) { return }

        if ([System.Windows.Forms.Control]::ModifierKeys -band [System.Windows.Forms.Keys]::Shift) {
            foreach ($gridRow in $grid.SelectedRows) {
                [void]$selectedKeys.Add([int]$gridRow.Cells['Key'].Value)
            }
        }
        else {
            $key = [int]$grid.Rows[$e.RowIndex].Cells['Key'].Value
            if ($selectedKeys.Contains($key)) { [void]$selectedKeys.Remove($key) }
            else                              { [void]$selectedKeys.Add($key) }
        }
        & $syncSelection
        & $updateStatus
    })

    $grid.Add_Sorted({ & $syncSelection })

    $selectAll.Add_Click({
        foreach ($v in $table.DefaultView) { [void]$selectedKeys.Add([int]$v['Key']) }
        & $syncSelection; & $updateStatus
    })
    $clearAll.Add_Click({
        $selectedKeys.Clear()
        $grid.ClearSelection()
        & $updateStatus
    })

    $deleteRows = {
        param($targetRows)

        if ($null -eq $targetRows -or $targetRows.Count -eq 0) { return }

        $deletable = New-Object System.Collections.Generic.List[object]
        $blocked   = New-Object System.Collections.Generic.List[string]
        foreach ($dr in $targetRows) {
            $path = [string]$dr['Path']
            $reason = Test-DeletableUsagePath -Path $path
            if ($reason) { $blocked.Add(('{0}  ({1})' -f $path, $reason)) } else { $deletable.Add($dr) }
        }

        if ($blocked.Count -gt 0) {
            $blockedText = if ($blocked.Count -eq 1) { 'This entry is not safe to delete:' }
                           else { 'These {0} entries will be skipped, they are not safe to delete:' -f $blocked.Count }
            [void][System.Windows.Forms.MessageBox]::Show($form,
                $blockedText + $NL + $NL +
                (($blocked | Select-Object -First 15) -join $NL),
                'Skipped',
                [System.Windows.Forms.MessageBoxButtons]::OK,
                [System.Windows.Forms.MessageBoxIcon]::Information)
        }
        if ($deletable.Count -eq 0) { return }

        $sizeLabel = if ($UseAllocated) { 'Size on disk' } else { 'Size' }
        $total = 0L
        foreach ($dr in $deletable) { $total += [long]$dr['Size'] }

        $verb = if ($UseRecycleBin) { 'Send' } else { 'PERMANENTLY delete' }
        if ($deletable.Count -eq 1) {
            $question = ('{0} this file?' -f $verb) + $NL +
                        $NL + '    ' + [string]$deletable[0]['Path'] +
                        $NL + $NL +
                        ('{0}: {1}' -f $sizeLabel, (Format-Bytes $total 0))
        }
        else {
            $ordered = @($deletable | Sort-Object -Property @{ Expression = { [long]$_['Size'] } } -Descending)
            $preview = @($ordered | Select-Object -First 15 | ForEach-Object { '    ' + [string]$_['Path'] })
            if ($ordered.Count -gt 15) { $preview += ('    ... and {0} more' -f ($ordered.Count - 15)) }
            $question = ('{0} {1} files?' -f $verb, $deletable.Count) + $NL +
                        ('Total {0}: {1}' -f $sizeLabel.ToLower(), (Format-Bytes $total 0)) +
                        $NL + $NL +
                        ($preview -join $NL)
        }

        $tail = if ($UseRecycleBin) {
            'These go to the Recycle Bin, so they can be restored, but the space is NOT ' +
            'reclaimed until the bin is emptied. Anything too large to recycle will prompt separately.'
        } else {
            'This does NOT use the Recycle Bin. The space is reclaimed immediately and these ' +
            'files CANNOT be recovered.'
        }

        $answer = [System.Windows.Forms.MessageBox]::Show($form,
            $question + $NL + $NL + $tail,
            $(if ($UseRecycleBin) { 'Confirm delete' } else { 'Confirm permanent delete' }),
            [System.Windows.Forms.MessageBoxButtons]::YesNo,
            [System.Windows.Forms.MessageBoxIcon]::Warning,
            [System.Windows.Forms.MessageBoxDefaultButton]::Button2)
        if ($answer -ne [System.Windows.Forms.DialogResult]::Yes) { return }

        $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor
        try {
            $outcome = Remove-UsageFiles -Recycle:$UseRecycleBin `
                -Paths @($deletable | ForEach-Object { [string]$_['Path'] })
        }
        finally {
            $form.Cursor = [System.Windows.Forms.Cursors]::Default
        }

        $goneSet = New-Object System.Collections.Generic.HashSet[string] `
            ([System.StringComparer]::OrdinalIgnoreCase)
        foreach ($p in $outcome.Deleted) { [void]$goneSet.Add($p) }

        $freed = 0L
        $gone = New-Object System.Collections.Generic.List[object]
        foreach ($dr in $deletable) {
            if ($goneSet.Contains([string]$dr['Path'])) {
                $freed += [long]$(if ($UseAllocated) { $dr['Size'] } else { $dr['Other'] })
                $gone.Add($dr)
            }
        }
        foreach ($dr in $gone) {
            [void]$selectedKeys.Remove([int]$dr['Key'])
            $table.Rows.Remove($dr)
        }
        $grid.Refresh(); & $updateStatus

        $failures = $outcome.Failed
        $summary = if ($UseRecycleBin) {
            'Recycled {0} file(s), {1}. Empty the Recycle Bin to actually reclaim it.' -f `
                $outcome.Deleted.Count, (Format-Bytes $freed 0)
        } else {
            'Deleted {0} file(s), {1} reclaimed.' -f $outcome.Deleted.Count, (Format-Bytes $freed 0)
        }
        if ($failures.Count -gt 0) {
            $summary += $NL + $NL +
                        ('{0} could not be deleted:' -f $failures.Count) + $NL +
                        (($failures | Select-Object -First 15) -join $NL)
        }
        [void][System.Windows.Forms.MessageBox]::Show($form, $summary, 'Delete complete',
            [System.Windows.Forms.MessageBoxButtons]::OK,
            $(if ($failures.Count -gt 0) { [System.Windows.Forms.MessageBoxIcon]::Warning }
              else { [System.Windows.Forms.MessageBoxIcon]::Information }))
    }

    $deleteSelected = { & $deleteRows (& $selectedRowsAll) }
    $deleteBtn.Add_Click($deleteSelected)

    $filter.Add_TextChanged({
        $text = $filter.Text
        if ([string]::IsNullOrWhiteSpace($text)) {
            $table.DefaultView.RowFilter = ''
        }
        else {
            $escaped = [regex]::Replace($text, '[\[\]%*]', { param($m) '[' + $m.Value + ']' })
            $escaped = $escaped.Replace("'", "''")
            $table.DefaultView.RowFilter = "Path LIKE '%$escaped%'"
        }
        & $syncSelection
        & $updateStatus
    })

    $openRowInExplorer = {
        param($gridRow)
        if (-not $gridRow) { return }
        $path = [string]$gridRow.Cells['Path'].Value
        if (-not (Test-Path -LiteralPath $path)) { return }
        if ([string]$gridRow.Cells['Type'].Value -eq 'File') {
            Start-Process explorer.exe -ArgumentList ('/select,"{0}"' -f $path)
        } else {
            Start-Process explorer.exe -ArgumentList ('"{0}"' -f $path)
        }
    }

    $menu = New-Object System.Windows.Forms.ContextMenuStrip
    $copyItem = $menu.Items.Add('Copy path')
    $copyItem.Add_Click({
        $paths = @($grid.SelectedRows | ForEach-Object { $_.Cells['Path'].Value })
        if ($paths.Count -gt 0) { $paths -join $NL | Set-Clipboard }
    })
    $openItem = $menu.Items.Add('Open in Explorer')
    $openItem.Add_Click({
        & $openRowInExplorer ($grid.SelectedRows | Select-Object -First 1)
    })

    [void]$menu.Items.Add((New-Object System.Windows.Forms.ToolStripSeparator))
    $deleteSelItem = $menu.Items.Add('Delete file...')
    $deleteSelItem.Add_Click($deleteSelected)

    $menu.Add_Opening({
        $count = (& $selectedRowsAll).Count
        if ($count -le 1) { $deleteSelItem.Text = 'Delete file...' }
        else              { $deleteSelItem.Text = 'Delete selected ({0})...' -f $count }
        $deleteSelItem.Enabled = ($count -ge 1)
    })

    $grid.ContextMenuStrip = $menu

    $grid.Add_CellMouseDown({
        param($sender, $e)
        if ($e.Button -ne [System.Windows.Forms.MouseButtons]::Right) { return }
        if ($e.RowIndex -lt 0) { return }

        $key = [int]$grid.Rows[$e.RowIndex].Cells['Key'].Value
        if (-not $selectedKeys.Contains($key)) {
            $selectedKeys.Clear()
            [void]$selectedKeys.Add($key)
            & $syncSelection
            & $updateStatus
        }
    })
    $grid.Add_CellDoubleClick({
        param($sender, $e)

        if ($e.RowIndex -lt 0) { return }
        & $openRowInExplorer $grid.Rows[$e.RowIndex]
    })

    $form.Controls.AddRange(@($grid, $status, $top))

    $grid.DataSource = $table.DefaultView

    $grid.Columns['Type'].Visible       = $false
    $grid.Columns['Key'].Visible        = $false
    $grid.Columns['Size'].HeaderText    = $activeName
    $grid.Columns['Size'].Width         = 110
    $grid.Columns['Size'].DefaultCellStyle.Alignment = 'MiddleRight'
    $grid.Columns['Percent'].HeaderText = '% of total'
    $grid.Columns['Percent'].Width      = 110
    $grid.Columns['Other'].HeaderText   = $otherName
    $grid.Columns['Other'].Width        = 110
    $grid.Columns['Other'].DefaultCellStyle.Alignment = 'MiddleRight'
    $grid.Columns['Path'].HeaderText    = 'Path'
    $grid.Columns['Path'].Width         = 760

    $form.Add_Shown({ $form.Activate(); $grid.ClearSelection(); & $updateStatus })
    [void]$form.ShowDialog()
    $form.Dispose()
}

function Write-Table {
    param(
        [string]$Title,
        [object[]]$Rows,
        [long]$Reference
    )

    if ($Quiet) { return }
    Write-Host ''
    Write-Host $Title -ForegroundColor Yellow
    Write-Host ('-' * $Script:BannerWidth) -ForegroundColor DarkGray

    if (-not $Rows -or $Rows.Count -eq 0) {
        Write-Host '  (nothing to report)' -ForegroundColor DarkGray
        return
    }

    $rank = 0
    foreach ($row in $Rows) {
        $rank++
        $pct = if ($Reference -gt 0) { '{0,6:N2}%' -f (100 * $row.Size / $Reference) } else { '       ' }
        $line = '{0,4}  {1}  {2}  {3}' -f $rank, (Format-Bytes $row.Size), $pct, $row.Path
        Write-Host $line
    }
}

$exitCode = 0
$volumeHandle = $null

try {
    $target = Resolve-ScanTarget -InputPath $Path

    if ($Select -and ($Delete -or $DeleteListPath)) {
        Write-Fatal '-Select and -Delete/-DeleteListPath are different selection modes; use one.' 3
    }
    if ($Select -and $Gui)     { Write-Fatal '-Select is the console picker and -Gui is the window; use one.' 3 }
    if ($Select -and $Quiet)   { Write-Fatal '-Select needs the numbered table on screen, so it cannot be combined with -Quiet.' 3 }
    if ($Select -and $NoSelect) { Write-Fatal '-Select and -NoSelect contradict each other.' 3 }
    if ($Commit -and -not ($Select -or $Delete -or $DeleteListPath)) {
        Write-Fatal '-Commit only means something with -Select or -Delete. Nothing would be deleted, so refusing rather than pretending.' 3
    }

    if (-not $ReceiptPath) {
        $ReceiptPath = Join-Path $env:ProgramData ('Sector\DiskUsage\delete-{0}.log' -f `
            (Get-Date).ToUniversalTime().ToString('yyyyMMdd-HHmmss'))
    }
    $maxDeleteBytes = [long]$MaxDeleteMB * 1MB

    $explicit = New-Object System.Collections.Generic.List[string]
    if ($Delete) { foreach ($d in $Delete) { if ($d -and $d.Trim()) { $explicit.Add($d.Trim()) } } }
    if ($DeleteListPath) {
        if (-not (Test-Path -LiteralPath $DeleteListPath -PathType Leaf)) {
            Write-Fatal "-DeleteListPath '$DeleteListPath' does not exist." 3
        }

        foreach ($line in (Get-Content -LiteralPath $DeleteListPath)) {
            $t = $line.Trim()
            if (-not $t -or $t.StartsWith('#')) { continue }
            $explicit.Add($t)
        }
    }

    if ($explicit.Count -gt 0) {
        if (-not $DeleteScope -or $DeleteScope.Count -eq 0) {
            Write-Fatal ("Deleting requires -DeleteScope naming the directory tree you are willing " +
                         "to clean. Nothing is deleted outside it.") 3
        }
        Write-Banner ("Delete {0} explicit path(s)  (no scan)" -f $explicit.Count)

        $cands = @($explicit | ForEach-Object { [pscustomobject]@{ Path = $_; Record = -1 } })
        $exitCode = Invoke-HeadlessDelete -Candidates $cands -Scopes $DeleteScope `
            -DoCommit ([bool]$Commit) -UseRecycle ([bool]$Recycle) `
            -MaxFiles $MaxDeleteFiles -MaxBytes $maxDeleteBytes -Receipt $ReceiptPath
        exit $exitCode
    }

    Write-Banner "Disk usage on $($target.Root)  (raw NTFS master file table scan)"

    if (-not (Test-Elevated)) {
        Write-Fatal ("Administrator rights are required. Reading the master file table means " +
                     "opening the raw volume \\.\$($target.Letter): which is an administrative " +
                     "operation. Re-run this script from an elevated PowerShell.") 2
    }

    $volume = Get-Volume -DriveLetter $target.Letter -ErrorAction SilentlyContinue -Verbose:$false
    if (-not $volume) {
        Write-Fatal "No volume is mounted on $($target.Letter):." 3
    }
    if ($volume.FileSystem -ne 'NTFS') {
        $fs = if ($volume.FileSystem) { $volume.FileSystem } else { 'unknown or no media' }
        Write-Fatal ("$($target.Letter): is $fs, not NTFS. This script reads the NTFS master file " +
                     "table directly and has no equivalent for ReFS, FAT32, exFAT or network shares.") 4
    }

    Write-Log ("Volume {0}  {1}  {2} total, {3} free" -f `
        $target.Root, $volume.FileSystem,
        (Format-Bytes $volume.Size 0), (Format-Bytes $volume.SizeRemaining 0))

    $win32 = 0
    $volumeHandle = [SectorDiskUsage.MftScanner]::OpenVolume($target.Letter[0], [ref]$win32)
    if (-not $volumeHandle) {
        $hint = switch ($win32) {
            5  { 'Access denied. Run from an elevated PowerShell.' }
            32 { 'The volume is locked by another process.' }
            default { "Win32 error $win32." }
        }
        Write-Fatal "Could not open \\.\$($target.Letter): for raw reading. $hint" 2
    }

    Write-Log 'Reading the master file table...'
    $swTotal = [System.Diagnostics.Stopwatch]::StartNew()

    try {
        $scan = [SectorDiskUsage.MftScanner]::Scan(
            $volumeHandle,
            $target.Letter[0],
            ($BufferMB * 1MB),
            (-not $ExcludeAds),
            (-not $ExcludeMetafiles))
    }
    catch {
        $msg = $_.Exception.Message
        if ($msg -match 'Win32 error (\d+)') {
            $code = [int]$Matches[1]
            $hint = switch ($code) {
                50 {
                    "The volume opened, but raw sector reads are being refused (ERROR_NOT_SUPPORTED). " +
                    "This is characteristic of an endpoint security filter driver intercepting direct " +
                    "disk access. Check for Check Point, CrowdStrike, SentinelOne or similar, and add " +
                    "an exclusion for the PowerShell host if you want this script to run here. " +
                    "Run 'fltmc filters' to list the filter drivers on this machine."
                }
                5 {
                    "Access denied on the sector read even though the volume opened. Confirm the " +
                    "session is elevated and that no filter driver is blocking direct disk access."
                }
                87 {
                    "The read was rejected as an invalid parameter, which on a volume handle means a " +
                    "sector alignment problem. Report this as a bug with the volume's sector size."
                }
                default { "Win32 error $code." }
            }
            Write-Host "[X] Could not read the master file table on $($target.Root)" -ForegroundColor Red
            Write-Host "    $msg" -ForegroundColor Red
            Write-Host "    $hint" -ForegroundColor Yellow
            exit 2
        }
        throw
    }

    $swTotal.Stop()

    $g = $scan.Geometry
    Write-Verbose ("Geometry: {0} bytes/sector, {1} sectors/cluster, {2} byte cluster, {3} byte record, `$MFT at LCN {4}" -f `
        $g.BytesPerSector, $g.SectorsPerCluster, $g.ClusterSize, $g.RecordSize, $g.MftStartLcn)
    Write-Verbose ("`$MFT self check passed: {0} fragments describing {1} bytes, matching the `$DATA header." -f `
        $scan.MftFragments, $scan.MftExtentBytes)

    Write-Log ("Scanned {0:N0} records ({1} files, {2} folders) in {3:N2} s" -f `
        $scan.RecordCount, $scan.FileCount, $scan.DirCount, $swTotal.Elapsed.TotalSeconds) 'SUCCESS'

    $inScope = $null
    $anchor  = [SectorDiskUsage.MftScanner]::ROOT_RECORD
    $scopeLabel = $target.Root

    $volumeSerialErr = 0
    $volumeSerial = [uint32]0
    $null = [SectorDiskUsage.MftScanner]::GetRecordNumberForPath(
        $target.Root, [ref]$volumeSerialErr, [ref]$volumeSerial)

    if (-not $target.IsRoot) {
        $err = 0
        $pathSerial = [uint32]0
        $rec = [SectorDiskUsage.MftScanner]::GetRecordNumberForPath($target.FullPath, [ref]$err, [ref]$pathSerial)
        if ($rec -lt 0) {
            Write-Fatal "Could not resolve '$($target.FullPath)' to an MFT record (Win32 error $err)." 3
        }
        if ($rec -ge $scan.RecordCount) {
            Write-Fatal "'$($target.FullPath)' resolved to MFT record $rec, which is outside this volume's MFT." 3
        }

        $rootErr = 0
        $rootSerial = [uint32]0
        $rootRec = [SectorDiskUsage.MftScanner]::GetRecordNumberForPath($target.Root, [ref]$rootErr, [ref]$rootSerial)
        if ($rootRec -lt 0) {
            $msg = "Could not verify that '{0}' is on {1} (Win32 error {2} opening the root). Refusing to anchor on an unverified MFT record."
            Write-Fatal ($msg -f $target.FullPath, $target.Root, $rootErr) 3
        }
        if ($rootSerial -ne $pathSerial) {
            $msg = "'{0}' resolves onto a different volume than {1} via a mount point or junction. Scan that volume directly instead."
            Write-Fatal ($msg -f $target.FullPath, $target.Root) 3
        }
        $anchor = [int]$rec
        $inScope = [SectorDiskUsage.MftScanner]::MarkSubtree($scan, $anchor)
        $scopeLabel = $target.FullPath
        Write-Log ("Filtered to '{0}' (MFT record {1})" -f $scopeLabel, $anchor)
    }

    $excludeRules = New-Object System.Collections.Generic.List[string]
    if (-not $NoDefaultExclude) {
        foreach ($e in $Script:DefaultExcludes) { $excludeRules.Add($target.Root + $e) }
    }
    $firstUserRule = $excludeRules.Count
    foreach ($e in $Exclude) {
        if ([string]::IsNullOrWhiteSpace($e)) { continue }
        $rule = $e.Trim()

        if (($rule.TrimEnd('\') + '\').ToUpperInvariant() -eq $target.Root) {
            Write-Fatal "-Exclude '$rule' is the volume root, which would hide the entire report." 3
        }
        $excludeRules.Add($rule)
    }

    $excluded = $null
    $exResult = $null
    if ($excludeRules.Count -gt 0) {
        $exResult = [SectorDiskUsage.MftScanner]::MarkExcluded($scan, $excludeRules.ToArray(), $anchor)
        $excluded = $exResult.Mask
        $inScope  = [SectorDiskUsage.MftScanner]::CombineScope($inScope, $excluded, $scan.RecordCount)

        if ($exResult.Suppressed -gt 0) {
            Write-Log ("{0} exclusion rule(s) cover the scan target itself and were ignored; its contents are listed" -f `
                $exResult.Suppressed)
        }

        for ($i = $firstUserRule; $i -lt $excludeRules.Count; $i++) {
            if ($exResult.RuleRoots[$i] -eq 0) {
                Write-Log ("-Exclude '{0}' matched nothing on {1}" -f $excludeRules[$i], $target.Root) 'WARN'
            }
        }
    }

    $useAlloc  = -not $LogicalSize
    $metricName = if ($useAlloc) { 'size on disk' } else { 'logical size' }

    $scopeTotal = if ($useAlloc) { $scan.SubtreeAlloc[$anchor] } else { $scan.Subtree[$anchor] }

    if (-not $Quiet) {
        Write-Host ''
        Write-Host "Scan summary  (reporting $metricName)" -ForegroundColor Yellow
        Write-Host ('-' * $Script:BannerWidth) -ForegroundColor DarkGray

        $mftPct = if ($volume.Size -gt 0) { 100 * $scan.MftExtentBytes / $volume.Size } else { 0 }
        $fps = if ($swTotal.Elapsed.TotalSeconds -gt 0) {
                   ($scan.FileCount + $scan.DirCount) / $swTotal.Elapsed.TotalSeconds
               } else { 0 }

        '{0,-26}: {1}'      -f 'Volume',            $target.Root                              | Write-Host
        '{0,-26}: {1}'      -f 'Cluster size',      (Format-Bytes $g.ClusterSize 0)           | Write-Host
        '{0,-26}: {1:N0} ({2} in {3:N0} fragments, {4:N2}% of the volume)' -f `
            'MFT records', $scan.RecordCount, (Format-Bytes $scan.MftExtentBytes 0), $scan.MftFragments, $mftPct | Write-Host
        '{0,-26}: {1:N0} files, {2:N0} folders' -f 'In use', $scan.FileCount, $scan.DirCount  | Write-Host
        '{0,-26}: {1:N2} s read, {2:N2} s parse, {3:N2} s aggregate ({4:N0} entries/s)' -f `
            'Timing', $scan.ReadSeconds, $scan.ParseSeconds, $scan.AggregateSeconds, $fps     | Write-Host
        '{0,-26}: {1}'      -f 'MFT bytes read',    (Format-Bytes $scan.MftBytesRead 0)       | Write-Host

        if ($scan.TornRecords -gt 0) {
            Write-Host ('{0,-26}: {1:N0} (torn mid-write during the scan, skipped)' -f `
                'Records skipped', $scan.TornRecords) -ForegroundColor Yellow
        }
        if ($scan.OrphanCount -gt 0) {
            $orphanBytes = if ($useAlloc) { $scan.OrphanAlloc } else { $scan.OrphanSize }
            Write-Host ('{0,-26}: {1:N0} entries, {2} <orphaned, parent unreachable>' -f `
                'Orphaned', $scan.OrphanCount, (Format-Bytes $orphanBytes 0)) -ForegroundColor Yellow
        }
        if ($scan.AdsCount -gt 0) {
            $adsNote = if ($ExcludeAds) { 'excluded from the totals' } else { 'counted in the totals' }
            '{0,-26}: {1:N0} files ({2})' -f 'Alternate data streams', $scan.AdsCount, $adsNote | Write-Host
        }
        if ($scan.SparseCount -gt 0) {
            '{0,-26}: {1:N0} files' -f 'Sparse or compressed', $scan.SparseCount | Write-Host
        }
        if ($scan.AttrListCount -gt 0) {
            '{0,-26}: {1:N0} files across {2:N0} extension records' -f `
                'Spilled attributes', $scan.AttrListCount, $scan.ExtensionRecords | Write-Host
        }

        Write-Host ''
        '{0,-26}: {1}' -f "Total under $scopeLabel", (Format-Bytes $scopeTotal 0) | Write-Host

        if ($target.IsRoot) {
            $used = $volume.Size - $volume.SizeRemaining
            $delta = $scopeTotal - $used
            $deltaPct = if ($used -gt 0) { 100 * [Math]::Abs($delta) / $used } else { 0 }
            '{0,-26}: {1}' -f 'Volume reports used', (Format-Bytes $used 0) | Write-Host
            $col = if ($deltaPct -le 5) { 'Green' } elseif ($deltaPct -le 15) { 'Yellow' } else { 'Red' }
            Write-Host ('{0,-26}: {1} ({2:N2}%)' -f 'Difference', (Format-Bytes $delta 0), $deltaPct) -ForegroundColor $col

            if (-not $useAlloc -and $scopeTotal -gt $used) {
                Write-Host ('                            logical totals exceed the volume when sparse files are ' +
                            'present ({0:N0} here)' -f $scan.SparseCount) -ForegroundColor DarkGray
                Write-Host '                            drop -LogicalSize for the real on-disk footprint' -ForegroundColor DarkGray
            }
        }

        if ($exResult -and ($exResult.Files + $exResult.Dirs) -gt 0) {
            $exBytes = if ($useAlloc) { $exResult.Alloc } else { $exResult.Size }
            Write-Host ('{0,-26}: {1:N0} files, {2} (counted in the total above, not listed)' -f `
                'Hidden by -Exclude', $exResult.Files, (Format-Bytes $exBytes 0)) -ForegroundColor DarkGray

            $ruleRows = New-Object System.Collections.Generic.List[psobject]
            for ($i = 0; $i -lt $exResult.Rules.Length; $i++) {
                $b = if ($useAlloc) { $exResult.RuleAlloc[$i] } else { $exResult.RuleSize[$i] }
                if ($b -gt 0) {
                    $ruleRows.Add([pscustomobject]@{ Rule = $exResult.Rules[$i]; Bytes = [long]$b })
                }
            }

            $sortedRules = @($ruleRows.ToArray() | Sort-Object -Property Bytes -Descending)
            $ruleLimit = if ($ShowExcluded) { $sortedRules.Count } else { 6 }
            $shownRules = 0
            foreach ($rr in $sortedRules) {
                if ($shownRules -ge $ruleLimit) { break }
                Write-Host ('  {0}  {1}' -f (Format-Bytes $rr.Bytes), $rr.Rule) -ForegroundColor DarkGray
                $shownRules++
            }
            if ($sortedRules.Count -gt $shownRules) {
                Write-Host ('  ...and {0} more rule(s); -ShowExcluded lists every one' -f `
                    ($sortedRules.Count - $shownRules)) -ForegroundColor DarkGray
            }
        }
    }

    $fileIdx   = [SectorDiskUsage.MftScanner]::TopIndices($scan, $inScope, $false, $useAlloc, $Top, -1)
    $filePaths = [SectorDiskUsage.MftScanner]::BuildPaths($scan, $fileIdx)

    $fileRows = New-Object System.Collections.Generic.List[psobject]
    for ($i = 0; $i -lt $fileIdx.Count; $i++) {
        $k = $fileIdx[$i]
        $fileRows.Add([pscustomobject]@{
            Type      = 'File'
            Path      = $filePaths[$i]
            Size      = if ($useAlloc) { $scan.Alloc[$k] } else { $scan.Size[$k] }
            Logical   = $scan.Size[$k]
            Allocated = $scan.Alloc[$k]
            Depth     = $scan.Depth[$k]
            Record    = $k
        })
    }

    Write-Table -Title "Largest files under $scopeLabel ($metricName)" -Rows $fileRows -Reference $scopeTotal

    $wantPicker = $false
    if (-not $NoSelect -and -not $Gui -and -not $Quiet -and -not $PassThru) {
        if ($Select) { $wantPicker = $true }
        elseif (-not [Console]::IsInputRedirected) { $wantPicker = $true }
    }

    if ($wantPicker) {
        $scopes = $DeleteScope
        if (-not $scopes -or $scopes.Count -eq 0) { $scopes = @($target.FullPath) }

        if ($fileRows.Count -gt 0) {
            Write-Host ''
            Write-Host ("Delete any of these? Enter numbers from the left column, for example " +
                        "1,2,3,7 or 1,3,5-8. Press Enter alone to skip.") -ForegroundColor Yellow
            Write-Host -NoNewline 'Files to delete: ' -ForegroundColor Yellow

            $spec = Read-TimedLine -TimeoutSeconds $SelectTimeoutSeconds

            if ($null -eq $spec) {
                Write-Host ''
                $msg = ("[i] No input within {0}s, nothing deleted. For unattended runs use " +
                        "-DeleteListPath or -NoSelect.")
                Write-Host ($msg -f $SelectTimeoutSeconds) -ForegroundColor DarkGray
                $exitCode = 7
            }
            elseif (-not $spec.Trim()) {
                Write-Host '[i] Nothing selected.' -ForegroundColor DarkGray
            }
            else {
                $picked = Expand-SelectionSpec -Spec $spec -Max $fileRows.Count
                if ($null -eq $picked) {
                    $msg = ("[!] Could not read '{0}'. Use numbers and ranges between 1 and {1}, " +
                            "for example 1,2,3,7. Nothing deleted.")
                    Write-Host ($msg -f $spec.Trim(), $fileRows.Count) -ForegroundColor Yellow
                    $exitCode = 3
                }
                else {
                    Write-Host ''
                    foreach ($n in $picked) {
                        $row = $fileRows[$n - 1]
                        Write-Host ('  {0,4}  {1}  {2}' -f $n, (Format-Bytes $row.Allocated), $row.Path)
                    }
                    $cands = @($picked | ForEach-Object {
                        $row = $fileRows[$_ - 1]

                        [pscustomobject]@{
                            Path   = $row.Path
                            Bytes  = [long]$row.Allocated
                            Record = [long]$row.Record
                            Serial = $volumeSerial
                        }
                    })

                    $exitCode = Invoke-HeadlessDelete -Candidates $cands -Scopes $scopes `
                        -DoCommit ([bool]$Commit) -UseRecycle ([bool]$Recycle) `
                        -MaxFiles $MaxDeleteFiles -MaxBytes $maxDeleteBytes -Receipt $ReceiptPath `
                        -InteractiveConfirm $true -ConfirmTimeoutSeconds $SelectTimeoutSeconds
                }
            }
        }
    }

    if ($Depth -gt 0) {
        $nodes = [SectorDiskUsage.MftScanner]::BuildTree($scan, $anchor, $Depth, $Top, $useAlloc, $excluded)
        if (-not $Quiet) {
            Write-Host ''
            Write-Host "Tree from $scopeLabel, $Depth level(s) deep, top $Top per level" -ForegroundColor Yellow
            Write-Host ('-' * $Script:BannerWidth) -ForegroundColor DarkGray
            foreach ($node in $nodes) {
                $k = $node.Index
                $sz = if ($useAlloc) { $scan.SubtreeAlloc[$k] } else { $scan.Subtree[$k] }
                $label = if ($node.RelDepth -eq 0) {
                    [SectorDiskUsage.MftScanner]::BuildPath($scan, $k)
                } else {
                    ('  ' * ($node.RelDepth - 1)) + '+- ' + $scan.Name[$k]
                }
                Write-Host ('{0}  {1}' -f (Format-Bytes $sz), $label)
            }
        }
    }

    if ($CsvPath) {
        $allIdx = [SectorDiskUsage.MftScanner]::AllIndices($scan, $inScope, $false, $useAlloc)
        $allPaths = [SectorDiskUsage.MftScanner]::BuildPaths($scan, $allIdx)
        $export = New-Object System.Collections.Generic.List[psobject]
        for ($i = 0; $i -lt $allIdx.Count; $i++) {
            $k = $allIdx[$i]
            $export.Add([pscustomobject]@{
                Path      = $allPaths[$i]
                Logical   = $scan.Size[$k]
                Allocated = $scan.Alloc[$k]
                Depth     = $scan.Depth[$k]
            })
        }
        $export | Export-Csv -LiteralPath $CsvPath -NoTypeInformation -Encoding UTF8
        Write-Log ("Exported {0:N0} files to {1}" -f $export.Count, $CsvPath) 'SUCCESS'
    }

    if ($PassThru) {
        $fileRows
    }

    if ($Gui) {
        $gridRows = $fileRows.ToArray()
        if ($gridRows.Count -eq 0) {
            Write-Log 'Nothing to show in the grid.' 'WARN'
        }
        else {
            try {
                Show-UsageGrid -Rows $gridRows -Reference $scopeTotal -UseAllocated $useAlloc `
                    -UseRecycleBin ([bool]$Recycle) `
                    -Title ("Disk usage: {0}  (largest {1} files by {2})" -f $scopeLabel, $Top, $metricName)
            }
            catch {
                Write-Host "[!] Could not open the grid window: $($_.Exception.Message)" -ForegroundColor Yellow
                Write-Host '    -Gui needs an interactive desktop session.' -ForegroundColor Yellow
            }
        }
    }

    if (-not $Quiet) { Write-Host '' }
}
catch {
    Write-Host "[X] $($_.Exception.Message)" -ForegroundColor Red
    if ($_.ScriptStackTrace) { Write-Verbose $_.ScriptStackTrace }
    $exitCode = 1
}
finally {
    if ($volumeHandle) { $volumeHandle.Dispose() }
}

exit $exitCode

Usage:
.SYNOPSIS Fast-speed disk usage analysis by reading the raw NTFS Master File Table. .DESCRIPTION Reports the largest files on an NTFS volume in seconds instead of minutes, by size on disk. Instead of walking the directory tree with FindFirstFile/FindNextFile (which is what Get-ChildItem -Recurse does, at roughly 14,000 files per second), this script opens the raw volume, reads the $MFT in bulk, and parses every file record out of it. One sequential read of a couple of gigabytes replaces millions of per-file syscalls, which is the same technique WizTree uses and the only reason it is fast. Requires Administrator (raw volume access) and a local NTFS volume. Both conditions are checked up front and the script fails fast with a specific message and exit code rather than silently falling back to a slow directory walk. .PARAMETER Path Volume or subtree to report on. 'C:', 'C:\' or a subtree such as 'C:\Users\Hans'. A subtree still scans the whole volume (that is the fast part) and filters the result, so it is no slower than a full report. Defaults to the system drive. .PARAMETER Top Number of rows in the largest-files table. Default 25. .PARAMETER Depth Render an indented folder tree this many levels below the scan target. 0 (the default) skips the tree. Each level shows at most -Top children, largest first. This is the only place folder totals are shown; the main report is files only. .PARAMETER CsvPath Export every file in scope with a non-zero size (not just the top N) to this CSV file, sorted largest first. .PARAMETER LogicalSize Report logical file size (what Explorer calls "Size") instead of size on disk, which is the default. Logical size is what a file claims to be, so a sparse file counts its whole span even where nothing is stored: $BadClus alone then claims the entire volume, and OneDrive placeholders claim their full cloud size. Expect the total to exceed the size of the volume. .PARAMETER AllocatedSize Deprecated and ignored. Size on disk is now the default, so this switch does nothing. Kept only so existing command lines keep working. .PARAMETER ExcludeMetafiles Exclude NTFS metafiles (records 0 to 15: $MFT, $LogFile, $Secure and friends). They are included by default because they are genuinely consumed space, often several GB, and including them is what makes the grand total reconcile against Get-Volume. .PARAMETER ExcludeAds Exclude alternate data streams (named $DATA attributes). They are counted by default because they consume real space. The summary reports how many files carry them. .PARAMETER Gui Open the results in a sortable window instead of only printing them. Also accepts -Grid, the original name for this switch. Needs an interactive desktop session. Click any column to sort; sizes sort by real byte value while still displaying as "1,25 TB". There is a filter box, a percent-of-total bar, and a right-click Copy path / Open in Explorer. Clicking a row selects it, clicking again deselects it, so a batch needs no modifier keys. Shift-click adds a range. Selection is tracked per row independently of the grid, so it survives sorting and filtering and keeps counting while a filter hides part of it. Selected files can be deleted from the "Delete selected (N)" button or by right-clicking, which offers "Delete file" for one row and "Delete selected (N)" for several; the menu is relabelled as it opens so it always names what it will act on. Deletion is PERMANENT and skips the Recycle Bin, because the bin sits on the same volume, so recycling a 100 GB file moves it rather than reclaiming the space. -Recycle opts into the reversible behaviour. Either way it confirms first with the file count and total size, and refuses NTFS metafiles, root level Windows system files such as pagefile.sys, and orphaned entries with no real path. .PARAMETER Select Forces the delete prompt on. It is normally not needed: after printing the table the script ASKS by default which files to delete, so a plain run already offers it. Enter numbers from the left column, for example 1,2,3,7 or 1,3,5-8. Press Enter alone to skip. Picking files shows them back with their sizes and then asks for a typed 'yes' before anything is deleted. That confirmation is the arming step, so -Commit is not needed interactively; passing -Commit pre-arms it and skips the confirmation instead. The prompt is skipped automatically when there is nobody to ask: with -Quiet, -PassThru, -Gui, -NoSelect, or when stdin is redirected, which is how automation runs. Even then it cannot block: with no input within -SelectTimeoutSeconds it gives up with exit 7 rather than holding the console, which in an RMM session would burn the whole execution window. This works with no GUI at all, which is what makes it usable from an RMM background session (N-able Advanced Remote Background and similar), where no window can ever be shown. .PARAMETER NoSelect Never ask. Use for scripted runs that want the report only, and for any run where a stray prompt would be unwelcome. .PARAMETER Delete Explicit paths to delete, for unattended use. Needs no MFT scan at all, so it works in contexts where raw sector reads are refused, and needs no Administrator for ordinary files. Requires -DeleteScope. Nothing is deleted without -Commit. Beware how you invoke it: with "pwsh -File script.ps1 -Delete a,b,c" the whole thing arrives as ONE string, because -File does not parse array literals. For more than one path either call through -Command: pwsh -Command "& .\Get-DiskUsage.ps1 -Delete 'a','b' -DeleteScope 'C:\X' -Commit" or, better, use -DeleteListPath. .PARAMETER DeleteListPath A text file of paths to delete, one per line. Preferred over -Delete for anything beyond a single file: a newline cannot occur in a Windows filename, so unlike a comma or semicolon separated string it can never split a legitimate path by accident, and it sidesteps the command-line length limits RMM input fields impose. Blank lines and lines starting with # are ignored, so a reviewer can annotate the list or comment a path out rather than delete the line. Requires -DeleteScope. Nothing is deleted without -Commit. .PARAMETER Commit Arms deletion. Without it, -Select and -Delete produce the plan and a receipt but delete nothing, and exit 5 rather than 0 so a cleanup job that lost its -Commit is not mistaken for a success. Deliberately not called -Force: that reads as "suppress friction" and gets pasted reflexively. .PARAMETER DeleteScope One or more directory trees that deletion is confined to. Mandatory for -Delete and for -Select on a whole volume. Paths are canonicalised and each scope is treated as ending in a separator, so C:\Data does not authorise C:\Data2, and a junction anywhere above a target is refused so a link cannot redirect a delete out of the scope. .PARAMETER MaxDeleteFiles Refuse the whole plan if it names more than this many files. Default 50. This is the guard that turns a wrong scope or an unpruned list into a bounded no-op rather than a disaster. .PARAMETER MaxDeleteMB Refuse the whole plan if it totals more than this many MB. Default 102400 (100 GB). .PARAMETER ReceiptPath Where to write the audit receipt. Defaults to %ProgramData%\Sector\DiskUsage\delete-<utc>.log. Every planned, skipped, deleted and failed path is recorded with its size, so a ticket can reconstruct exactly what happened. Receipts are never pruned by this script. .PARAMETER SelectTimeoutSeconds How long -Select waits for input before concluding no operator is present. Default 120. .PARAMETER Recycle Send deletions to the Recycle Bin instead of deleting permanently. Reversible, but the space is not reclaimed until the bin is emptied, which is why it is not the default. .PARAMETER PassThru Emit the report rows as objects on the pipeline in addition to the console report. .PARAMETER Quiet Suppress all console output, including the results table. Combine with -PassThru or -CsvPath to get the results in machine-readable form; errors still print regardless. .PARAMETER BufferMB Size of the single reusable read buffer, in MB. Default 8. Rarely worth changing. .EXAMPLE .\Get-DiskUsage.ps1 The 25 largest files on the system drive by size on disk. .EXAMPLE .\Get-DiskUsage.ps1 -Gui -Top 100 The 100 largest files in a sortable window. .EXAMPLE .\Get-DiskUsage.ps1 -Path 'C:\Users' -Top 40 -Depth 2 The 40 largest files under C:\Users, plus a two-level folder tree. .EXAMPLE .\Get-DiskUsage.ps1 -CsvPath .\c-usage.csv Export every file on C: to CSV, sorted largest first. .EXAMPLE .\Get-DiskUsage.ps1 -Quiet -PassThru | Select-Object -First 5 Path, Size Machine-readable output for a pipeline or an RMM agent. .EXAMPLE .\Get-DiskUsage.ps1 -Path 'C:\Users\hans\Downloads' Lists the largest files, then asks which to delete. Type 1,2,3,7 and confirm with 'yes'. No GUI is involved, so this works in an RMM background session. .EXAMPLE .\Get-DiskUsage.ps1 -NoSelect Report only, never asks. .EXAMPLE .\Get-DiskUsage.ps1 -Delete 'C:\Temp\old.iso' -DeleteScope 'C:\Temp' -Commit Unattended delete of a known path. No scan, no prompt, no window; writes a receipt. .NOTES Requires Administrator. Local NTFS volumes only. Exit codes: 0 scan completed, or delete completed with no failures 1 unhandled error 2 not running as Administrator, the volume could not be opened, or the volume opened but raw sector reads were refused (typically an endpoint security filter driver) 3 bad input: path does not resolve, resolves onto a different volume through a mount point or junction, contradictory switches, or an unparseable -Select specification 4 volume is not NTFS 5 delete plan produced but NOT executed, because -Commit was absent (dry run) 6 nothing to delete, or the operator cancelled or declined the confirmation 7 no operator answered before the timeout; nothing was deleted 8 delete REFUSED: the plan exceeded -MaxDeleteFiles or -MaxDeleteMB, nothing deleted 9 delete completed but some files failed or were skipped Headless delete safety contract (-Select and -Delete): - Dry run is the default. -Commit on the same command line is the only thing that arms it. - Every candidate is validated before anything is deleted, then re-validated immediately before its own delete, so a file replaced or opened in between is skipped, not deleted. - Refused outright: wildcards, relative paths, anything outside -DeleteScope, anything below a junction, NTFS metafiles and root level system files (the same guard the GUI uses), \Windows, \Program Files, \System Volume Information, $Recycle.Bin, and the extensions .pst .ost .vhd .vhdx .vmdk .avhdx .edb .mdf .ldf .bak, which top every largest-files report and whose loss is unrecoverable. There is no override switch. - Files open by another process are skipped, never forced. - A receipt is written for every run, including dry runs and refusals. - No prompt exists anywhere on the -Delete path, and -Select cannot block indefinitely. Deliberate deviations from the rest of this repo, each for a reason: - No '#Requires -RunAsAdministrator'. That statement fails before param() binds, so the exit codes above would never fire and the operator would get PowerShell's generic message instead of one that says which of the four conditions failed. - The inline C# targets C# 5.0 syntax so it compiles under both the Windows PowerShell 5.1 CodeDOM compiler and the PowerShell 7 Roslyn compiler. No string interpolation, no expression-bodied members, no 'out var', no tuples. - No 'unsafe' / pointers. The workload is I/O bound, so BitConverter over byte[] costs nothing measurable and removes the single largest portability risk. Known and intentional accounting behaviour: - Hardlinks are counted ONCE, under one parent, because the MFT has one record per file regardless of link count. C:\Windows\WinSxS will therefore report substantially LESS than Get-ChildItem -Recurse does. This matches WizTree and is not a bug. - Reparse points (junctions, symlinks) are not followed. MFT scanning avoids the infinite-recursion problem a directory walk has, structurally. - On-disk size is measured by summing the $DATA run list; the attribute's AllocatedSize field is only used as a last-resort fallback when a run list cannot be decoded. That field holds the size of the VCN span, so for anything with holes it overstates badly: the USN journal claims its whole logical span, and every OneDrive placeholder claims its full cloud size. It cannot be trusted even when the sparse flag is clear, because $BadClus on a healthy volume reports the ENTIRE volume as allocated while occupying nothing, and carries no sparse flag. Measured on a 927 GB volume, trusting the field gave 2.84 TB against 808 GB used. - Because of those same sparse files, the LOGICAL total can legitimately exceed the size of the volume. That is not an error, and it is why size on disk is the default; you only see it if you ask for -LogicalSize. - $ATTRIBUTE_LIST is handled for the common case: an extension record holding the unnamed $DATA at StartingVCN 0 is credited to its base record, and sparse run lists are summed across extension records too. Full attribute-list resolution is an explicit non-goal. - Records with an unreachable parent are reported as a single "<orphaned>" line rather than silently dropped, so the grand total still reconciles. Memory: roughly 60 bytes per MFT record plus filename strings, so about 150 to 250 MB for a 1.5M record volume. A 10M file server would want about 1 GB free. BitLocker: an unlocked encrypted volume reads normally, because the volume handle sits above the encryption filter. Only a locked volume fails, and it fails as access denied.