#Requires -Version 5.1 <# PC audit collector. Reads this PC's settings and writes them to one JSON file. It changes nothing on this PC, and makes no network connection. HOW TO RUN - save this file as C:\Users\Public\collect.ps1 then open Start > Terminal (use "Terminal (Admin)" for full coverage) and paste: powershell -ExecutionPolicy Bypass -File C:\Users\Public\collect.ps1 -IncludeEnergyReport That path works from any folder and is the same on every PC. If you save the file somewhere else, use its full path - ".\collect.ps1" only works when the terminal is already in that folder. Options: -SkipDxdiag faster, but loses the per-monitor refresh rate -OutFile write somewhere else -Quiet no progress output It writes pcaudit-.json into this same folder. SEND THAT FILE BACK - that is the whole job. Maintainers: read remote/README.md before editing this file. #> [CmdletBinding()] param( [string] $OutFile, [switch] $SkipDxdiag, [switch] $IncludeEnergyReport, [switch] $Quiet, [switch] $Detailed ) $ErrorActionPreference = 'Stop' # SchemaVersion 2: gaming.overlaysDetected changed shape. It used to be an # array of literal strings appended unconditionally (a fabricated list); it is # now an array of objects, emitted only on a real match. Bumped so the analysis # can branch on the version instead of sniffing the element type. $CollectorVersion = '1.1.0' $SchemaVersion = 2 $R = [ordered]@{} $Timings = [ordered]@{} $Warnings = New-Object System.Collections.ArrayList # --------------------------------------------------------------------------- # helpers # --------------------------------------------------------------------------- function Say { param([string] $Message) if (-not $Quiet) { Write-Host $Message } } function Warn { # Recorded, not printed. Warnings fire from inside a section body, where # the progress line is mid-write with no newline yet - printing there # garbles it. The footer reports the count and _meta.warnings has the text. param([string] $Message) [void] $Warnings.Add($Message) } # Plain-English labels for the progress display. The person running this is a # Rust player, not a sysadmin - "registryTweaks" tells them nothing. The count # of this table is also the step total, so adding a section keeps it honest. $StepLabels = [ordered]@{ os = 'Windows' cpu = 'Processor' memory = 'Memory' board = 'Motherboard' gpu = 'Graphics card' displays = 'Monitors' storage = 'Drives' power = 'Power settings' registryTweaks = 'Windows settings' bcd = 'Boot settings' services = 'Background services' drivers = 'Drivers' startup = 'Startup programs' scheduledTasks = 'Scheduled tasks' network = 'Network' processes = 'Running programs' installedSoftware = 'Installed programs' security = 'Security' timers = 'System timers' events = 'Crash history' gaming = 'Steam and Rust' rustCrashes = 'Rust crash logs' tuningTools = 'Overlays and tuning tools' processLasso = 'Process Lasso' environment = 'Final details' } $script:StepIndex = 0 $LineWidth = 58 function Show-Step { # Grey "in progress" line, rewritten in place when the step finishes. param([string] $Label, [int] $Index) if ($Quiet) { return } $dots = '.' * (1 + ($Index % 3)) $text = " [ ] " + $Label + " " + $dots Write-Host ("`r" + $text.PadRight($LineWidth)) -NoNewline -ForegroundColor DarkGray } function Complete-Step { param([string] $Label, [bool] $Ok = $true) if ($Quiet) { return } $mark = if ($Ok) { '[OK]' } else { '[!!]' } $col = if ($Ok) { 'Green' } else { 'Yellow' } Write-Host ("`r" + (" " + $mark + " " + $Label).PadRight($LineWidth)) -ForegroundColor $col } function Add-Section { param( [Parameter(Mandatory = $true)][string] $Name, [Parameter(Mandatory = $true)][scriptblock] $Body ) $script:StepIndex++ $label = $StepLabels[$Name] if (-not $label) { $label = $Name } Show-Step $label $script:StepIndex $ok = $true $sw = [System.Diagnostics.Stopwatch]::StartNew() try { $R[$Name] = & $Body } catch { $R[$Name] = [ordered]@{ collectError = $_.Exception.Message } [void] $Warnings.Add($Name + ": " + $_.Exception.Message) $ok = $false } $sw.Stop() Complete-Step $label $ok $Timings[$Name] = [int] $sw.ElapsedMilliseconds } function Get-Reg { # Single registry value, or $null when the key or value is absent. param([string] $Path, [string] $Name) try { $item = Get-ItemProperty -LiteralPath $Path -ErrorAction Stop $prop = $item.PSObject.Properties[$Name] if ($null -eq $prop) { return $null } if ($prop.Value -is [byte[]]) { return (($prop.Value | ForEach-Object { $_.ToString('x2') }) -join '') } return $prop.Value } catch { return $null } } function Get-RegKey { # Every value under one key as an ordered hashtable, or $null. param([string] $Path) try { $item = Get-ItemProperty -LiteralPath $Path -ErrorAction Stop } catch { return $null } $out = [ordered]@{} foreach ($p in $item.PSObject.Properties) { if ($p.Name -like 'PS*') { continue } $v = $p.Value # REG_BINARY as hex, not as an array of numbers. Serialized one-per-line # a blob becomes hundreds of lines reading "0," - 2052 such lines, about # 80KB, in one sample. The data is kept, the bulk is not. if ($v -is [byte[]]) { if ($v.Length -gt 512) { $out[$p.Name] = (($v[0..511] | ForEach-Object { $_.ToString('x2') }) -join '') + '...truncated' } else { $out[$p.Name] = ($v | ForEach-Object { $_.ToString('x2') }) -join '' } continue } $out[$p.Name] = $v } return $out } function Get-CimSafe { param( [string] $ClassName, [string] $Namespace = 'root\cimv2', [string] $Filter ) try { if ($Filter) { return Get-CimInstance -ClassName $ClassName -Namespace $Namespace -Filter $Filter -ErrorAction Stop } return Get-CimInstance -ClassName $ClassName -Namespace $Namespace -ErrorAction Stop } catch { # Empty array, NOT $null - @($null) is a one-element array holding null, # and the first method call on it takes the whole section down. return @() } } function ConvertTo-PlainLines { # REQUIRED for any file content that reaches the JSON. Get-Content strings # carry PSProvider/PSDrive properties that ConvertTo-Json follows into a # cycle - five decorated lines serialize to 450MB. See README.md. param([object[]] $Lines) if (-not $Lines) { return @() } return @($Lines | ForEach-Object { [string] $_ }) } # --------------------------------------------------------------------------- # identifier scrubbing - keeps the output anonymous (see README.md) # --------------------------------------------------------------------------- $ScrubPatterns = New-Object System.Collections.ArrayList function Add-ScrubName { # Whole-word replacement. Names under 3 characters are skipped - they match # too much ordinary text; their path forms are caught by the \Users\ rule. param([string] $Name, [string] $Token) if (-not $Name -or $Name.Length -lt 3) { return } foreach ($existing in $ScrubPatterns) { if ($existing.Name -eq $Name) { return } } [void] $ScrubPatterns.Add(@{ Name = $Name Regex = ('(?i)\b' + [regex]::Escape($Name) + '\b') Token = $Token }) } Add-ScrubName $env:USERNAME '' # Other local accounts get distinct tokens, so "this task runs as a different # account than the one that collected" survives the scrub. try { $profileRoot = Split-Path -Parent ([Environment]::GetFolderPath('UserProfile')) $skip = @('Public', 'Default', 'Default User', 'All Users', 'defaultuser0') $n = 1 foreach ($d in @(Get-ChildItem -LiteralPath $profileRoot -Directory -Force -ErrorAction SilentlyContinue)) { if ($skip -contains $d.Name) { continue } if ($d.Name -eq $env:USERNAME) { continue } $n++ Add-ScrubName $d.Name ('') } } catch { } Add-ScrubName $env:COMPUTERNAME '' Add-ScrubName $env:USERDOMAIN '' function Protect-Text { param([string] $Text) if ([string]::IsNullOrEmpty($Text)) { return $Text } $t = $Text # Known names first, so a path keeps its per-account token instead of being # flattened by the catch-all below. foreach ($p in $ScrubPatterns) { $t = [regex]::Replace($t, $p.Regex, $p.Token) } # Any profile directory the enumeration missed, whatever precedes it - # including the "\Device\HarddiskVolume3\Users\bob" form powercfg emits. $t = [regex]::Replace($t, '(?i)([\\/])Users([\\/])[^\\/:*?"<>|\r\n]+', '${1}Users${2}') $t = [regex]::Replace($t, 'S-1-5-21-[0-9\-]+', 'S-1-5-21-') $t = [regex]::Replace($t, '(?i)\b([0-9a-f]{2}[:-]){5}[0-9a-f]{2}\b', '') # USB/HID PnP instance IDs carry a per-device serial as their last segment # (USB\VID_xxxx&PID_xxxx\). The VID/PID class is the useful part; # the serial is an identifier and goes. Safe as a generic rule - the prefix # shape cannot collide with anything else. $t = [regex]::Replace($t, '(?i)\b((?:USB|HID)\\VID_[0-9A-F]{4}&PID_[0-9A-F]{4}[^\\\s"]*\\)[^\\\s",]+', '${1}') return $t } # Free-text lines (launch options, crash-log tails) can carry server addresses: # "+connect :" is routine in Steam launch options for Rust. Applied at # the free-text collection sites ONLY - the generic walker must never run this, # because version quads (a NIC driver's "2.1.4.3") match the same shape and # driver versions are load-bearing analysis data. function Protect-FreeText { param([string] $Text) if ([string]::IsNullOrEmpty($Text)) { return $Text } return [regex]::Replace((Protect-Text $Text), '\b(?:\d{1,3}\.){3}\d{1,3}(?::\d{1,5})?\b', '') } function ConvertTo-Sanitized { # Recursively rewrite every string in a section. Returns the same shape. param($Node, [int] $Depth = 0) if ($null -eq $Node -or $Depth -gt 12) { return $Node } if ($Node -is [string]) { return (Protect-Text $Node) } # int/bool/enum/datetime/guid - walking their properties would explode. if ($Node -is [ValueType]) { return $Node } if ($Node -is [System.Collections.IDictionary]) { foreach ($k in @($Node.Keys)) { $Node[$k] = ConvertTo-Sanitized $Node[$k] ($Depth + 1) } return $Node } if ($Node -is [System.Collections.IEnumerable]) { $list = New-Object System.Collections.ArrayList foreach ($item in $Node) { [void] $list.Add((ConvertTo-Sanitized $item ($Depth + 1))) } return $list.ToArray() } # Select-Object output. Rebuilt rather than mutated: JSON is identical, and # rebuilding cannot trip over a read-only or calculated property. $base = $(try { $Node.PSObject.BaseObject } catch { $null }) if ($base -is [System.Management.Automation.PSCustomObject]) { $rebuilt = [ordered]@{} foreach ($p in $Node.PSObject.Properties) { if ($p.Name -like 'PS*') { continue } $rebuilt[$p.Name] = ConvertTo-Sanitized $p.Value ($Depth + 1) } return $rebuilt } # Unexpected type: flatten. Safer than walking an unknown object graph. return (Protect-Text ([string] $Node)) } function Fmt-Date { param($Value) if ($null -eq $Value) { return $null } try { return ([datetime] $Value).ToString('yyyy-MM-dd HH:mm:ss') } catch { return "$Value" } } function Invoke-Native { # Run a console tool and return its combined output as a string array. param([string] $Exe, [string[]] $Arguments) try { $out = & $Exe @Arguments 2>&1 if ($null -eq $out) { return @() } return @($out | ForEach-Object { "$_" }) } catch { return @("") } } function Test-IsAdmin { try { $id = [Security.Principal.WindowsIdentity]::GetCurrent() $pr = New-Object Security.Principal.WindowsPrincipal($id) return $pr.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } $IsAdmin = Test-IsAdmin # --------------------------------------------------------------------------- # NEVER add Add-Type -TypeDefinition or any P/Invoke here: on PowerShell 5.1 # that compiles an unsigned DLL into %TEMP% via csc.exe and antivirus # quarantines the script. In-box tools, CIM and registry only. # # The ONE exception, used once at displays.desktopLayout: Add-Type # -AssemblyName loads an already-installed Microsoft-signed framework # assembly. It compiles nothing and writes nothing to disk, and it is the only # reliable way to read which monitor is primary. See README.md. # --------------------------------------------------------------------------- # resolve output path # --------------------------------------------------------------------------- $stamp = (Get-Date).ToString('yyyyMMdd-HHmmss') if (-not $OutFile) { # Next to the script, so the report sits with the files that produced it and # there is one folder to look in. Timestamp only in the name - no hostname, # the file gets sent to somebody else. $dir = $PSScriptRoot if (-not $dir) { $dir = [Environment]::GetFolderPath('Desktop') } $OutFile = Join-Path $dir ("pcaudit-" + $stamp + ".json") } if (-not $Quiet) { Write-Host "" Write-Host " =============================================================" -ForegroundColor DarkCyan Write-Host " PC AUDIT COLLECTOR" -ForegroundColor White -NoNewline Write-Host " reads settings, changes nothing" -ForegroundColor DarkGray Write-Host " =============================================================" -ForegroundColor DarkCyan Write-Host "" if ($IsAdmin) { Write-Host " Full report" -ForegroundColor Green -NoNewline Write-Host " - running as administrator." -ForegroundColor Gray } else { Write-Host " Partial report" -ForegroundColor Yellow -NoNewline Write-Host " - not running as administrator." -ForegroundColor Gray Write-Host " It still works; a few things just cannot be read." -ForegroundColor Gray } Write-Host "" Write-Host " This takes about half a minute. Please leave this window open" -ForegroundColor Gray Write-Host " while it works - you do not need to do anything." -ForegroundColor Gray Write-Host "" } $runStopwatch = [System.Diagnostics.Stopwatch]::StartNew() # --------------------------------------------------------------------------- # 1. operating system # --------------------------------------------------------------------------- Add-Section 'os' { $os = Get-CimSafe -ClassName Win32_OperatingSystem $cv = Get-RegKey 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' $out = [ordered]@{} if ($os) { $out.caption = $os.Caption $out.version = $os.Version $out.buildNumber = $os.BuildNumber $out.architecture = $os.OSArchitecture $out.installDate = Fmt-Date $os.InstallDate $out.lastBootUpTime = Fmt-Date $os.LastBootUpTime $out.uptimeHours = if ($os.LastBootUpTime) { [math]::Round(((Get-Date) - $os.LastBootUpTime).TotalHours, 1) } else { $null } # No locale/countryCode: nothing here is affected by them, and a country # code is a small piece of identity we have no reason to carry. $out.totalVisibleMemoryKB = $os.TotalVisibleMemorySize $out.freePhysicalMemoryKB = $os.FreePhysicalMemory } if ($cv) { $out.displayVersion = $cv['DisplayVersion'] $out.releaseId = $cv['ReleaseId'] $out.ubr = $cv['UBR'] $out.editionId = $cv['EditionID'] $out.currentBuild = $cv['CurrentBuild'] } # Win10 vs Win11 matters for several tweaks; decide it here, once. $build = 0 if ($out.currentBuild) { [void][int]::TryParse("$($out.currentBuild)", [ref] $build) } $out.buildNumeric = $build if ($build -ge 22000) { $out.family = 'Windows 11' } elseif ($build -gt 0) { $out.family = 'Windows 10' } else { $out.family = 'unknown' } $out.powerShellVersion = $PSVersionTable.PSVersion.ToString() $out.powerShellEdition = $PSVersionTable.PSEdition $out.dotNetVersion = [System.Environment]::Version.ToString() # A pending reboot invalidates half of what we are about to read. $pending = @() if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { $pending += 'CBS' } if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\WindowsUpdate\Auto Update\RebootRequired') { $pending += 'WindowsUpdate' } if (Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager' 'PendingFileRenameOperations') { $pending += 'PendingFileRename' } $out.pendingReboot = $pending return $out } # --------------------------------------------------------------------------- # 2. CPU / memory / board / firmware # --------------------------------------------------------------------------- Add-Section 'cpu' { $procs = @(Get-CimSafe -ClassName Win32_Processor) $list = @() foreach ($p in $procs) { $list += [ordered]@{ name = $p.Name manufacturer = $p.Manufacturer cores = $p.NumberOfCores logicalProcessors = $p.NumberOfLogicalProcessors maxClockMHz = $p.MaxClockSpeed currentClockMHz = $p.CurrentClockSpeed l2CacheKB = $p.L2CacheSize l3CacheKB = $p.L3CacheSize virtualizationFirmwareEnabled = $p.VirtualizationFirmwareEnabled secondLevelAddressTranslation = $p.SecondLevelAddressTranslationExtensions socket = $p.SocketDesignation } } $out = [ordered]@{ processors = $list } if ($list.Count -gt 0) { # Sum by hand: Measure-Object -Property cannot read keys off an ordered # hashtable on 5.1. $cores = 0 $logical = 0 foreach ($e in $list) { if ($e.cores) { $cores += [int] $e.cores } if ($e.logicalProcessors) { $logical += [int] $e.logicalProcessors } } $out.totalPhysicalCores = $cores $out.totalLogicalCores = $logical # Every affinity recommendation is computed from this - never copied. if ($cores -gt 0) { $out.smtEnabled = ($logical -gt $cores) $out.threadsPerCore = [math]::Round($logical / $cores, 2) } } $out.environmentProcessorCount = [Environment]::ProcessorCount return $out } Add-Section 'memory' { $mods = @(Get-CimSafe -ClassName Win32_PhysicalMemory) $list = @() foreach ($m in $mods) { # Deliberately no SerialNumber. $list += [ordered]@{ deviceLocator = $m.DeviceLocator bankLabel = $m.BankLabel capacityGB = if ($m.Capacity) { [math]::Round($m.Capacity / 1GB, 0) } else { $null } configuredClockMHz = $m.ConfiguredClockSpeed ratedSpeedMHz = $m.Speed configuredVoltagemV = $m.ConfiguredVoltage manufacturer = $m.Manufacturer partNumber = if ($m.PartNumber) { $m.PartNumber.Trim() } else { $null } smbiosMemoryType = $m.SMBIOSMemoryType } } $arr = Get-CimSafe -ClassName Win32_PhysicalMemoryArray $out = [ordered]@{ modules = $list moduleCount = $list.Count totalGB = if ($mods) { [math]::Round((($mods | Measure-Object -Property Capacity -Sum).Sum) / 1GB, 0) } else { $null } maxCapacityKB = if ($arr) { @($arr)[0].MaxCapacity } else { $null } slots = if ($arr) { @($arr)[0].MemoryDevices } else { $null } } # EXPO / XMP verdict. Win32_PhysicalMemory.Speed is the SPD/JEDEC base the # module falls back to with no profile applied; ConfiguredClockSpeed is # what it is ACTUALLY running at. Configured > base means a memory profile # (EXPO on AMD, XMP on Intel) is enabled. The kit's advertised speed is # also usually encoded in the part number, which catches the expensive # case: a 6000 kit sitting at its 4800 JEDEC fallback because nobody ever # turned the profile on. No SPD read and no BIOS access required. $cfgSpeeds = @($mods | ForEach-Object { $_.ConfiguredClockSpeed } | Where-Object { $_ }) $baseSpeeds = @($mods | ForEach-Object { $_.Speed } | Where-Object { $_ }) $cfgMHz = if ($cfgSpeeds.Count) { ($cfgSpeeds | Measure-Object -Maximum).Maximum } else { $null } $baseMHz = if ($baseSpeeds.Count) { ($baseSpeeds | Measure-Object -Maximum).Maximum } else { $null } # Advertised speed from the part number: the first plausible DDR data rate. $ratedFromPart = $null foreach ($m in $mods) { if (-not $m.PartNumber) { continue } foreach ($mm in [regex]::Matches($m.PartNumber, '(\d{4,5})')) { $v = [int] $mm.Groups[1].Value if ($v -ge 2133 -and $v -le 9000) { $ratedFromPart = $v; break } } if ($ratedFromPart) { break } } $ddr = switch ([int] $(if ($mods) { @($mods)[0].SMBIOSMemoryType } else { 0 })) { 34 { 'DDR5' } 26 { 'DDR4' } 24 { 'DDR3' } default { 'unknown' } } $verdict = 'inconclusive' if ($cfgMHz -and $baseMHz) { if ($cfgMHz -gt $baseMHz) { $verdict = 'profile ACTIVE (running above the JEDEC base)' } elseif ($ratedFromPart -and $ratedFromPart -gt $cfgMHz + 100) { $verdict = 'profile likely OFF (running at the JEDEC base while the part number advertises higher)' } else { $verdict = 'no profile detected (running at the JEDEC base; kit may have no faster rating)' } } # Advertised CAS latency, decoded from the vendor part number. There is no # WMI/SMBIOS field for timings, and reading the real ones needs an SMBus # SPD read through a ring-0 driver - out of scope by design. So this is # what the kit is SOLD as, never what it is RUNNING at: a hand-tuned kit # will contradict it (a CL30-rated kit running CL28 still says J3040). $clRated = $null; $clSource = $null foreach ($m in $mods) { if (-not $m.PartNumber) { continue } $pn = $m.PartNumber.Trim() if ($pn -match 'J(\d{2})(\d{2})') { $clRated = "CL$($Matches[1])-$($Matches[2])"; $clSource = 'G.Skill DDR5 Jxxyy' } elseif ($pn -match 'HC(\d{2})') { $clRated = "CL$($Matches[1])"; $clSource = 'TeamGroup HCxx' } elseif ($pn -match '(? jedecBaseMHz is proof a profile is applied. Some boards report both fields as the running speed, in which case this is inconclusive rather than negative - say so, do not report "EXPO off". Timings/subtimings are NOT available without an SPD read and are not collected.' } # Page file: fixed vs system-managed is a real gaming-stability lever. $pf = @(Get-CimSafe -ClassName Win32_PageFileSetting) $out.pageFileSettings = @($pf | Select-Object Name, InitialSize, MaximumSize) $pfu = @(Get-CimSafe -ClassName Win32_PageFileUsage) $out.pageFileUsage = @($pfu | Select-Object Name, AllocatedBaseSize, CurrentUsage, PeakUsage) $out.systemManagedPageFile = (Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' 'PagingFiles') return $out } Add-Section 'board' { $bb = Get-CimSafe -ClassName Win32_BaseBoard $bios = Get-CimSafe -ClassName Win32_BIOS $cs = Get-CimSafe -ClassName Win32_ComputerSystem $out = [ordered]@{} if ($bb) { $bb = @($bb)[0]; $out.manufacturer = $bb.Manufacturer; $out.product = $bb.Product; $out.version = $bb.Version } if ($bios) { $bios = @($bios)[0] $out.biosVendor = $bios.Manufacturer $out.biosVersion = $bios.SMBIOSBIOSVersion $out.biosReleaseDate = Fmt-Date $bios.ReleaseDate } if ($cs) { $cs = @($cs)[0] $out.systemManufacturer = $cs.Manufacturer $out.systemModel = $cs.Model $out.systemType = $cs.SystemType $out.totalPhysicalMemoryGB = if ($cs.TotalPhysicalMemory) { [math]::Round($cs.TotalPhysicalMemory / 1GB, 1) } else { $null } $out.hypervisorPresent = $cs.HypervisorPresent } $out.secureBoot = $(try { Confirm-SecureBootUEFI } catch { $null }) $out.firmwareType = $(try { (Get-CimSafe -ClassName Win32_ComputerSystem) | Out-Null; $env:firmware_type } catch { $null }) return $out } # --------------------------------------------------------------------------- # 3. GPU # --------------------------------------------------------------------------- Add-Section 'gpu' { $cards = @(Get-CimSafe -ClassName Win32_VideoController) $list = @() foreach ($c in $cards) { $list += [ordered]@{ name = $c.Name driverVersion = $c.DriverVersion driverDate = Fmt-Date $c.DriverDate videoProcessor = $c.VideoProcessor adapterRamGB = if ($c.AdapterRAM -and $c.AdapterRAM -gt 0) { [math]::Round($c.AdapterRAM / 1GB, 1) } else { $null } currentHorizontalRes = $c.CurrentHorizontalResolution currentVerticalRes = $c.CurrentVerticalResolution currentRefreshRate = $c.CurrentRefreshRate maxRefreshRate = $c.MaxRefreshRate minRefreshRate = $c.MinRefreshRate status = $c.Status pnpDeviceId = $c.PNPDeviceID } } $out = [ordered]@{ controllers = $list } # nvidia-smi: authoritative for power limit, driver branch, VBIOS. $smi = $null foreach ($cand in @( (Join-Path $env:SystemRoot 'System32\nvidia-smi.exe'), 'C:\Program Files\NVIDIA Corporation\NVSMI\nvidia-smi.exe' )) { if (Test-Path -LiteralPath $cand) { $smi = $cand; break } } if ($smi) { # Explicit arguments, never a splatted array: splatting an element that # contains '=' and ',' joins it with the next argument. $q = 'name,driver_version,vbios_version,memory.total,power.limit,power.default_limit,power.max_limit,clocks.max.graphics,clocks.max.memory,pcie.link.gen.max' try { $out.nvidiaSmi = @(& $smi "--query-gpu=$q" '--format=csv,noheader' 2>&1 | ForEach-Object { "$_" }) } catch { $out.nvidiaSmi = @('') } } else { $out.nvidiaSmi = $null } # Hardware-accelerated GPU scheduling. $out.hwSchMode = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\GraphicsDrivers' 'HwSchMode' # Multi-plane overlay kill switch (5 = MPO disabled). $out.mpoOverlayTestMode = Get-Reg 'HKLM:\SOFTWARE\Microsoft\Windows\Dwm' 'OverlayTestMode' $out.tdrDelay = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\GraphicsDrivers' 'TdrDelay' $out.tdrDdiDelay = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\GraphicsDrivers' 'TdrDdiDelay' $out.tdrLevel = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\GraphicsDrivers' 'TdrLevel' # The display adapter's own driver class key. On AMD this is where Radeon # Software persists its tuning - EnableUlps, Anti-Lag, Chill, power tuning - # and none of it is visible anywhere else in this collection. On NVIDIA the # key is thinner but still records which user-mode driver DLLs are loaded. # Without it this whole collection is NVIDIA-only by accident. # # What it does NOT contain, on either vendor: the per-game 3D settings. # NVIDIA keeps those in a binary profile store (Drs\nvdrsdb0.bin, ~2.5MB) # with no in-box reader, which is why nvidiaProfileStore below records only # that it exists and when it changed. The analysis side treats this as a stated gap. $out.driverClassKeys = @() $classRoot = 'HKLM:\SYSTEM\CurrentControlSet\Control\Class\{4d36e968-e325-11ce-bfc1-08002be10318}' foreach ($sub in @(Get-ChildItem -LiteralPath $classRoot -ErrorAction SilentlyContinue)) { # Numbered adapter subkeys only (0000, 0001, ...), never Configuration. if ($sub.PSChildName -notmatch '^\d{4}$') { continue } $vals = Get-RegKey $sub.PSPath if (-not $vals) { continue } $out.driverClassKeys += [ordered]@{ subkey = $sub.PSChildName; values = $vals } } $drs = Join-Path $env:ProgramData 'NVIDIA Corporation\Drs' $out.nvidiaProfileStore = @(Get-ChildItem -LiteralPath $drs -File -ErrorAction SilentlyContinue | Select-Object @{ n = 'name'; e = { $_.Name } }, @{ n = 'bytes'; e = { $_.Length } }, @{ n = 'modified'; e = { $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') } }) $out.nvidiaProfileStoreNote = 'Existence and mtime only. The per-game 3D settings (Low Latency Mode, Power Management Mode, V-Sync, Max Frame Rate, texture filtering) live inside these binary files and cannot be decoded without NVAPI. Never infer their values; ask for a screenshot of the NVIDIA control panel instead.' return $out } # --------------------------------------------------------------------------- # 4. displays - current mode per monitor, plus panel capability # --------------------------------------------------------------------------- Add-Section 'displays' { $out = [ordered]@{} # (a) Active modes from the registry: GraphicsDrivers\Configuration holds a # subtree per monitor arrangement, with pixel size and refresh per output. $cfgRoot = 'HKLM:\SYSTEM\CurrentControlSet\Control\GraphicsDrivers\Configuration' $cfgDump = @() if (Test-Path -LiteralPath $cfgRoot) { try { $keys = @(Get-ChildItem -LiteralPath $cfgRoot -Recurse -ErrorAction SilentlyContinue | Select-Object -First 250) foreach ($k in $keys) { $vals = Get-RegKey $k.PSPath if (-not $vals -or $vals.Count -eq 0) { continue } $clean = [ordered]@{} foreach ($vn in $vals.Keys) { $v = $vals[$vn] if ($v -is [byte[]]) { if ($v.Length -le 64) { $clean[$vn] = ($v | ForEach-Object { $_.ToString('x2') }) -join '' } else { $clean[$vn] = '<' + $v.Length + ' bytes>' } } else { $clean[$vn] = $v } } $cfgDump += [ordered]@{ key = ($k.Name -replace '^HKEY_LOCAL_MACHINE\\SYSTEM\\CurrentControlSet\\Control\\GraphicsDrivers\\Configuration\\?', '') values = $clean } } } catch { $out.configurationError = $_.Exception.Message } } # Drop the arrangement stubs - SetId plus a Timestamp, no mode data. The # entries that matter carry ActiveSize and VSyncFreq. $out.graphicsConfiguration = @($cfgDump | Where-Object { $n = @($_.values.Keys) ($n -contains 'ActiveSize.cx') -or ($n -contains 'PrimSurfSize.cx') }) # Every mode Windows has ever APPLIED to each output. This is the closest # thing to a panel maximum that can be read without parsing EDID extension # blocks: WmiMonitorListedSupportedSourceModes reports base timings only and # understates badly (it called a 4K144 panel 1024x768@60), while this history # showed that same panel at 3840x2160@144 and a QD-OLED at 2560x1440@360. # # It is a LOWER BOUND, not the maximum: a mode appears only if it was # actually used at some point. "Ran at 144 before, at 60 now" is still the # finding worth having - and it is proof, not inference. $modes = @() foreach ($c in $out.graphicsConfiguration) { $v = $c.values $w = $v.'ActiveSize.cx'; $h = $v.'ActiveSize.cy' $num = $v.'VSyncFreq.Numerator'; $den = $v.'VSyncFreq.Denominator' if (-not $w -or -not $h -or -not $num -or -not $den) { continue } $hz = [math]::Round($num / $den, 1) # Placeholder rows carry a nonsense rate (1Hz seen on this box). if ($hz -lt 20) { continue } $modes += [ordered]@{ width = [int] $w; height = [int] $h; hz = $hz } } $seenMode = @{} $uniq = @() foreach ($m in ($modes | Sort-Object -Property @{ e = { $_.width * $_.height } }, @{ e = { $_.hz } } -Descending)) { $k = "$($m.width)x$($m.height)@$($m.hz)" if (-not $seenMode.ContainsKey($k)) { $seenMode[$k] = $true; $uniq += $m } } $out.modesEverApplied = $uniq $out.maxHzEverApplied = $(if ($uniq.Count -gt 0) { (@($uniq | ForEach-Object { $_.hz }) | Sort-Object -Descending)[0] } else { $null }) $out.modesEverAppliedNote = 'LOWER BOUND on panel capability - a mode is listed only if Windows actually applied it. Compare against dxdiagCurrentModes to find a panel running below what it has already demonstrated.' # Win32_VideoController reports only one controller's mode on a # multi-monitor box - which is why dxdiag runs below. $out.videoControllerModes = @(Get-CimSafe -ClassName Win32_VideoController | Select-Object Name, CurrentHorizontalResolution, CurrentVerticalResolution, CurrentRefreshRate, MaxRefreshRate, MinRefreshRate, CurrentBitsPerPixel, VideoModeDescription) # (a2) Desktop layout - which output is PRIMARY, and where each one sits. # This closes the "we cannot tell which monitor the game is on" gap: a # fullscreen game opens on the primary unless the player moved it. # NOTE ON SAFETY: -AssemblyName LOADS a Microsoft-signed framework # assembly that is already on every Windows box. It is NOT # -TypeDefinition, which compiles C# via csc.exe into an unsigned temp # DLL and gets quarantined by AV. No P/Invoke is written here. $out.desktopLayout = @() try { Add-Type -AssemblyName System.Windows.Forms -ErrorAction Stop foreach ($s in [System.Windows.Forms.Screen]::AllScreens) { $out.desktopLayout += [ordered]@{ deviceName = $s.DeviceName primary = [bool] $s.Primary originX = $s.Bounds.X originY = $s.Bounds.Y width = $s.Bounds.Width height = $s.Bounds.Height bitsPerPixel = $s.BitsPerPixel } } } catch { $out.desktopLayoutError = $_.Exception.Message } $out.desktopLayoutNote = 'primary=true is the display Windows treats as primary (desktop origin 0,0); a fullscreen game opens there by default. Pair it with dxdiagCurrentModes by matching width/height to get its refresh rate and monitor model. Bounds can be DPI-scaled if display scaling is not 100%.' # (b) EDID-derived panel identity + supported modes (no serial numbers). $ids = @(Get-CimSafe -ClassName WmiMonitorID -Namespace 'root\wmi') $panels = @() foreach ($id in $ids) { $decode = { param($arr) if (-not $arr) { return $null } $chars = @($arr | Where-Object { $_ -ne 0 } | ForEach-Object { [char] $_ }) if ($chars.Count -eq 0) { return $null } return (-join $chars) } $panels += [ordered]@{ instanceName = $id.InstanceName manufacturerName = (& $decode $id.ManufacturerName) productCodeId = (& $decode $id.ProductCodeID) userFriendlyName = (& $decode $id.UserFriendlyName) yearOfManufacture = $id.YearOfManufacture weekOfManufacture = $id.WeekOfManufacture } } $out.panels = $panels $modes = @(Get-CimSafe -ClassName WmiMonitorListedSupportedSourceModes -Namespace 'root\wmi') $caps = @() foreach ($mm in $modes) { $best = $null $all = @() foreach ($sm in @($mm.MonitorSourceModes)) { $den = $sm.VerticalRefreshRateDenominator if (-not $den -or $den -eq 0) { $den = 1 } $hz = [math]::Round($sm.VerticalRefreshRateNumerator / $den, 0) $all += [ordered]@{ width = $sm.HorizontalActivePixels height = $sm.VerticalActivePixels hz = $hz } if ($null -eq $best -or $hz -gt $best) { $best = $hz } } $caps += [ordered]@{ instanceName = $mm.InstanceName edidListedMaxHz = $best modeCount = $all.Count modes = $all } } $out.edidListedModes = $caps # DO NOT treat edidListedMaxHz as the panel's real maximum - this class sees # only base EDID timings and understates high-refresh panels badly (a 4K144 # reported as 1024x768@60). Use dxdiag's Current Mode instead. See README.md. $out.edidListedModesWarning = 'edidListedMaxHz UNDERSTATES high-refresh panels; use dxdiagLines / videoControllerModes instead' # Raw EDID incl. CTA-861 extension blocks - the true panel maximum lives in # their timing descriptors. Captured as bytes and decoded during analysis, # not parsed here. $edid = @() $edidBlocks = @(Get-CimSafe -ClassName WmiMonitorRawEEdidV1Block -Namespace 'root\wmi' | Where-Object { $_ }) foreach ($b in $edidBlocks) { $content = @($b.BlockContent | Where-Object { $null -ne $_ }) $bytes = @($content | ForEach-Object { [byte] $_ }) # Zero the monitor serial: 4 bytes at offset 12, plus any descriptor # tagged 0xFF. Timing descriptors untouched. Keyed on the fixed EDID # header, not BlockType, so an extension block can never be corrupted. $isBase = $false if ($bytes.Count -ge 128) { $isBase = ($bytes[0] -eq 0x00 -and $bytes[7] -eq 0x00 -and $bytes[1] -eq 0xFF -and $bytes[2] -eq 0xFF -and $bytes[3] -eq 0xFF -and $bytes[4] -eq 0xFF -and $bytes[5] -eq 0xFF -and $bytes[6] -eq 0xFF) } if ($isBase) { for ($i = 12; $i -le 15; $i++) { $bytes[$i] = 0 } foreach ($d in @(54, 72, 90, 108)) { if ($bytes[$d] -eq 0 -and $bytes[$d + 1] -eq 0 -and $bytes[$d + 2] -eq 0 -and $bytes[$d + 3] -eq 0xFF) { for ($i = $d + 5; $i -lt ($d + 18); $i++) { $bytes[$i] = 0 } } } } $edid += [ordered]@{ instanceName = $b.InstanceName blockType = $b.BlockType serialScrubbed = $isBase hex = if ($bytes.Count -gt 0) { ($bytes | ForEach-Object { $_.ToString('x2') }) -join '' } else { $null } } } $out.edidRawBlocks = $edid $out.edidRawNote = if ($edid.Count -gt 0) { 'base block + CTA extensions, hex; decode detailed timing descriptors for the true panel maximum' } else { 'WmiMonitorRawEEdidV1Block exposed no instances on this machine; true panel maximum cannot be derived from this collection' } # dxdiag (signed, in-box) is the only reliable per-monitor CURRENT refresh # rate. A 144Hz panel running at 60Hz is the most common finding, so it is # on by default. if (-not $SkipDxdiag) { try { $tmp = Join-Path $env:TEMP ("dxdiag-" + $stamp + ".txt") & dxdiag /whql:off /t $tmp | Out-Null $deadline = (Get-Date).AddSeconds(90) while (-not (Test-Path -LiteralPath $tmp) -and (Get-Date) -lt $deadline) { Start-Sleep -Milliseconds 500 } if (Test-Path -LiteralPath $tmp) { # Let the file settle - dxdiag detaches and writes asynchronously. $last = -1 while ((Get-Date) -lt $deadline) { $len = (Get-Item -LiteralPath $tmp).Length if ($len -eq $last -and $len -gt 0) { break } $last = $len Start-Sleep -Milliseconds 750 } $txt = Get-Content -LiteralPath $tmp -ErrorAction SilentlyContinue $wanted = @( 'Current Mode', 'Native Mode', 'Monitor Name', 'Monitor Model', 'Monitor Id', 'Output Type', 'Card name', 'Driver Version', 'Driver Date', 'DDI Version', 'Feature Levels', 'Hardware Scheduling', 'MPO MaxPlanes', 'MPO Caps', 'Display Memory', 'Dedicated Memory', 'Shared Memory' ) $picked = @() foreach ($w in $wanted) { $picked += @($txt | Select-String -SimpleMatch $w | ForEach-Object { $_.Line.Trim() }) } $out.dxdiagLines = $picked $out.dxdiagCurrentModes = @($txt | Select-String -SimpleMatch 'Current Mode' | ForEach-Object { $_.Line.Trim() }) $out.dxdiagMonitorNames = @($txt | Select-String -SimpleMatch 'Monitor Name' | ForEach-Object { $_.Line.Trim() }) $out.dxdiagPath = $tmp } else { $out.dxdiagError = 'dxdiag produced no output within 90s' } } catch { $out.dxdiagError = $_.Exception.Message } } return $out } # --------------------------------------------------------------------------- # 5. storage # --------------------------------------------------------------------------- Add-Section 'storage' { $out = [ordered]@{} try { $out.physicalDisks = @(Get-PhysicalDisk -ErrorAction Stop | Select-Object FriendlyName, MediaType, BusType, HealthStatus, @{ n = 'SizeGB'; e = { [math]::Round($_.Size / 1GB, 0) } }, FirmwareVersion, CanPool, SpindleSpeed) } catch { $out.physicalDisks = $null $out.physicalDisksError = $_.Exception.Message } try { $out.disks = @(Get-Disk -ErrorAction Stop | Select-Object Number, FriendlyName, PartitionStyle, BusType, IsBoot, IsSystem, @{ n = 'SizeGB'; e = { [math]::Round($_.Size / 1GB, 0) } }) } catch { $out.disks = $null } try { $out.volumes = @(Get-Volume -ErrorAction Stop | Where-Object { $_.DriveLetter } | Select-Object DriveLetter, FileSystem, FileSystemLabel, HealthStatus, @{ n = 'SizeGB'; e = { [math]::Round($_.Size / 1GB, 1) } }, @{ n = 'FreeGB'; e = { [math]::Round($_.SizeRemaining / 1GB, 1) } }, @{ n = 'FreePercent'; e = { if ($_.Size) { [math]::Round(100 * $_.SizeRemaining / $_.Size, 1) } else { $null } } }) } catch { $out.volumes = $null } # Drive letter -> physical disk. Get-Volume and Get-PhysicalDisk share no # common key, so on their own they cannot answer "is the game on the spinning # disk" - which is the single most valuable storage question here and the one # the critical finding depends on. Get-Partition carries both the drive # letter and the disk number and is the join between the two. Unelevated. try { $phys = @{} foreach ($p in @(Get-PhysicalDisk -ErrorAction SilentlyContinue)) { $phys["$($p.DeviceId)"] = $p } $out.volumeToDisk = @(Get-Partition -ErrorAction Stop | Where-Object { $_.DriveLetter } | ForEach-Object { $pd = $phys["$($_.DiskNumber)"] [ordered]@{ driveLetter = "$($_.DriveLetter)" diskNumber = $_.DiskNumber friendlyName = $(if ($pd) { "$($pd.FriendlyName)" } else { $null }) mediaType = $(if ($pd) { "$($pd.MediaType)" } else { $null }) busType = $(if ($pd) { "$($pd.BusType)" } else { $null }) spindleSpeed = $(if ($pd) { $pd.SpindleSpeed } else { $null }) healthStatus = $(if ($pd) { "$($pd.HealthStatus)" } else { $null }) sizeGB = [math]::Round($_.Size / 1GB, 1) } }) } catch { $out.volumeToDisk = $null $out.volumeToDiskError = $_.Exception.Message } $out.volumeToDiskNote = 'The join that turns a drive letter into a physical disk. Take the first character of gaming.rustPath, find it here, and read mediaType: HDD is a critical finding on a game that streams assets. mediaType can also read Unspecified on some NVMe controllers - fall back to busType NVMe, or to spindleSpeed 0, before calling a disk mechanical.' # TRIM. "DisableDeleteNotify = 0" is the healthy answer on SSDs. $out.trimRaw = Invoke-Native 'fsutil' @('behavior', 'query', 'DisableDeleteNotify') $out.lastAccessRaw = Invoke-Native 'fsutil' @('behavior', 'query', 'disablelastaccess') $out.memoryUsageRaw = Invoke-Native 'fsutil' @('behavior', 'query', 'memoryusage') $out.largeSystemCache = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' 'LargeSystemCache' $out.ntfsDisableLastAccessUpdate = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem' 'NtfsDisableLastAccessUpdate' $out.storahciInterruptThrottle = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Services\storahci\Parameters\Device' 'EnableHIPM' return $out } # --------------------------------------------------------------------------- # 6. power policy # --------------------------------------------------------------------------- Add-Section 'power' { $out = [ordered]@{} $active = Invoke-Native 'powercfg' @('/getactivescheme') $out.activeSchemeRaw = $active $joined = ($active -join ' ') $m = [regex]::Match($joined, '([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})') if ($m.Success) { $out.activeSchemeGuid = $m.Groups[1].Value } else { $out.activeSchemeGuid = $null } $m2 = [regex]::Match($joined, '\(([^)]+)\)') if ($m2.Success) { $out.activeSchemeName = $m2.Groups[1].Value } else { $out.activeSchemeName = $null } $out.schemesRaw = Invoke-Native 'powercfg' @('/list') $out.sleepStatesRaw = Invoke-Native 'powercfg' @('/a') # Full dump of the active scheme: processor min/max state, USB selective # suspend, PCIe ASPM, core parking. GUIDs survive locale translation. $out.activeSchemeQueryRaw = Invoke-Native 'powercfg' @('/query', 'SCHEME_CURRENT') # Locale-proof duplicates from the registry. Absent key = never changed. if ($out.activeSchemeGuid) { $base = 'HKLM:\SYSTEM\CurrentControlSet\Control\Power\User\PowerSchemes\' + $out.activeSchemeGuid $known = [ordered]@{ processorThrottleMin = '54533251-82be-4824-96c1-47b60b740d00\893dee8e-2bef-41e0-89c6-b55d0929964c' processorThrottleMax = '54533251-82be-4824-96c1-47b60b740d00\bc5038f7-23e0-4960-96da-33abaf5935ec' processorIdleDisable = '54533251-82be-4824-96c1-47b60b740d00\5d76a2ca-e8c0-402f-a133-2158492d58ad' coreParkingMinCores = '54533251-82be-4824-96c1-47b60b740d00\0cc5b647-c1df-4637-891a-dec35c318583' usbSelectiveSuspend = '2a737441-1930-4402-8d77-b2bebba308a3\48e6b7a6-50f5-4782-a5d4-53bb8f07e226' pciExpressAspm = '501a4d13-42af-4429-9fd1-a8218c268e20\ee12f906-d277-404b-b6da-e5fa1a576df5' } $vals = [ordered]@{} foreach ($k in $known.Keys) { $p = Join-Path $base $known[$k] $vals[$k] = [ordered]@{ ac = Get-Reg $p 'ACSettingIndex' dc = Get-Reg $p 'DCSettingIndex' } } $out.settings = $vals } $out.hiberbootEnabled = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Power' 'HiberbootEnabled' $out.hibernateEnabled = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\Power' 'HibernateEnabled' $out.platformAoAcOverride = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\Power' 'PlatformAoAcOverride' return $out } # --------------------------------------------------------------------------- # 7. the registry surface that gaming tweaks live in # --------------------------------------------------------------------------- Add-Section 'registryTweaks' { $checks = @( @{ id = 'Win32PrioritySeparation'; path = 'HKLM:\SYSTEM\CurrentControlSet\Control\PriorityControl'; name = 'Win32PrioritySeparation' }, @{ id = 'NetworkThrottlingIndex'; path = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Multimedia\SystemProfile'; name = 'NetworkThrottlingIndex' }, @{ id = 'SystemResponsiveness'; path = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Multimedia\SystemProfile'; name = 'SystemResponsiveness' }, @{ id = 'MMCSS.Games.GPUPriority'; path = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Multimedia\SystemProfile\Tasks\Games'; name = 'GPU Priority' }, @{ id = 'MMCSS.Games.Priority'; path = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Multimedia\SystemProfile\Tasks\Games'; name = 'Priority' }, @{ id = 'MMCSS.Games.SchedCategory'; path = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Multimedia\SystemProfile\Tasks\Games'; name = 'Scheduling Category' }, @{ id = 'FeatureSettingsOverride'; path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management'; name = 'FeatureSettingsOverride' }, @{ id = 'FeatureSettingsOverrideMask'; path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management'; name = 'FeatureSettingsOverrideMask' }, @{ id = 'DODownloadMode'; path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DeliveryOptimization'; name = 'DODownloadMode' }, @{ id = 'AllowAutoGameMode'; path = 'HKCU:\SOFTWARE\Microsoft\GameBar'; name = 'AllowAutoGameMode' }, @{ id = 'AutoGameModeEnabled'; path = 'HKCU:\SOFTWARE\Microsoft\GameBar'; name = 'AutoGameModeEnabled' }, @{ id = 'GameBar.UseNexusForGameBarEnabled'; path = 'HKCU:\SOFTWARE\Microsoft\GameBar'; name = 'UseNexusForGameBarEnabled' }, @{ id = 'GameDVR_Enabled'; path = 'HKCU:\System\GameConfigStore'; name = 'GameDVR_Enabled' }, @{ id = 'GameDVR_FSEBehavior'; path = 'HKCU:\System\GameConfigStore'; name = 'GameDVR_FSEBehavior' }, @{ id = 'GameDVR_FSEBehaviorMode'; path = 'HKCU:\System\GameConfigStore'; name = 'GameDVR_FSEBehaviorMode' }, @{ id = 'GameDVR_HonorUserFSEBehaviorMode'; path = 'HKCU:\System\GameConfigStore'; name = 'GameDVR_HonorUserFSEBehaviorMode' }, @{ id = 'GameDVR_DXGIHonorFSEWindowsCompatible'; path = 'HKCU:\System\GameConfigStore'; name = 'GameDVR_DXGIHonorFSEWindowsCompatible' }, @{ id = 'GameDVR_EFSEFeatureFlags'; path = 'HKCU:\System\GameConfigStore'; name = 'GameDVR_EFSEFeatureFlags' }, @{ id = 'AppCaptureEnabled'; path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR'; name = 'AppCaptureEnabled' }, @{ id = 'HistoricalCaptureEnabled'; path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR'; name = 'HistoricalCaptureEnabled' }, @{ id = 'PolicyAllowGameDVR'; path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\GameDVR'; name = 'AllowGameDVR' }, @{ id = 'SwapEffectUpgradeEnable'; path = 'HKCU:\SOFTWARE\Microsoft\DirectX\UserGpuPreferences'; name = 'DirectXUserGlobalSettings' }, @{ id = 'MouseSensitivity'; path = 'HKCU:\Control Panel\Mouse'; name = 'MouseSensitivity' }, @{ id = 'MouseSpeed'; path = 'HKCU:\Control Panel\Mouse'; name = 'MouseSpeed' }, @{ id = 'MouseThreshold1'; path = 'HKCU:\Control Panel\Mouse'; name = 'MouseThreshold1' }, @{ id = 'MouseThreshold2'; path = 'HKCU:\Control Panel\Mouse'; name = 'MouseThreshold2' }, @{ id = 'VisualFXSetting'; path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\VisualEffects'; name = 'VisualFXSetting' }, @{ id = 'MenuShowDelay'; path = 'HKCU:\Control Panel\Desktop'; name = 'MenuShowDelay' }, @{ id = 'DisableTaskbarWidgets'; path = 'HKLM:\SOFTWARE\Policies\Microsoft\Dsh'; name = 'AllowNewsAndInterests' }, @{ id = 'PowerThrottlingOff'; path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Power\PowerThrottling'; name = 'PowerThrottlingOff' }, @{ id = 'CsEnabled'; path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Power'; name = 'CsEnabled' }, @{ id = 'TcpAckFrequency.note'; path = 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters'; name = 'TcpAckFrequency' }, @{ id = 'IRPStackSize'; path = 'HKLM:\SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters'; name = 'IRPStackSize' }, @{ id = 'DisableDynamicTick.note'; path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\kernel'; name = 'GlobalTimerResolutionRequests' }, @{ id = 'SerializeTimerExpiration'; path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\kernel'; name = 'SerializeTimerExpiration' }, @{ id = 'DistributeTimers'; path = 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\kernel' ; name = 'DistributeTimers' }, @{ id = 'ExcludeWUDriversInQualityUpdate'; path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate'; name = 'ExcludeWUDriversInQualityUpdate' }, @{ id = 'SearchboxTaskbarMode'; path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Search'; name = 'SearchboxTaskbarMode' }, @{ id = 'AllowTelemetry'; path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection'; name = 'AllowTelemetry' }, @{ id = 'ContentDeliveryManager.SilentInstalledAppsEnabled'; path = 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\ContentDeliveryManager'; name = 'SilentInstalledAppsEnabled' }, @{ id = 'NvidiaTelemetry.OptInOrOutPreference'; path = 'HKLM:\SOFTWARE\NVIDIA Corporation\NvControlPanel2\Client'; name = 'OptInOrOutPreference' } ) $out = [ordered]@{} foreach ($c in $checks) { $out[$c.id] = [ordered]@{ path = $c.path name = $c.name value = Get-Reg $c.path $c.name } } # Whole-key dumps where the interesting thing is "what else is in here". $out['_keys'] = [ordered]@{ gameConfigStore = Get-RegKey 'HKCU:\System\GameConfigStore' gameBar = Get-RegKey 'HKCU:\SOFTWARE\Microsoft\GameBar' gameDVR = Get-RegKey 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\GameDVR' multimediaSystemProfile = Get-RegKey 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Multimedia\SystemProfile' graphicsDrivers = Get-RegKey 'HKLM:\SYSTEM\CurrentControlSet\Control\GraphicsDrivers' priorityControl = Get-RegKey 'HKLM:\SYSTEM\CurrentControlSet\Control\PriorityControl' memoryManagement = Get-RegKey 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\Memory Management' mouse = Get-RegKey 'HKCU:\Control Panel\Mouse' gpuPreferences = Get-RegKey 'HKCU:\SOFTWARE\Microsoft\DirectX\UserGpuPreferences' } # Per-app compatibility shims (fullscreen optimisation opt-outs etc). $out['appCompatLayers'] = [ordered]@{ hkcu = Get-RegKey 'HKCU:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers' hklm = Get-RegKey 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\AppCompatFlags\Layers' } return $out } # --------------------------------------------------------------------------- # 8. boot configuration (needs elevation) # --------------------------------------------------------------------------- Add-Section 'bcd' { $out = [ordered]@{ elevated = $IsAdmin } if ($IsAdmin) { $out.currentRaw = Invoke-Native 'bcdedit' @('/enum', '{current}') } else { $out.note = 'skipped: requires Administrator' } return $out } # --------------------------------------------------------------------------- # 9. services # --------------------------------------------------------------------------- Add-Section 'services' { $svc = @(Get-CimSafe -ClassName Win32_Service) $all = @($svc | Select-Object Name, DisplayName, StartMode, State, ProcessId, @{ n = 'Path'; e = { $_.PathName } }, DelayedAutoStart) $out = [ordered]@{ count = $all.Count all = $all } # Named call-outs so the analysis side does not have to guess spellings. $interesting = @( 'SysMain', 'DiagTrack', 'dmwappushservice', 'WSearch', 'Spooler', 'Fax', 'WerSvc', 'DPS', 'WdiServiceHost', 'WdiSystemHost', 'PcaSvc', 'DusmSvc', 'XblAuthManager', 'XblGameSave', 'XboxGipSvc', 'XboxNetApiSvc', 'TabletInputService', 'TextInputManagementService', 'InstallService', 'WpnUserService', 'CDPSvc', 'CDPUserSvc', 'MapsBroker', 'RetailDemo', 'RemoteRegistry', 'lfsvc', 'SharedAccess', 'SSDPSRV', 'upnphost', 'NvTelemetryContainer', 'NVDisplay.ContainerLocalSystem', 'NvContainerLocalSystem', 'AMD External Events Utility', 'amd3dvcacheSvc', 'AMDRyzenMasterDriverV', 'Themes', 'SecurityHealthService', 'wscsvc', 'iphlpsvc', 'BITS', 'wuauserv', 'UsoSvc', 'WaaSMedicSvc', 'edgeupdate', 'edgeupdatem', 'GoogleChromeElevationService', 'gupdate', 'gupdatem', 'SteamService' ) $lookup = @{} foreach ($s in $all) { $lookup[$s.Name] = $s } $picked = [ordered]@{} foreach ($n in $interesting) { if ($lookup.ContainsKey($n)) { $picked[$n] = [ordered]@{ startMode = $lookup[$n].StartMode; state = $lookup[$n].State } } else { $picked[$n] = $null } } $out.notable = $picked return $out } # --------------------------------------------------------------------------- # 10. drivers - kernel + filter, the usual latency suspects # --------------------------------------------------------------------------- Add-Section 'drivers' { $out = [ordered]@{} $sys = @(Get-CimSafe -ClassName Win32_SystemDriver) $out.runningKernelDrivers = @($sys | Where-Object { $_.State -eq 'Running' } | Select-Object Name, DisplayName, StartMode, @{ n = 'Path'; e = { $_.PathName } }) $out.kernelDriverCount = @($sys).Count # Latency-relevant device classes. NOT Win32_PnPSignedDriver - that provider # walks the whole driver store and can take minutes. try { $out.devices = @(Get-PnpDevice -PresentOnly -ErrorAction Stop | Where-Object { $_.Class -in @('Display', 'Net', 'Mouse', 'Keyboard', 'HIDClass', 'Media', 'AudioEndpoint', 'USB', 'SCSIAdapter', 'System') } | Select-Object Class, FriendlyName, Status, InstanceId, Present | Sort-Object Class, FriendlyName) } catch { $out.devicesError = $_.Exception.Message } # Problem devices (yellow bang) - cheap and frequently the whole story. try { $out.problemDevices = @(Get-PnpDevice -PresentOnly -ErrorAction Stop | Where-Object { $_.Status -ne 'OK' } | Select-Object Class, FriendlyName, Status, InstanceId, ProblemDescription) } catch { $out.problemDevices = $null } if ($IsAdmin) { $out.filterDriversRaw = Invoke-Native 'fltmc' @('filters') } else { $out.filterDriversNote = 'skipped: requires Administrator' } return $out } # --------------------------------------------------------------------------- # 11. startup + scheduled tasks # --------------------------------------------------------------------------- Add-Section 'startup' { $out = [ordered]@{} $out.runHKLM = Get-RegKey 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' $out.runHKLMWow = Get-RegKey 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run' $out.runHKCU = Get-RegKey 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run' $out.runOnceHKCU = Get-RegKey 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce' $out.approvedRun = Get-RegKey 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run' $out.approvedRun32 = Get-RegKey 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\StartupApproved\Run' # StartupApproved records what Task Manager's Startup tab has toggled. The # value is 12 bytes: byte 0 is a flag whose low bit means DISABLED, bytes # 4-11 are a FILETIME of when it was changed. Decoded here because nobody # reading the report should be parsing hex to find out whether a startup # item runs, and because an entry with no matching Run value is a leftover # from uninstalled software rather than something that executes. $decodeApproved = { param($Key, $Scope, $RunKey) $rows = @() if (-not $Key) { return $rows } foreach ($p in $Key.GetEnumerator()) { $hex = "$($p.Value)" if ($hex.Length -lt 24) { continue } $flag = [Convert]::ToByte($hex.Substring(0, 2), 16) $ticks = 0L for ($i = 11; $i -ge 4; $i--) { $ticks = ($ticks -shl 8) -bor [Convert]::ToByte($hex.Substring($i * 2, 2), 16) } $rows += [ordered]@{ name = $p.Key scope = $Scope enabled = (($flag -band 1) -eq 0) changedUtc = $(if ($ticks -gt 0) { try { [DateTime]::FromFileTimeUtc($ticks).ToString('yyyy-MM-dd HH:mm:ss') } catch { $null } } else { $null }) # No Run value behind it: the software is gone, only the # approval record remains. Not something that runs. orphaned = -not ($RunKey -and $RunKey.Contains($p.Key)) } } return $rows } $out.startupApproved = @(& $decodeApproved $out.approvedRun 'HKCU' $out.runHKCU) + @(& $decodeApproved $out.approvedRun32 'HKLM' $out.runHKLM) $folders = @( [Environment]::GetFolderPath('Startup'), [Environment]::GetFolderPath('CommonStartup') ) $items = @() foreach ($f in $folders) { if ($f -and (Test-Path -LiteralPath $f)) { $items += @(Get-ChildItem -LiteralPath $f -ErrorAction SilentlyContinue | Select-Object @{ n = 'Folder'; e = { $f } }, Name, Length) } } $out.startupFolder = $items return $out } Add-Section 'scheduledTasks' { $out = [ordered]@{} try { $tasks = @(Get-ScheduledTask -ErrorAction Stop) } catch { return [ordered]@{ collectError = $_.Exception.Message } } # Microsoft tasks worth reporting whatever their trigger. The telemetry set # is idle/daily/maintenance triggered, so a logon-or-boot filter drops it # exactly when it is enabled and firing - which on an untuned PC is the # finding you went looking for. Background wakeups during play are a direct # frame-time hazard on a CPU-bound title. $msOfInterest = @( '\Microsoft\Windows\Application Experience\', '\Microsoft\Windows\Customer Experience Improvement Program\', '\Microsoft\Windows\Autochk\', '\Microsoft\Windows\DiskDiagnostic\', '\Microsoft\Windows\Flighting\', '\Microsoft\Windows\Feedback\', '\Microsoft\Windows\Maintenance\', '\Microsoft\Windows\Defrag\', '\Microsoft\Windows\WindowsUpdate\', '\Microsoft\Windows\UpdateOrchestrator\', '\Microsoft\XblGameSave\', '\Microsoft\Windows\GameSaveTask\' ) $rows = @() foreach ($t in $tasks) { $isMs = $t.TaskPath -like '\Microsoft\*' $triggers = @() foreach ($tr in @($t.Triggers)) { $triggers += $tr.CimClass.CimClassName } $isAutoStart = ($triggers -match 'Logon|Boot|Startup').Count -gt 0 $isOfInterest = $false foreach ($root in $msOfInterest) { if ($t.TaskPath -like ($root + '*')) { $isOfInterest = $true; break } } # Third-party in full; Microsoft when it autostarts, or when it belongs # to one of the roots above whatever its trigger and state. if ($isMs -and -not $isOfInterest -and -not ($t.State -ne 'Disabled' -and $isAutoStart)) { continue } $info = $null try { $info = Get-ScheduledTaskInfo -TaskName $t.TaskName -TaskPath $t.TaskPath -ErrorAction Stop } catch { } $rows += [ordered]@{ taskPath = $t.TaskPath taskName = $t.TaskName state = "$($t.State)" triggers = $triggers principalId = $t.Principal.UserId principalGroup = $t.Principal.GroupId runLevel = "$($t.Principal.RunLevel)" logonType = "$($t.Principal.LogonType)" actions = @(@($t.Actions) | ForEach-Object { ($_.Execute + ' ' + $_.Arguments).Trim() }) lastRunTime = if ($info) { Fmt-Date $info.LastRunTime } else { $null } lastTaskResult = if ($info) { $info.LastTaskResult } else { $null } } } $out.tasks = $rows $out.totalTaskCount = $tasks.Count $out.reportedCount = $rows.Count $out.note = 'Third-party tasks in full. Microsoft tasks when they autostart, plus the telemetry/maintenance/update roots whatever their trigger or state.' $out.microsoftRootsAlwaysIncluded = $msOfInterest return $out } # --------------------------------------------------------------------------- # 12. network # --------------------------------------------------------------------------- Add-Section 'network' { $out = [ordered]@{} try { $out.adapters = @(Get-NetAdapter -ErrorAction Stop | Select-Object Name, InterfaceDescription, Status, LinkSpeed, MediaType, DriverVersion, DriverDate, DriverProvider, ifIndex, FullDuplex, MtuSize) } catch { $out.adaptersError = $_.Exception.Message } # Offloads, interrupt moderation, RSS, EEE - the per-NIC latency surface. try { $out.advancedProperties = @(Get-NetAdapterAdvancedProperty -ErrorAction Stop | Select-Object Name, DisplayName, DisplayValue, RegistryKeyword, RegistryValue | Sort-Object Name, DisplayName) } catch { $out.advancedPropertiesError = $_.Exception.Message } try { $out.tcpSettings = @(Get-NetTCPSetting -ErrorAction Stop | Select-Object SettingName, MinRto, AutoTuningLevelLocal, EcnCapability, Timestamps, InitialCongestionWindow, CwndRestart, ForceWS) } catch { $out.tcpSettings = $null } try { $out.offloadGlobal = Get-NetOffloadGlobalSetting -ErrorAction Stop | Select-Object ReceiveSideScaling, ReceiveSegmentCoalescing, Chimney, TaskOffload, NetworkDirect, PacketCoalescingFilter } catch { $out.offloadGlobal = $null } # Shape only, no addresses - which adapters are live and whether DNS is set # manually is what tuning needs; the numbers identify the network. try { $out.ipConfig = @(Get-NetIPConfiguration -ErrorAction Stop | Select-Object InterfaceAlias, InterfaceIndex, @{ n = 'HasIPv4'; e = { [bool] $_.IPv4Address } }, @{ n = 'HasGateway'; e = { [bool] $_.IPv4DefaultGateway } }, @{ n = 'DnsServerCount'; e = { @($_.DNSServer | Where-Object { $_.AddressFamily -eq 2 } | ForEach-Object { $_.ServerAddresses }).Count } }) } catch { $out.ipConfig = $null } try { $out.qosPolicies = @(Get-NetQosPolicy -ErrorAction SilentlyContinue | Select-Object Name, AppPathNameMatchCondition, DSCPAction, ThrottleRateActionBitsPerSecond) } catch { $out.qosPolicies = $null } # Third-party network filter/LSP layers (the ExitLag class of problem). $out.winsockCatalogRaw = Invoke-Native 'netsh' @('winsock', 'show', 'catalog') if (@($out.winsockCatalogRaw).Count -gt 400) { $out.winsockCatalogRaw = @($out.winsockCatalogRaw)[0..399] + '' } $out.tcpGlobalRaw = Invoke-Native 'netsh' @('int', 'tcp', 'show', 'global') # Tuning values (TcpAckFrequency, MTU) sit next to hostname/domain/DHCP # addresses in this key. Keep the former, drop the latter. $tcpip = Get-RegKey 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' if ($tcpip) { $identifying = @('Hostname', 'NV Hostname', 'Domain', 'NV Domain', 'DhcpDomain', 'DhcpIPAddress', 'DhcpNameServer', 'DhcpDefaultGateway', 'DhcpServer', 'DhcpSubnetMask', 'NameServer', 'SearchList', 'DhcpDomainSearchList', 'IPAddress', 'DefaultGateway', 'SubnetMask') $clean = [ordered]@{} foreach ($k in $tcpip.Keys) { if ($identifying -notcontains $k) { $clean[$k] = $tcpip[$k] } } $tcpip = $clean } $out.tcpipParameters = $tcpip return $out } # --------------------------------------------------------------------------- # 13. processes snapshot # --------------------------------------------------------------------------- Add-Section 'processes' { $procs = @(Get-Process -ErrorAction SilentlyContinue) # Win32_Process gives an executable path and a parent PID for processes # whose path Get-Process cannot read. That matters because a protected # process with no path is exactly the one that looks alarming in a report: # a security suite's helper process can have no readable path, no command # line and too little CPU or memory to reach either top-25 list, and be # identifiable as part of the antivirus only by its parent being the AV's # main process. Parentage is cheap and often the whole answer. $wmiByPid = @{} $nameByPid = @{} foreach ($wp in @(Get-CimSafe -ClassName Win32_Process)) { $wmiByPid[[int] $wp.ProcessId] = $wp $nameByPid[[int] $wp.ProcessId] = $wp.Name } $grouped = $procs | Group-Object -Property ProcessName | ForEach-Object { # Priority and affinity for EVERY process, not just the memory-heavy # ones. "What else is allowed to run at Normal priority on the game's # cores" is the highest-value question here, and the offenders - RGB # agents, vendor tray apps, launcher helpers - are small in memory and # would never appear in a top-N-by-working-set list. Distinct values per # name keeps it compact. Both throw on protected processes. $prio = @() $aff = @() foreach ($p in $_.Group) { $v = $(try { "$($p.PriorityClass)" } catch { $null }) if ($v -and ($prio -notcontains $v)) { $prio += $v } # Cast only when there is something to cast: a protected process # yields $null, and [int64] $null is 0 - which reads as "pinned to # no cores" instead of "not readable". $a = $(try { $p.ProcessorAffinity } catch { $null }) if ($null -ne $a) { $a = [int64] $a if ($aff -notcontains $a) { $aff += $a } } } # Path and parent for the whole list, from WMI, so an unidentifiable # process name can still be traced to its owner. $paths = @() $parents = @() foreach ($p in $_.Group) { $wp = $wmiByPid[[int] $p.Id] if (-not $wp) { continue } if ($wp.ExecutablePath -and ($paths -notcontains $wp.ExecutablePath)) { $paths += $wp.ExecutablePath } $pn = $nameByPid[[int] $wp.ParentProcessId] if ($pn -and ($parents -notcontains $pn)) { $parents += $pn } } [ordered]@{ name = $_.Name count = $_.Count workingSetMB = [math]::Round((($_.Group | Measure-Object -Property WorkingSet64 -Sum).Sum) / 1MB, 1) cpuSeconds = [math]::Round((($_.Group | Measure-Object -Property CPU -Sum).Sum), 1) priorities = $prio affinities = $aff paths = $paths parents = $parents } } $out = [ordered]@{ total = $procs.Count # Scriptblock sort: a bare -Property name silently does not sort an # ordered hashtable on 5.1. byName = @($grouped | Sort-Object -Property { $_.workingSetMB } -Descending) } # Paths for the top consumers only - keeps the JSON bounded. By CPU as well # as memory: on a CPU-bound title the process stealing cycles is rarely the # one holding memory. $out.topByCpu = @($procs | Where-Object { $_.CPU } | Sort-Object CPU -Descending | Select-Object -First 25 | Select-Object ProcessName, Id, @{ n = 'CPUSeconds'; e = { [math]::Round($_.CPU, 1) } }, @{ n = 'WorkingSetMB'; e = { [math]::Round($_.WorkingSet64 / 1MB, 1) } }, @{ n = 'Path'; e = { $(try { $_.Path } catch { $null }) } }, @{ n = 'PriorityClass'; e = { $(try { "$($_.PriorityClass)" } catch { $null }) } }, @{ n = 'AffinityMask'; e = { $(try { $v = $_.ProcessorAffinity; if ($null -eq $v) { $null } else { [int64] $v } } catch { $null }) } }) $out.topByMemory = @($procs | Sort-Object WorkingSet64 -Descending | Select-Object -First 25 | Select-Object ProcessName, Id, @{ n = 'WorkingSetMB'; e = { [math]::Round($_.WorkingSet64 / 1MB, 1) } }, @{ n = 'CPUSeconds'; e = { if ($_.CPU) { [math]::Round($_.CPU, 1) } else { $null } } }, @{ n = 'Path'; e = { $(try { $_.Path } catch { $null }) } }, @{ n = 'PriorityClass'; e = { $(try { "$($_.PriorityClass)" } catch { $null }) } }, @{ n = 'AffinityMask'; e = { $(try { $v = $_.ProcessorAffinity; if ($null -eq $v) { $null } else { [int64] $v } } catch { $null }) } }) return $out } # --------------------------------------------------------------------------- # 14. installed software # --------------------------------------------------------------------------- Add-Section 'installedSoftware' { $paths = @( 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' ) $seen = @{} $list = @() foreach ($p in $paths) { $items = @(Get-ItemProperty -Path $p -ErrorAction SilentlyContinue) foreach ($i in $items) { if (-not $i.DisplayName) { continue } $key = "$($i.DisplayName)|$($i.DisplayVersion)" if ($seen.ContainsKey($key)) { continue } $seen[$key] = $true $list += [ordered]@{ name = $i.DisplayName version = $i.DisplayVersion publisher = $i.Publisher installDate = $i.InstallDate } } } return [ordered]@{ count = $list.Count programs = @($list | Sort-Object -Property { $_.name }) } } # --------------------------------------------------------------------------- # 15. security posture # --------------------------------------------------------------------------- Add-Section 'security' { $out = [ordered]@{} $av = @(Get-CimSafe -ClassName AntiVirusProduct -Namespace 'root\SecurityCenter2') $out.antivirusProducts = @($av | Select-Object displayName, productState, pathToSignedProductExe, timestamp) $dg = Get-CimSafe -ClassName Win32_DeviceGuard -Namespace 'root\Microsoft\Windows\DeviceGuard' if ($dg) { $dg = @($dg)[0] $out.deviceGuard = [ordered]@{ virtualizationBasedSecurityStatus = $dg.VirtualizationBasedSecurityStatus securityServicesConfigured = @($dg.SecurityServicesConfigured) securityServicesRunning = @($dg.SecurityServicesRunning) requiredSecurityProperties = @($dg.RequiredSecurityProperties) } } $out.hypervisorEnforcedCodeIntegrity = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\DeviceGuard\Scenarios\HypervisorEnforcedCodeIntegrity' 'Enabled' $out.vbsEnableVirtualizationBasedSecurity = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\DeviceGuard' 'EnableVirtualizationBasedSecurity' try { $mp = Get-MpComputerStatus -ErrorAction Stop $out.defender = [ordered]@{ realTimeProtectionEnabled = $mp.RealTimeProtectionEnabled antivirusEnabled = $mp.AntivirusEnabled amRunningMode = $mp.AMRunningMode isTamperProtected = $mp.IsTamperProtected } } catch { $out.defender = $null } # Scheduled scan timing. A full scan that fires while they play is a # multi-minute CPU tax on the cores the game needs - measured at 625% of one # core on the reference machine, mid-session. Moving it is a schedule change # inside the antivirus's own UI: not an exclusion, not a disable, and not a # reduction in protection. Collect the schedule, never the exclusion paths. try { $mpp = Get-MpPreference -ErrorAction Stop $out.defenderScanSchedule = [ordered]@{ scanParameters = "$($mpp.ScanParameters)" scanScheduleDay = "$($mpp.ScanScheduleDay)" scanScheduleTime = "$($mpp.ScanScheduleTime)" quickScanTime = "$($mpp.ScanScheduleQuickScanTime)" scanOnlyIfIdleEnabled = $mpp.ScanOnlyIfIdleEnabled disableCatchupFullScan = $mpp.DisableCatchupFullScan disableCatchupQuickScan = $mpp.DisableCatchupQuickScan randomizeScheduleTaskTimes = $mpp.RandomizeScheduleTaskTimes # Count only - the paths themselves name accounts and game folders. # Filtered, because @($null).Count is 1 and would invent an entry. exclusionPathCount = @($mpp.ExclusionPath | Where-Object { $_ }).Count exclusionProcessCount = @($mpp.ExclusionProcess | Where-Object { $_ }).Count } } catch { $out.defenderScanSchedule = $null $out.defenderScanScheduleNote = 'Get-MpPreference failed. On a consumer PC that almost always means Defender stood down because a third-party antivirus is registered - read antivirusProducts. That vendor has its own scan schedule which this collection cannot see.' } $out.hypervisorLaunchType = $(if ($IsAdmin) { (Invoke-Native 'bcdedit' @('/enum', '{current}')) -match 'hypervisorlaunchtype' } else { $null }) return $out } # --------------------------------------------------------------------------- # 16. timer resolution # --------------------------------------------------------------------------- Add-Section 'timers' { # powercfg /energy instead of NtQueryTimerResolution: same value, names the # holders too, and needs no P/Invoke. Elevation + ~15s, so it is opt-in. $out = [ordered]@{} $out.globalTimerResolutionRequests = Get-Reg 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\kernel' 'GlobalTimerResolutionRequests' if (-not $IncludeEnergyReport) { $out.note = 'platform timer resolution not measured; re-run elevated with -IncludeEnergyReport' return $out } if (-not $IsAdmin) { $out.note = 'skipped: -IncludeEnergyReport requires Administrator' return $out } try { $report = Join-Path $env:TEMP ("energy-report-" + $stamp + ".html") Invoke-Native 'powercfg' @('/energy', '/output', $report, '/duration', '10') | Out-Null if (Test-Path -LiteralPath $report) { $raw = Get-Content -LiteralPath $report -Raw -ErrorAction SilentlyContinue $text = $raw -replace '<[^>]+>', "`n" $lines = @($text -split "`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) $hits = @() for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match 'Timer Resolution|Requested Period|Requesting Process') { $upper = [math]::Max(0, $i - 1) $lower = [math]::Min($lines.Count - 1, $i + 3) $hits += ($lines[$upper..$lower] -join ' | ') } } $out.energyReportPath = $report $out.timerLines = @($hits | Select-Object -First 60) # First-number-after-the-label heuristic. powercfg repeats "Platform # Timer Resolution" as a WARNING heading, so this frequently lands on # the outstanding-request text instead of the current resolution # (measured 10000 on this box, which is a requested period, not the # resolution). Kept as a hint only - timerLines is the honest source. $m = [regex]::Match($text, 'Platform Timer Resolution[^0-9]*([0-9]+(?:\.[0-9]+)?)') if ($m.Success) { $out.platformTimerResolutionRaw = $m.Groups[1].Value } $out.platformTimerResolutionNote = 'UNRELIABLE first-match heuristic; read timerLines for the real requestors and periods' } else { $out.error = 'powercfg /energy produced no report' } } catch { $out.error = $_.Exception.Message } return $out } # --------------------------------------------------------------------------- # 17. crash / stability history # --------------------------------------------------------------------------- Add-Section 'events' { $out = [ordered]@{} $since = (Get-Date).AddDays(-45) function Get-Events { param([string] $Log, [int[]] $Ids, [int] $Max = 120) try { $ev = Get-WinEvent -FilterHashtable @{ LogName = $Log; Id = $Ids; StartTime = $since } -MaxEvents $Max -ErrorAction Stop return @($ev | Select-Object @{ n = 'time'; e = { $_.TimeCreated.ToString('yyyy-MM-dd HH:mm:ss') } }, Id, ProviderName, LevelDisplayName, @{ n = 'message'; e = { if ($_.Message) { ($_.Message -replace '\s+', ' ').Substring(0, [math]::Min(400, ($_.Message -replace '\s+', ' ').Length)) } else { $null } } }) } catch { return @() } } # 41 = unexpected power loss, 1001 = BugCheck, 6008 = dirty shutdown, # 7/51/153 = disk, 17/18/19 = WHEA, 219 = driver load failure, # 129 = storage reset, 4101 = display driver recovered (TDR). $out.system = Get-Events -Log 'System' -Ids @(41, 1001, 6008, 219, 129, 153, 51, 7, 17, 18, 19, 4101, 55, 98) # 1000 = app crash (names faulting module + offset), 1001 = WER bucket # record, 1002 = app hang. 1001 was missing and it is often the ONLY row # that survives for a crash whose dump was never written. $out.application = Get-Events -Log 'Application' -Ids @(1000, 1001, 1002) $out.minidumps = @() $out.minidumpsNote = 'KERNEL bugcheck dumps (BSODs) from Windows\Minidump - NOT application crashes. Rust crashes live in the rustCrashes section.' $dumpDir = Join-Path $env:SystemRoot 'Minidump' if (Test-Path -LiteralPath $dumpDir) { $out.minidumps = @(Get-ChildItem -LiteralPath $dumpDir -Filter '*.dmp' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 30 | Select-Object Name, @{ n = 'SizeMB'; e = { [math]::Round($_.Length / 1MB, 1) } }, @{ n = 'Written'; e = { $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') } }) } $memDump = Join-Path $env:SystemRoot 'MEMORY.DMP' if (Test-Path -LiteralPath $memDump) { $mi = Get-Item -LiteralPath $memDump $out.memoryDmp = [ordered]@{ sizeMB = [math]::Round($mi.Length / 1MB, 1); written = $mi.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') } } $out.werLocalDumps = Get-RegKey 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps' $out.crashControl = Get-RegKey 'HKLM:\SYSTEM\CurrentControlSet\Control\CrashControl' $out.windowValue = 'events limited to the last 45 days' return $out } # --------------------------------------------------------------------------- # 18. gaming: Steam / Rust # --------------------------------------------------------------------------- Add-Section 'gaming' { $out = [ordered]@{} $steamPath = Get-Reg 'HKCU:\SOFTWARE\Valve\Steam' 'SteamPath' if (-not $steamPath) { $steamPath = Get-Reg 'HKLM:\SOFTWARE\WOW6432Node\Valve\Steam' 'InstallPath' } $out.steamPath = $steamPath if ($steamPath -and (Test-Path -LiteralPath $steamPath)) { # Library roots, so we can find where Rust actually lives. $lf = Join-Path $steamPath 'steamapps\libraryfolders.vdf' if (Test-Path -LiteralPath $lf) { $txt = Get-Content -LiteralPath $lf -Raw -ErrorAction SilentlyContinue $out.libraryPaths = @([regex]::Matches($txt, '"path"\s+"([^"]+)"') | ForEach-Object { $_.Groups[1].Value -replace '\\\\', '\' }) } # Launch options for Rust (appid 252490) from every local Steam user. $userdata = Join-Path $steamPath 'userdata' $launch = @() if (Test-Path -LiteralPath $userdata) { $cfgs = @(Get-ChildItem -LiteralPath $userdata -Directory -ErrorAction SilentlyContinue | ForEach-Object { Join-Path $_.FullName 'config\localconfig.vdf' } | Where-Object { Test-Path -LiteralPath $_ }) foreach ($c in $cfgs) { try { $raw = Get-Content -LiteralPath $c -Raw -ErrorAction Stop # The appid appears in several unrelated blocks of # localconfig.vdf - licenses, playtime, tickets - and only # one of them is the app's settings. Taking the first hit # landed on a block with no LaunchOptions and reported "none # set" on a machine that has a full launch line (verified: # 6 occurrences, the settings block was the second). Scan # every occurrence and keep the first that actually carries # them. foreach ($m in [regex]::Matches($raw, '"252490"')) { $slice = $raw.Substring($m.Index, [math]::Min(4000, $raw.Length - $m.Index)) $mo = [regex]::Match($slice, '"LaunchOptions"\s+"([^"]*)"') if ($mo.Success) { if ($mo.Groups[1].Value) { $launch += $mo.Groups[1].Value } break } } } catch { } } } # Launch options are free text and routinely carry "+connect :". if ($null -ne $launch) { if ($launch -is [string]) { $launch = Protect-FreeText $launch } elseif ($launch -is [System.Collections.IEnumerable]) { $launch = @($launch | ForEach-Object { if ($_ -is [string]) { Protect-FreeText $_ } else { $_ } }) } } $out.rustLaunchOptions = $launch } # Rust install + client.cfg, searched across all known library roots. $roots = @() if ($out.libraryPaths) { $roots += $out.libraryPaths } if ($steamPath) { $roots += $steamPath } $rustDir = $null # NOT $r: PowerShell variable names are case-insensitive, so a loop # variable named $r shadows the script-scope $R section accumulator for the # rest of this scope, and every later $R['section'] lookup silently returns # nothing. That cost a debugging round on the overlay detection below. foreach ($libRoot in $roots) { $cand = Join-Path $libRoot 'steamapps\common\Rust' if (Test-Path -LiteralPath $cand) { $rustDir = $cand; break } } $out.rustPath = $rustDir if ($rustDir) { $exe = Join-Path $rustDir 'RustClient.exe' if (Test-Path -LiteralPath $exe) { $fi = Get-Item -LiteralPath $exe $out.rustClientExe = [ordered]@{ sizeMB = [math]::Round($fi.Length / 1MB, 1) modified = $fi.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') version = $fi.VersionInfo.FileVersion } } $cfg = Join-Path $rustDir 'cfg\client.cfg' if (Test-Path -LiteralPath $cfg) { # ConvertTo-PlainLines is mandatory here - see its definition. $out.rustClientCfg = ConvertTo-PlainLines (Get-Content -LiteralPath $cfg -ErrorAction SilentlyContinue) } $prefs = Join-Path $rustDir 'cfg\keys.cfg' $out.rustHasKeysCfg = (Test-Path -LiteralPath $prefs) } # Which other launchers and overlays are present is half the tuning story. # # This was a hardcoded list appended unconditionally - every tool reported # as present on every machine, including tools the box had never had. It is # a real test now: each candidate is matched against the installed-program # names and the running-process names already collected, only matches are # emitted, and every entry carries the evidence that produced it. Doing the # cross-reference once here beats asking every reader to re-derive it by # fuzzy-matching two long lists, which is the part that goes wrong (the # program is "Logitech G HUB", the process is "lghub_agent"). # # Both source sections run before this one, so the accumulator already holds # them. Addressed as $script:R deliberately - see the shadowing note above. $progNames = @() if ($script:R['installedSoftware'] -and $script:R['installedSoftware'].programs) { $progNames = @($script:R['installedSoftware'].programs | ForEach-Object { "$($_.name)" }) } $procNames = @() if ($script:R['processes'] -and $script:R['processes'].byName) { $procNames = @($script:R['processes'].byName | ForEach-Object { "$($_.name)" }) } # Emitted so an empty overlaysDetected can be told apart from a detection # that never had any data to match against. $out.overlayMatchSources = [ordered]@{ programs = $progNames.Count; processes = $procNames.Count } # programs = case-insensitive substring of the uninstall DisplayName. # processes = exact process name, no .exe (that is what byName carries). $overlayDefs = @( @{ name = 'RivaTuner Statistics Server'; programs = @('RivaTuner'); processes = @('RTSS', 'RTSSHooksLoader64') }, @{ name = 'MSI Afterburner'; programs = @('MSI Afterburner'); processes = @('MSIAfterburner') }, @{ name = 'OBS Studio'; programs = @('OBS Studio'); processes = @('obs64', 'obs32') }, @{ name = 'Discord'; programs = @('Discord'); processes = @('Discord', 'DiscordPTB', 'DiscordCanary') }, @{ name = 'NVIDIA overlay'; programs = @('GeForce Experience', 'NVIDIA App'); processes = @('NVIDIA Share', 'NVIDIA Overlay', 'NVIDIA app') }, @{ name = 'Razer Synapse'; programs = @('Razer Synapse'); processes = @('Razer Synapse Service', 'RzSDKService', 'RazerAppEngine') }, @{ name = 'Logitech G HUB'; programs = @('Logitech G HUB', 'LGHUB'); processes = @('lghub', 'lghub_agent', 'lghub_system_tray') }, @{ name = 'Corsair iCUE'; programs = @('iCUE'); processes = @('iCUE', 'iCUELink') }, @{ name = 'Armoury Crate'; programs = @('Armoury Crate'); processes = @('ArmouryCrate.Service', 'ArmouryCrate.UserSessionHelper', 'ArmourySocketServer') }, @{ name = 'MSI Center'; programs = @('MSI Center'); processes = @('MSI Center', 'MSI.CentralServer') }, @{ name = 'SteelSeries GG'; programs = @('SteelSeries GG'); processes = @('SteelSeriesGG', 'SteelSeriesEngine') }, @{ name = 'Wallpaper Engine'; programs = @('Wallpaper Engine'); processes = @('wallpaper32', 'wallpaper64') }, @{ name = 'CrosshairX'; programs = @('CrosshairX'); processes = @('CrosshairX') }, @{ name = 'HudSight'; programs = @('HudSight'); processes = @('HudSight') }, @{ name = 'ExitLag'; programs = @('ExitLag'); processes = @('ExitLag') }, @{ name = 'NoPing'; programs = @('NoPing'); processes = @('NoPing') } ) $out.overlaysDetected = @() foreach ($d in $overlayDefs) { $hitProg = @($progNames | Where-Object { $n = $_; @($d.programs | Where-Object { $n -like "*$_*" }).Count -gt 0 }) $hitProc = @($procNames | Where-Object { $d.processes -contains $_ }) if ($hitProg.Count -eq 0 -and $hitProc.Count -eq 0) { continue } $out.overlaysDetected += [ordered]@{ name = $d.name installed = ($hitProg.Count -gt 0) running = ($hitProc.Count -gt 0) matchedProgram = $(if ($hitProg.Count -gt 0) { $hitProg[0] } else { $null }) matchedProcesses = $(if ($hitProc.Count -gt 0) { ($hitProc -join ', ') } else { $null }) } } $out.overlaysDetectedCount = @($out.overlaysDetected).Count $out.overlayNote = 'Real detection as of schemaVersion 2: every entry matched an installed-program name or a running process and carries the evidence. ABSENCE IS NOT PROOF - a portable build with no uninstall entry, a renamed executable, or any tool not on this watchlist will not appear here. installed=true with running=false means it is present but was not running at collection time, which for an overlay is the question that matters. Process Lasso is deliberately excluded: it has its own section.' return $out } # --------------------------------------------------------------------------- # 19. Rust crash history - every place a Rust fault can land # --------------------------------------------------------------------------- # # Windows\Minidump (section 17) holds KERNEL bugchecks and tells you nothing # about a game that closed itself. A Rust fault can land in six different # places and the most useful one is usually NOT the Windows event log: # UnityCrashHandler64.exe is attached to RustClient and handles the exception # itself, so WER is frequently never invoked and Event 1000 never appears. # Collect all of them and let the analysis decide which fired. Add-Section 'rustCrashes' { $out = [ordered]@{} $rustDir = $null; $steamPath = $null if ($R['gaming']) { $rustDir = $R['gaming'].rustPath; $steamPath = $R['gaming'].steamPath } $out.rustPathUsed = $rustDir $rx = '(?i)rust|unity|easyanticheat|eac' function Get-Head { # First N lines of a crash log - the exception and the top of the stack. param([string] $Path, [int] $Max = 40) try { $l = Get-Content -LiteralPath $Path -TotalCount $Max -ErrorAction Stop return @(ConvertTo-PlainLines $l | ForEach-Object { if ($_.Length -gt 300) { $_.Substring(0, 300) } else { $_ } }) } catch { return @() } } # --- 1. Unity crash folders (the primary source on this engine) --------- # Only ever enumerate a folder literally named Crashes. The Rust data # folder also contains a 'Unity' directory holding analytics GUIDs, and # treating the data root as a crash root reports it as a phantom crash. $out.unityCrashes = @() $rustDataDirs = @() if ($env:LOCALAPPDATA) { $rustDataDirs += (Join-Path $env:LOCALAPPDATA 'Temp\Facepunch Studios LTD\Rust') } if ($env:TEMP) { $rustDataDirs += (Join-Path $env:TEMP 'Facepunch Studios LTD\Rust') } if ($env:USERPROFILE) { $rustDataDirs += (Join-Path $env:USERPROFILE 'AppData\LocalLow\Facepunch Studios LTD\Rust') } $out.rustDataDirs = @($rustDataDirs | Sort-Object -Unique) $unityRoots = @($rustDataDirs | ForEach-Object { Join-Path $_ 'Crashes' }) $seenRoot = @{} foreach ($root in $unityRoots) { if (-not $root -or $seenRoot[$root]) { continue } $seenRoot[$root] = $true if (-not (Test-Path -LiteralPath $root)) { continue } try { $dirs = @(Get-ChildItem -LiteralPath $root -Directory -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 15) foreach ($d in $dirs) { $rec = [ordered]@{ folder = $d.Name root = $root written = $d.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') files = @() } foreach ($f in @(Get-ChildItem -LiteralPath $d.FullName -File -ErrorAction SilentlyContinue)) { $rec.files += ($f.Name + ' (' + [math]::Round($f.Length / 1KB, 1) + ' KB)') if ($f.Name -match '(?i)^(error|crash)\.log$') { $rec.errorLogHead = @(Get-Head $f.FullName 40 | ForEach-Object { Protect-FreeText $_ }) } } $out.unityCrashes += $rec } } catch { } } $out.unityCrashRootsChecked = @($seenRoot.Keys) # --- 2. WER LocalDumps files (per-app full dumps, if configured) -------- $out.localDumpFiles = @() $dumpFolders = @() $cfgFolder = Get-Reg 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps' 'DumpFolder' if ($cfgFolder) { $dumpFolders += [Environment]::ExpandEnvironmentVariables($cfgFolder) } $rustKey = 'HKLM:\SOFTWARE\Microsoft\Windows\Windows Error Reporting\LocalDumps\RustClient.exe' $out.localDumpsRustClientKey = Get-RegKey $rustKey $rustFolder = Get-Reg $rustKey 'DumpFolder' if ($rustFolder) { $dumpFolders += [Environment]::ExpandEnvironmentVariables($rustFolder) } if ($env:LOCALAPPDATA) { $dumpFolders += (Join-Path $env:LOCALAPPDATA 'CrashDumps') } $seenDump = @{} foreach ($df in $dumpFolders) { if (-not $df -or $seenDump[$df] -or -not (Test-Path -LiteralPath $df)) { continue } $seenDump[$df] = $true try { foreach ($f in @(Get-ChildItem -LiteralPath $df -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 25)) { $out.localDumpFiles += [ordered]@{ name = $f.Name folder = $df sizeMB = [math]::Round($f.Length / 1MB, 2) written = $f.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') isRust = [bool]($f.Name -match $rx) } } } catch { } } $out.localDumpFoldersChecked = @($seenDump.Keys) # --- 3. WER report queue/archive (survives when the dump does not) ------ # Report.wer carries AppName, ModName, ExceptionCode and ExceptionOffset - # enough to tell an engine bug from a driver fault without a debugger. $out.werReports = @() $werRoots = @() if ($env:ProgramData) { $werRoots += (Join-Path $env:ProgramData 'Microsoft\Windows\WER\ReportQueue'); $werRoots += (Join-Path $env:ProgramData 'Microsoft\Windows\WER\ReportArchive') } if ($env:LOCALAPPDATA) { $werRoots += (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\WER\ReportQueue'); $werRoots += (Join-Path $env:LOCALAPPDATA 'Microsoft\Windows\WER\ReportArchive') } $werSeen = 0 foreach ($wr in $werRoots) { if (-not (Test-Path -LiteralPath $wr)) { continue } try { $dirs = @(Get-ChildItem -LiteralPath $wr -Directory -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 60) foreach ($d in $dirs) { $werSeen++ if ($out.werReports.Count -ge 20) { break } $wer = Join-Path $d.FullName 'Report.wer' if (-not (Test-Path -LiteralPath $wer)) { continue } try { $txt = Get-Content -LiteralPath $wer -Raw -ErrorAction Stop } catch { continue } if ($txt -notmatch $rx) { continue } $keep = @(ConvertTo-PlainLines ($txt -split "`r?`n") | Where-Object { $_ -match '^(EventType|AppName|AppPath|AppVersion|ModName|ModVersion|ModTimeStamp|ExceptionCode|ExceptionOffset|FriendlyEventName|Sig\[\d+\]\.(Name|Value))=' } | Select-Object -First 30) $out.werReports += [ordered]@{ folder = $d.Name root = $wr written = $d.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') fields = $keep } } } catch { } } $out.werReportDirsScanned = $werSeen $out.werNote = 'Only reports whose Report.wer mentions rust/unity/easyanticheat are kept. WER is often EMPTY for Rust because UnityCrashHandler64 handles the fault first - an empty list is not evidence of no crashes.' # --- 4. Unity player log in the Rust folder ----------------------------- # EAC's launcher injects -logfile output_log.txt. The tail holds the # exception context; the whole file can be tens of MB, so filter it. # Unity writes Player.log / Player-prev.log into the LocalLow data folder, # NOT the install folder - looking only in the game directory misses them # entirely on any machine whose launcher does not pass -logfile. $out.playerLogs = @() $logPaths = @() if ($rustDir) { foreach ($ln in @('output_log.txt', 'output_log_prev.txt', 'Player.log', 'Player-prev.log')) { $logPaths += (Join-Path $rustDir $ln) } } foreach ($dd in $rustDataDirs) { foreach ($ln in @('Player.log', 'Player-prev.log', 'output_log.txt')) { $logPaths += (Join-Path $dd $ln) } } $seenLog = @{} foreach ($lp in $logPaths) { if ($seenLog[$lp]) { continue } $seenLog[$lp] = $true if (-not (Test-Path -LiteralPath $lp)) { continue } $fi = Get-Item -LiteralPath $lp $ln = $fi.Name $rec = [ordered]@{ name = $ln folder = $fi.DirectoryName sizeMB = [math]::Round($fi.Length / 1MB, 2) written = $fi.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') } try { $tail = @(Get-Content -LiteralPath $lp -Tail 400 -ErrorAction Stop) $rec.crashLines = @(ConvertTo-PlainLines $tail | Where-Object { $_ -match '(?i)(exception|stack ?trace|crash|fatal|access violation|0x[0-9a-f]{8}|unable to|failed to|out of memory|d3d|gpu (hang|reset)|assert)' } | Select-Object -Last 40 | ForEach-Object { if ($_.Length -gt 300) { $_.Substring(0, 300) } else { $_ } } | ForEach-Object { Protect-FreeText $_ }) } catch { $rec.readError = $_.Exception.Message } $out.playerLogs += $rec } # Archived / rotated logs, if the player keeps them. $out.playerLogFiles = @() foreach ($dd in @(@($rustDir) + $rustDataDirs)) { if (-not $dd -or -not (Test-Path -LiteralPath $dd)) { continue } try { $out.playerLogFiles += @(Get-ChildItem -LiteralPath $dd -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -match '(?i)^(output_log|Player).*\.(txt|log)$' } | Sort-Object LastWriteTime -Descending | Select-Object -First 12 | ForEach-Object { $_.Name + ' ' + [math]::Round($_.Length / 1MB, 2) + ' MB ' + $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') }) } catch { } } # --- 5. Steam's own dump folder ---------------------------------------- $out.steamDumps = @() if ($steamPath) { $sd = Join-Path $steamPath 'dumps' if (Test-Path -LiteralPath $sd) { try { $out.steamDumps = @(Get-ChildItem -LiteralPath $sd -File -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 20 | ForEach-Object { $_.Name + ' ' + [math]::Round($_.Length / 1KB, 1) + ' KB ' + $_.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') }) } catch { } } } # --- 6. EasyAntiCheat logs (EAC terminates the client on its own) ------- $out.eacLogs = @() $eacRoots = @() if ($rustDir) { $eacRoots += (Join-Path $rustDir 'EasyAntiCheat'); $eacRoots += (Join-Path $rustDir 'Logs') } $eacRoots += 'C:\Program Files (x86)\EasyAntiCheat_EOS\Logs' if ($env:LOCALAPPDATA) { $eacRoots += (Join-Path $env:LOCALAPPDATA 'EasyAntiCheat_EOS\Logs') } foreach ($er in $eacRoots) { if (-not (Test-Path -LiteralPath $er)) { continue } try { foreach ($f in @(Get-ChildItem -LiteralPath $er -File -Filter '*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 6)) { $out.eacLogs += [ordered]@{ name = $f.Name folder = $er sizeKB = [math]::Round($f.Length / 1KB, 1) written = $f.LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') tail = @(ConvertTo-PlainLines (Get-Content -LiteralPath $f.FullName -Tail 25 -ErrorAction SilentlyContinue) | ForEach-Object { if ($_.Length -gt 300) { $_.Substring(0, 300) } else { $_ } } | ForEach-Object { Protect-FreeText $_ }) } } } catch { } } # --- 7. Application-log rows that name Rust ---------------------------- # Cross-cut of section 17's Application events, so the analysis does not # have to re-derive which of them belong to the game. $out.rustAppEvents = @() if ($R['events'] -and $R['events'].application) { $out.rustAppEvents = @(@($R['events'].application) | Where-Object { $_ -and $_.message -match $rx }) } $out.summary = [ordered]@{ unityCrashFolders = @($out.unityCrashes).Count localDumpFiles = @($out.localDumpFiles | Where-Object { $_ }).Count rustLocalDumps = @($out.localDumpFiles | Where-Object { $_ -and $_.isRust }).Count werReportsMatched = @($out.werReports | Where-Object { $_ }).Count playerLogs = @($out.playerLogs | Where-Object { $_ }).Count steamDumps = @($out.steamDumps | Where-Object { $_ }).Count eacLogs = @($out.eacLogs | Where-Object { $_ }).Count rustAppEvents = @($out.rustAppEvents | Where-Object { $_ }).Count } $out.note = 'Six independent sources. UnityCrashHandler64 usually handles the fault before WER sees it, so unityCrashes + playerLogs are the sources that fire most often for Rust; an empty werReports/rustAppEvents pair is normal and is NOT evidence the game has not crashed.' return $out } # --------------------------------------------------------------------------- # 20. Tuning tools - the settings inside them, not just their presence # --------------------------------------------------------------------------- # # Same class of gap as the processLasso section below. installedSoftware can # say a program exists and processes can say it is running; neither can say # what it is configured to do, and for these three that configuration silently # overrides something the player believes they control: # # RTSS - its framerate limiter beats the in-game limiter and Reflex # Afterburner - an overclock applied at startup, which the player will not # mention when reporting instability # Discord - hardware acceleration puts a second GPU client on the card # # Deliberately narrow. Each of these has a large config surface and almost all # of it is UI state; only the few keys that change frame time are carried. Add-Section 'tuningTools' { $out = [ordered]@{} function Read-IniValues { # Pull a named subset out of an INI. Whitespace around '=' is real: # Afterburner writes "UnlockVoltageControl\t\t= 1". param([string] $Path, [string[]] $Keys) $res = [ordered]@{} $txt = $(try { Get-Content -LiteralPath $Path -ErrorAction Stop } catch { $null }) if ($null -eq $txt) { return $res } foreach ($line in (ConvertTo-PlainLines $txt)) { $i = $line.IndexOf('=') if ($i -lt 1) { continue } $k = $line.Substring(0, $i).Trim() if ($Keys -notcontains $k) { continue } if ($res.Contains($k)) { continue } $v = $line.Substring($i + 1).Trim() if ($v.Length -gt 200) { $v = $v.Substring(0, 200) + '...' } $res[$k] = $v } return $res } # --- RivaTuner Statistics Server ------------------------------------- # NOT INSTALLED on the reference machine, so everything below the install # check is written from RTSS's documented layout and has never executed # against a real install. A populated rtss.profiles is unverified output; # say so rather than reporting a frame cap as fact. $rtssDir = $null foreach ($cand in @( (Get-Reg 'HKLM:\SOFTWARE\WOW6432Node\Unwinder\RTSS' 'InstallDir'), (Get-Reg 'HKLM:\SOFTWARE\Unwinder\RTSS' 'InstallDir'), (Join-Path ${env:ProgramFiles(x86)} 'RivaTuner Statistics Server'), (Join-Path $env:ProgramFiles 'RivaTuner Statistics Server') )) { if ($cand -and (Test-Path -LiteralPath "$cand")) { $rtssDir = "$cand"; break } } $out.rtss = [ordered]@{ installed = [bool]$rtssDir; installDir = $rtssDir } if ($rtssDir) { $limitKeys = @('Limit', 'LimitDenominator', 'LimitTime', 'SyncLimit', 'ScanLineSync') $profs = @() foreach ($pf in @(Get-ChildItem -LiteralPath (Join-Path $rtssDir 'Profiles') -File -ErrorAction SilentlyContinue | Select-Object -First 40)) { $v = Read-IniValues $pf.FullName $limitKeys if (@($v.Keys).Count -eq 0) { continue } $profs += [ordered]@{ profile = $pf.Name; values = $v } } $out.rtss.profiles = $profs $out.rtss.parseNote = 'Unverified parse path - see the comment in collect.ps1. Limit is a frames-per-second cap; 0 means no cap.' } # --- MSI Afterburner -------------------------------------------------- # Not collected in order to advise on overclocking - that stays banned # bans that outright. Collected because an overclock applied automatically # at startup is the first suspect when a machine is unstable or a game is # crashing, and it is the thing players least often think to mention. $abDir = $null foreach ($cand in @( (Join-Path ${env:ProgramFiles(x86)} 'MSI Afterburner'), (Join-Path $env:ProgramFiles 'MSI Afterburner') )) { if (Test-Path -LiteralPath $cand) { $abDir = $cand; break } } $out.afterburner = [ordered]@{ installed = [bool]$abDir; installDir = $abDir } if ($abDir) { $profDir = Join-Path $abDir 'Profiles' $main = Join-Path $profDir 'MSIAfterburner.cfg' if (-not (Test-Path -LiteralPath $main)) { $main = Join-Path $abDir 'MSIAfterburner.cfg' } # The main cfg is ~44KB and nearly all of it is window state. Only the # keys that decide whether an overclock is applied without asking. $out.afterburner.settings = Read-IniValues $main @( 'StartWithWindows', 'StartMinimized', 'StartupDelay', 'LockProfiles', 'UnlockVoltageControl', 'UnlockVoltageMonitoring', 'ForceConstantVoltage') $ocKeys = @('CoreClkBoost', 'MemClkBoost', 'ShaderClkBoost', 'CoreVoltageBoost', 'PowerLimit', 'ThermalLimit', 'ThermalPrioritize', 'FanSpeed') $profs = @() foreach ($pf in @(Get-ChildItem -LiteralPath $profDir -Filter '*.cfg' -File -ErrorAction SilentlyContinue | Select-Object -First 20)) { if ($pf.Name -eq 'MSIAfterburner.cfg') { continue } $v = Read-IniValues $pf.FullName $ocKeys if (@($v.Keys).Count -eq 0) { continue } $profs += [ordered]@{ profile = $pf.Name; values = $v } } $out.afterburner.profiles = $profs $out.afterburner.note = 'Clock boosts are in kHz and are OFFSETS from stock, so 90000 is +90MHz and 0 is stock. A profile file existing does not mean it is applied; StartWithWindows plus a profile loaded at startup is what makes an overclock automatic.' } # --- Discord ---------------------------------------------------------- # Without this the overlay finding can only say "Discord is installed". # The in-game OVERLAY toggle is NOT in this file - it lives server-side on # their account - so the absence of an overlay field here is not evidence # the overlay is off. Hardware acceleration is the part that is readable # and it puts a second GPU client on the card while the game runs. $out.discord = @() foreach ($chan in @('discord', 'discordptb', 'discordcanary')) { $sp = Join-Path $env:APPDATA "$chan\settings.json" if (-not (Test-Path -LiteralPath $sp)) { continue } $row = [ordered]@{ channel = $chan } try { $s = (Get-Content -LiteralPath $sp -Raw -ErrorAction Stop) | ConvertFrom-Json foreach ($k in @('enableHardwareAcceleration', 'audioSubsystem', 'OPEN_ON_STARTUP', 'MINIMIZE_TO_TRAY', 'openasar', 'chromiumSwitches', 'debugLogging')) { $pv = $s.PSObject.Properties[$k] $row[$k] = $(if ($pv) { "$($pv.Value)" } else { $null }) } } catch { $row.readError = $_.Exception.Message } $out.discord += $row } $out.discordNote = 'settings.json has no overlay field; the overlay toggle is stored on their Discord account, not on disk. Never report the overlay as off from this section.' $out.note = 'Software whose CONFIGURATION moves frame time while being invisible in installedSoftware. Same class as the processLasso section: presence is not the finding, the settings are.' return $out } # --------------------------------------------------------------------------- # 21. Process Lasso - installed, what is running, and what it enforces # --------------------------------------------------------------------------- # # Its own section because the three questions people ask about it are answered # in three different places, and only two of those places exist elsewhere in # this file. "Is it installed" is in installedSoftware; "which of its processes # are running" is in processes; "what rules is it actually applying" is in # neither - and that last one is the only one that changes frame time. # # The rules are enforced by the ProcessGovernor SERVICE. The main window and # the tray session agent are optional and play no part in enforcement, so an # install can legitimately run governor-only. Registry GUIStart is the durable # answer to "does the UI start"; a process snapshot only says "not right now". # # Why it matters for Rust specifically: a persistent Process Lasso affinity # rule OVERRIDES the -cpu_affinity launch option. Somebody who set a launch # option and forgot a stale rule is running the rule, not the flag. # # Encoding: prolasso.ini is UTF-16 LE WITH a BOM, .profile is UTF-16 LE with # NO BOM. Decode from bytes or both come back split by null characters. Add-Section 'processLasso' { $out = [ordered]@{} $reg = Get-RegKey 'HKLM:\SOFTWARE\ProcessLasso' $installDir = '' $configDir = '' if ($reg) { $installDir = "$($reg['Install_Dir'])" $configDir = "$($reg['ConfigFolderEx'])" } # Fall back to the defaults only for the existence test - never assume the # installer used them. if (-not $installDir) { $installDir = Join-Path $env:ProgramFiles 'Process Lasso' } if (-not $configDir) { $configDir = Join-Path $env:ProgramData 'ProcessLasso\config' } $exe = Join-Path $installDir 'ProcessLasso.exe' $out.installed = [bool]($reg -or (Test-Path -LiteralPath $exe)) if (-not $out.installed) { $out.note = 'Process Lasso is not installed. Every other field in this section is absent by design, not by failure.' return $out } $out.installDir = $installDir $out.configDir = $configDir # FileVersion is blank on the shipped Process Lasso binaries; the version # lives in ProductVersion. Reading only FileVersion reports it as unknown. $out.version = $(try { $vi = (Get-Item -LiteralPath $exe -ErrorAction Stop).VersionInfo if ($vi.ProductVersion) { "$($vi.ProductVersion)".Trim() } else { "$($vi.FileVersion)".Trim() } } catch { $null }) $out.registry = $reg # The service is the whole product as far as enforcement goes. $svc = @(Get-CimSafe -ClassName Win32_Service -Filter "Name='ProcessGovernor'") if ($svc.Count -gt 0) { $out.service = [ordered]@{ displayName = $svc[0].DisplayName startMode = $svc[0].StartMode state = $svc[0].State processId = $svc[0].ProcessId startName = $svc[0].StartName path = $svc[0].PathName } } else { $out.service = $null } # The three processes an install can have running. Only the first one is # required for the rule table to apply. $procMap = [ordered]@{ processGovernor = 'ProcessGovernor' gui = 'ProcessLasso' sessionAgent = 'bitsumsessionagent' } $out.processes = [ordered]@{} foreach ($e in $procMap.GetEnumerator()) { $ps = @(Get-Process -Name $e.Value -ErrorAction SilentlyContinue) $row = [ordered]@{ processName = $e.Value; running = ($ps.Count -gt 0); count = $ps.Count } if ($ps.Count -gt 0) { $row.priority = $(try { "$($ps[0].PriorityClass)" } catch { $null }) # Same trap as the processes section: [int64] $null is 0, which # reads as "pinned to no cores" instead of "could not read". $a = $(try { $ps[0].ProcessorAffinity } catch { $null }) $row.affinityMask = $(if ($null -ne $a) { [int64] $a } else { $null }) } $out.processes[$e.Key] = $row } function Get-PlText { # Byte-level decode: handles the BOM'd ini and the BOM-less .profile # without depending on the console codepage. param([string] $Path) try { $b = [System.IO.File]::ReadAllBytes($Path) } catch { return $null } if ($b.Length -lt 2) { return '' } if ($b[0] -eq 0xFF -and $b[1] -eq 0xFE) { return [System.Text.Encoding]::Unicode.GetString($b, 2, $b.Length - 2) } if ($b[1] -eq 0) { return [System.Text.Encoding]::Unicode.GetString($b) } return [System.Text.Encoding]::UTF8.GetString($b) } # ConfigPasswordMD5 holds a hash of the config-lock password when one is # set. Nothing here needs it and this file gets sent to somebody else. $redactKeys = @('ConfigPasswordMD5', 'LicenseKey', 'SerialNumber', 'RegCode') function ConvertFrom-PlIni { param([string] $Text, [string[]] $Redact) $settings = [ordered]@{} $dupes = @() if ($null -eq $Text) { return [ordered]@{ settings = $settings; duplicateKeys = $dupes } } foreach ($line in ($Text -split "`r?`n")) { $l = $line.Trim() if (-not $l) { continue } if ($l[0] -eq ';' -or $l[0] -eq '#' -or $l[0] -eq '[') { continue } $i = $l.IndexOf('=') if ($i -lt 1) { continue } $k = $l.Substring(0, $i) $v = $l.Substring($i + 1) if ($Redact -contains $k) { $v = '' } if ($v.Length -gt 4000) { $v = $v.Substring(0, 4000) + '...truncated' } # Process Lasso reads only the FIRST occurrence of a key and # silently ignores every later one. Keep the first, record the # rest: a duplicated DefaultPriorities or DefaultAffinitiesEx line # means part of their rule table is dead and never applied. if ($settings.Contains($k)) { $dupes += $k; continue } $settings[$k] = $v } return [ordered]@{ settings = $settings; duplicateKeys = $dupes } } $iniPath = Join-Path $configDir 'prolasso.ini' $out.configPath = $iniPath $out.config = $null $out.configDuplicateKeys = @() if (Test-Path -LiteralPath $iniPath) { $out.configModified = $(try { (Get-Item -LiteralPath $iniPath).LastWriteTime.ToString('yyyy-MM-dd HH:mm:ss') } catch { $null }) $parsed = ConvertFrom-PlIni (Get-PlText $iniPath) $redactKeys $out.config = $parsed.settings $out.configDuplicateKeys = @($parsed.duplicateKeys) } else { $out.configNote = 'prolasso.ini was not found at the path named by the Process Lasso registry key. Installed, but with no rule table to enforce.' } # Named call-outs so the analysis does not have to guess spellings, and so # a setting that does not exist on their build is visibly null rather than # merely missing. Every name below was verified present in a real 17.x ini. $notableKeys = @( 'Version', # the rule table itself 'DefaultPriorities', 'DefaultAffinitiesEx', 'DefaultIOPriorities', 'DefaultMemoryPriorities', 'DefaultGPUPriorities', 'DefaultPowerSchemes', 'EfficiencyMode', 'UseEfficiencyMode', 'CPUSets', 'NamedAffinities', 'DoNotAdjustAffinityIfCustomized', # ProBalance - the part that can lower a running game's priority 'OocOn', 'OocExclusions', 'RestrainByAffinity', 'RestraintAffinity', 'LowerToIdleInsteadOfBelowNormal', 'ExcludeForegroundProcesses2', 'ExcludeServices', 'DisableProBalanceWhenSysIdle', 'TameOnlyNormal', 'TotalProcessorUsageBeforeRestraint', 'PerProcessUsageBeforeRestraint', # gaming mode and power - can move the active power plan underneath them 'GamingModeEnabled', 'GamingModeEngageForSteam', 'GamingChangePowerPlan', 'AutomaticGamingModeProcessPaths', 'TargetPowerPlan', 'ForcedMode', 'EnergySaverEnabled', 'EnergySaverForceActivePowerProfile', 'DisableEnergySaverDuringGamingMode', 'OocDisableCoreParkingWhileIn', # memory and timers 'SmartTrimIsEnabled', 'SmartTrimClearStandbyList', 'SmartTrimClearFileCache', 'SmartTrimIntervalMins', 'ClearStandbyFreeRAMThresholdMB', 'SetTimerResolutionAtStartup', # foreground boosting and scope 'BoostForegroundProcess', 'ForegroundBoostPriorityClass', 'ForegroundBoostGPU', 'ManageOnlyCurrentUser', 'KeepRunningProcessesEx', 'InstanceLimitedProcesses', 'ProcessThrottles', 'CPULimitRules', 'WatchdogRules2' ) $picked = [ordered]@{} foreach ($k in $notableKeys) { $picked[$k] = $(if ($out.config -and $out.config.Contains($k)) { $out.config[$k] } else { $null }) } $out.notable = $picked # Config profiles are an optional feature: each subdirectory of the config # folder holds a complete alternative prolasso.ini, and one can be swapped # in automatically when a named process starts. Most installs have none. # Where they exist the ACTIVE profile is what is in force, so reading only # the root ini would describe rules that are not running. $out.profiles = @() foreach ($d in @(Get-ChildItem -LiteralPath $configDir -Directory -ErrorAction SilentlyContinue)) { $pi = Join-Path $d.FullName 'prolasso.ini' if (-not (Test-Path -LiteralPath $pi)) { continue } $pp = ConvertFrom-PlIni (Get-PlText $pi) $redactKeys $out.profiles += [ordered]@{ name = $d.Name defaultPriorities = $pp.settings['DefaultPriorities'] defaultAffinitiesEx = $pp.settings['DefaultAffinitiesEx'] efficiencyMode = $pp.settings['EfficiencyMode'] duplicateKeys = @($pp.duplicateKeys) } } # .profile names the standing profile. The rule that SWAPS profiles lives # in the registry rather than the ini, and the service reads it only at # startup - so a rule edited without a service restart is not yet live. $activePath = Join-Path $configDir '.profile' $out.activeProfile = $(if (Test-Path -LiteralPath $activePath) { "$(Get-PlText $activePath)".Trim() } else { $null }) $out.configSwitcherRules = $(if ($reg) { $reg['ConfigSwitcherRules'] } else { $null }) # Governor-only = rules enforced with no UI process loaded. GUIStart is the # setting behind it; the process rows confirm it at this instant. $gui = $(if ($reg) { $reg['GUIStart'] } else { $null }) $out.guiStartsAtLogin = $(if ($null -eq $gui) { $null } else { ("$gui" -ne '0') }) $out.sessionAgentInstalled = $(if ($reg) { ("$($reg['InstalledSessionAgent'])" -eq '1') } else { $null }) $out.governorRunning = ($null -ne $out.service -and $out.service.state -eq 'Running') $out.governorOnly = ($out.governorRunning -and -not $out.processes.gui.running -and -not $out.processes.sessionAgent.running) $out.note = 'The ProcessGovernor service enforces the rules; the GUI and tray agent do not. A persistent affinity rule here OVERRIDES a -cpu_affinity launch option, so the two must agree. Cross-check what is genuinely in force against processes.byName[].priorities and .affinities.' return $out } # --------------------------------------------------------------------------- # 22. misc / environment # --------------------------------------------------------------------------- Add-Section 'environment' { # No computerName / userName here by design. return [ordered]@{ processorArch = $env:PROCESSOR_ARCHITECTURE processorId = $env:PROCESSOR_IDENTIFIER numberOfProcessors = $env:NUMBER_OF_PROCESSORS firmwareType = $env:firmware_type systemDrive = $env:SystemDrive timeZone = (Get-TimeZone -ErrorAction SilentlyContinue).Id culture = (Get-Culture).Name uiCulture = (Get-UICulture).Name } } # --------------------------------------------------------------------------- # finish # --------------------------------------------------------------------------- $runStopwatch.Stop() $R['_meta'] = [ordered]@{ collectorVersion = $CollectorVersion schemaVersion = $SchemaVersion collectedAtLocal = (Get-Date).ToString('yyyy-MM-dd HH:mm:ss K') collectedAtUtc = (Get-Date).ToUniversalTime().ToString('yyyy-MM-dd HH:mm:ss') + 'Z' elevated = $IsAdmin anonymized = $true durationSeconds = [math]::Round($runStopwatch.Elapsed.TotalSeconds, 1) sectionTimingsMs = $Timings warnings = @($Warnings) readOnly = $true } if (-not $Quiet) { Write-Host "" Write-Host " Writing the report..." -ForegroundColor Gray } # -Detailed restores the per-section size/time table. Maintainer tool: it is # how a section that balloons or stalls gets localised. Never shown by default, # because it means nothing to the person actually running this. if ($Detailed) { Say ""; Say " section / size / time:" } # Section by section, not one ConvertTo-Json call: 5.1's JSON writer can # balloon on an unexpected object graph, and framing it manually shows which # section stalled and degrades a bad one to an error stub. $sb = New-Object System.Text.StringBuilder [void] $sb.Append('{') $firstKey = $true foreach ($key in @($R.Keys)) { $swj = [System.Diagnostics.Stopwatch]::StartNew() try { $frag = ConvertTo-Json -InputObject (ConvertTo-Sanitized $R[$key]) -Depth 6 } catch { $frag = ConvertTo-Json -InputObject ([ordered]@{ serializeError = $_.Exception.Message }) -Depth 3 } $swj.Stop() if ($frag.Length -gt 12000000) { $frag = ConvertTo-Json -InputObject ([ordered]@{ serializeError = 'section exceeded 12MB and was dropped' characters = $frag.Length }) -Depth 3 } if (-not $firstKey) { [void] $sb.Append(',') } $firstKey = $false [void] $sb.Append((ConvertTo-Json -InputObject ([string] $key))) [void] $sb.Append(':') [void] $sb.Append($frag) if ($Detailed) { Say (" " + ([string] $key).PadRight(20) + ([math]::Round($frag.Length / 1KB, 1)).ToString().PadLeft(9) + " KB " + [math]::Round($swj.Elapsed.TotalSeconds, 2) + "s") } } [void] $sb.Append('}') $json = $sb.ToString() # Catches a name that reached the JSON as a KEY, which the value-walker cannot # see. Word-boundary anchored, exactly like Protect-Text: a bare substring match # fires on ordinary text that merely contains the name (a short account name # can sit inside an ordinary word - one real account matched inside a browser # argument string) and reports a leak that does not exist. Reported with Say, # not Warn - _meta is already serialized by this point, so a Warn here would # claim a count _meta does not carry. $leakCount = 0 foreach ($needle in @($env:COMPUTERNAME, $env:USERNAME)) { if ($needle -and $needle.Length -ge 3) { $leakCount += ([regex]::Matches($json, '(?i)\b' + [regex]::Escape($needle) + '\b')).Count } } if ($leakCount -gt 0) { $json = Protect-Text $json if ($Detailed) { Say (" note: scrubbed " + $leakCount + " residual name occurrence(s) from the assembled JSON") } } $enc = New-Object System.Text.UTF8Encoding($false) try { [System.IO.File]::WriteAllText($OutFile, $json, $enc) } catch { # Script folder not writable (read-only media, a locked-down location). The # JSON is already built, so fall back rather than lose a 30s collection. $OutFile = Join-Path ([Environment]::GetFolderPath('Desktop')) (Split-Path -Leaf $OutFile) [System.IO.File]::WriteAllText($OutFile, $json, $enc) $script:SavedToDesktop = $true } $sizeKB = [math]::Round((Get-Item -LiteralPath $OutFile).Length / 1KB, 1) if (-not $Quiet) { $secs = [math]::Round($R['_meta'].durationSeconds, 0) Write-Host "" Write-Host " =============================================================" -ForegroundColor DarkCyan Write-Host " DONE" -ForegroundColor Green -NoNewline Write-Host (" - finished in " + $secs + " seconds.") -ForegroundColor Gray Write-Host "" Write-Host (" " + (Split-Path -Leaf $OutFile)) -ForegroundColor White -NoNewline Write-Host (" (" + $sizeKB + " KB)") -ForegroundColor DarkGray Write-Host "" if ($script:SavedToDesktop) { Write-Host " Saved to your Desktop (this folder was not writable)." -ForegroundColor Gray } else { Write-Host " That file is in this folder, next to the one you clicked." -ForegroundColor Gray } Write-Host " Send it back - nothing else is needed." -ForegroundColor Gray Write-Host "" Write-Host " Nothing on this PC was changed, and nothing was sent anywhere." -ForegroundColor DarkGray if (-not $IsAdmin) { Write-Host "" Write-Host " Note: run as administrator next time for the full report." -ForegroundColor Yellow } if ($Warnings.Count -gt 0) { Write-Host "" Write-Host (" " + $Warnings.Count + " item(s) could not be read - that is recorded in the file.") -ForegroundColor Yellow } Write-Host " =============================================================" -ForegroundColor DarkCyan Write-Host "" } # Emit the path so a caller can capture it. Only in -Quiet, where the console # output is nobody's UI - otherwise it prints the full path a third time. if ($Quiet) { $OutFile }