private/Add-Modulelog.ps1

function Add-ModuleLog {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [ValidateSet('Error', 'Warning', 'Info', 'Performance', 'Signature', 'WMI', 'Detection')]
        [string]$LogType,

        [Parameter(Mandatory)]
        [string]$Message,

        [Parameter()]
        [object]$Data,

        [Parameter()]
        [array]$Updates
    )

    $logRoot = "$env:ProgramData\Lenovo\lenovo.Client.Update\Logs"

    # Create log directory if it doesn't exist
    if (-not (Test-Path $logRoot)) {
        New-Item -ItemType Directory -Path $logRoot -Force | Out-Null
    }

    $timestamp = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ")

    switch ($LogType) {
        'Error' {
            $logFile = Join-Path $logRoot "ErrorLog.txt"
            $logEntry = "[$timestamp] [ERROR] $Message"
            Add-Content -Path $logFile -Value $logEntry
        }
        'Warning' {
            $logFile = Join-Path $logRoot "ErrorLog.txt"
            $logEntry = "[$timestamp] [WARNING] $Message"
            Add-Content -Path $logFile -Value $logEntry
        }
        'Info' {
            $logFile = Join-Path $logRoot "InfoLog.txt"
            $logEntry = "[$timestamp] [INFO] $Message"
            Add-Content -Path $logFile -Value $logEntry
        }
        'Detection' {
            $logFile = Join-Path $logRoot "Detection.json"

            # Build update details array
            $updateDetails = @()
            if ($Updates -and $Updates.Count -gt 0) {
                $updateDetails = foreach ($update in $Updates) {
                    [PSCustomObject]@{
                        packageId = $update.ID
                        title = $update.Title
                        version = $update.Version
                        type = $update.Type
                        releaseDate = if ($update.ReleaseDate) {
                            $update.ReleaseDate.ToString("yyyy-MM-dd")
                        } else {
                            $null
                        }
                        isInstalled = $update.IsInstalled
                    }
                }
            }

            # Create structured log entry
            $entry = [PSCustomObject]@{
                timestamp = $timestamp
                level = "INFO"
                component = "Get-LnvUpdate"
                message = $Message
                computer = $Data.ComputerName
                user = $Data.User
                executionTimeMs = if ($Data.ExecutionTime) {
                    [int]([TimeSpan]::Parse($Data.ExecutionTime)).TotalMilliseconds
                } else {
                    0
                }
                summary = [PSCustomObject]@{
                    totalFound = $Data.TotalUpdatesFound
                    applicable = $Data.ApplicableUpdates
                    installed = $Data.InstalledUpdates
                    needed = $Data.ApplicableUpdates - $Data.InstalledUpdates
                }
                updates = $updateDetails
            }

            $existing = if (Test-Path $logFile) {
                $content = Get-Content $logFile -Raw
                if ($content) {
                    $parsed = $content | ConvertFrom-Json
                    if ($parsed -is [array]) {
                        $parsed
                    } else {
                        @($parsed)
                    }
                } else {
                    @()
                }
            } else {
                @()
            }

            $existing += $entry

            if ($existing.Count -gt 100) {
                $existing = $existing | Select-Object -Last 100
            }

            $existing | ConvertTo-Json -Depth 10 | Set-Content $logFile -Force
        }
        'Performance' {
            $logFile = Join-Path $logRoot "Performance.json"
            $entry = [PSCustomObject]@{
                timestamp = $timestamp
                level = "DEBUG"
                message = $Message
                data = $Data
            }

            $existing = if (Test-Path $logFile) {
                $content = Get-Content $logFile -Raw
                if ($content) { $content | ConvertFrom-Json } else { @() }
            } else {
                @()
            }

            if ($existing -isnot [array]) { $existing = @($existing) }
            $existing += $entry
            if ($existing.Count -gt 100) {
                $existing = $existing | Select-Object -Last 100
            }

            $existing | ConvertTo-Json -Depth 10 | Set-Content $logFile -Force
        }
        'Signature' {
            $logFile = Join-Path $logRoot "SignatureVerification.json"
            $entry = [PSCustomObject]@{
                timestamp = $timestamp
                level = "DEBUG"
                message = $Message
                data = $Data
            }

            $existing = if (Test-Path $logFile) {
                $content = Get-Content $logFile -Raw
                if ($content) { $content | ConvertFrom-Json } else { @() }
            } else {
                @()
            }

            if ($existing -isnot [array]) { $existing = @($existing) }
            $existing += $entry
            if ($existing.Count -gt 100) {
                $existing = $existing | Select-Object -Last 100
            }

            $existing | ConvertTo-Json -Depth 10 | Set-Content $logFile -Force
        }
        'WMI' {
            $logFile = Join-Path $logRoot "WMIExport.json"
            $entry = [PSCustomObject]@{
                timestamp = $Timestamp
                level = "INFO"
                message = $Message
                data = $Data
            }

            $existing = if (Test-Path $logFile) {
                $content = Get-Content $logFile -Raw
                if ($content) { $content | ConvertFrom-Json } else { @() }
            } else {
                @()
            }

            if ($existing -isnot [array]) { $existing = @($existing) }
            $existing += $entry
            if ($existing.Count -gt 50) {
                $existing = $existing | Select-Object -Last 50
            }

            $existing | ConvertTo-Json -Depth 10 | Set-Content $logFile -Force
        }
    }

    Write-Verbose "Logged [$LogType]: $Message"
}

function Write-LnvLog {
    <#
        .SYNOPSIS
        Writes a diagnostic line into the log file a public cmdlet is currently writing.
 
        .DESCRIPTION
        The log file is appended to directly rather than produced with Start-Transcript, because
        a transcript only records what is sent to the host: every line that reached the log would
        also have to scroll past in the terminal. Writing the file here keeps the console quiet
        while the log file still gets the full detail.
 
        The line only goes to the log when a cmdlet has set $script:LnvLogFilePath. While that
        is $null this function does nothing at all, which is what lets the callers log
        unconditionally: a run that writes no log, or that writes a transcript instead
        (Get-LnvUpdate -LogFile), is left exactly as it would have been without these calls.
        When a log file is active, Info and Detail messages are additionally emitted as verbose
        output so an interactive user can follow along with -Verbose; Warning and Error levels
        are not, because the caller has already reported those on their own stream.
 
        While $script:LnvLogQuietDetection is $true, Info and Detail lines are kept out of the
        log file unless they were written with -Always.
 
        A log file that cannot be written is ignored on purpose - logging must never be the
        reason a detection run fails.
 
        .PARAMETER Message
        The text to log.
 
        .PARAMETER Level
        Info (default), Detail, Warning or Error. Detail is used for the indented sub-lines that
        explain a preceding Info or Warning line.
 
        .PARAMETER NoTimestamp
        Write the message as-is, without the leading timestamp and level tag. Used for the header
        block and other pre-formatted output.
 
        .PARAMETER ToConsole
        Also write the line to the console. Used for the parts of Debug-LnvUpdate that are meant
        to be read on screen, so that they end up in its log file as well.
 
        .PARAMETER ConsoleOnly
        Write the line to the console and not to the log file. Used for the few notes that are
        true only on screen, such as Debug-LnvUpdate reporting that a section was cut short -
        the log file holds that section in full, so the note would contradict it there.
 
        .PARAMETER Always
        Write the line even while $script:LnvLogQuietDetection is on. Used for the environment
        header, which states which module version produced the log and on which machine, and so
        has to be there however little else was written.
 
        .EXAMPLE
        Write-LnvLog "Already installed: n3jet42w" -Level Info
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, Position = 0, ValueFromPipeline)]
        [AllowEmptyString()]
        [string]$Message,

        [ValidateSet('Info', 'Detail', 'Warning', 'Error')]
        [string]$Level = 'Info',

        [switch]$NoTimestamp,
        [switch]$ToConsole,
        [switch]$ConsoleOnly,
        [switch]$Always
    )

    process {
        if ($ToConsole -or $ConsoleOnly) {
            Write-Information $Message -InformationAction Continue
        } elseif ($script:LnvLogFilePath -and ($Level -eq 'Info' -or $Level -eq 'Detail')) {
            Write-Verbose $Message
        }

        if ($ConsoleOnly -or -not $script:LnvLogFilePath) {
            return
        }

        
        if ($script:LnvLogQuietDetection -and -not ($Always -or $ToConsole) -and ($Level -eq 'Info' -or $Level -eq 'Detail')) {
            return
        }

        $Line = if ($NoTimestamp) {
            $Message
        } else {
            '[{0}] [{1}] {2}' -f (Get-Date).ToString('HH:mm:ss'), $Level.ToUpperInvariant(), $Message
        }

        try {
            Add-Content -LiteralPath $script:LnvLogFilePath -Value $Line -Encoding UTF8 -ErrorAction Stop
        }
        catch {
            # Deliberately ignored, see the description
        }
    }
}

function Write-LnvWarning {
    <#
        .SYNOPSIS
        Reports a warning to the caller and records it in the log file.
 
        .DESCRIPTION
        Warnings state why a package was skipped (a 404 on the Lenovo server, unsigned files, XML
        that will not parse), which is exactly what a support case needs, so they have to be in
        the log file and not only on the console. The warning itself is unchanged: it is still
        raised with Write-Warning.
 
        .PARAMETER Message
        The warning text.
 
        .EXAMPLE
        Write-LnvWarning "Package $Name returned 404, skipping"
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, Position = 0)]
        [AllowEmptyString()]
        [string]$Message
    )

    Write-Warning $Message
    # Flattened, because some of these messages span several lines
    Write-LnvLog -Message ($Message -replace '\s*\r?\n\s*', ' ') -Level 'Warning'
}

function Write-LnvError {
    <#
        .SYNOPSIS
        Reports a non-terminating error to the caller and records it in the log file.
 
        .DESCRIPTION
        The counterpart of Write-LnvWarning for the errors the cmdlets report while carrying on
        with the next package. The error is still raised with Write-Error, so the caller's
        $ErrorActionPreference and -ErrorAction continue to apply.
 
        .PARAMETER Message
        The error text.
 
        .EXAMPLE
        Write-LnvError "The package $Name could not be retrieved and will be skipped"
    #>

    [CmdletBinding()]
    param (
        [Parameter(Mandatory, Position = 0)]
        [AllowEmptyString()]
        [string]$Message
    )

    Write-LnvLog -Message ($Message -replace '\s*\r?\n\s*', ' ') -Level 'Error'
    Write-Error $Message
}

function Resolve-LnvLogFilePath {
    <#
        .SYNOPSIS
        Works out which file a cmdlet should write its log to and makes sure the directory exists.
 
        .DESCRIPTION
        Shared by the cmdlets that accept -LogFile / -LogPath so they all resolve a log location
        the same way:
 
        - -LogFile : a timestamped file in $env:ProgramData\Lenovo\Lenovo.Client.Update\Logs
        - -LogPath <dir> : a timestamped file inside that directory
        - -LogPath <file> : that file, and a bare file name goes to the default log directory
        - both : -LogPath wins, with a warning
 
        Returns $null when neither was requested, or when the directory could not be created,
        in which case the caller carries on without a log file.
 
        .PARAMETER LogFile
        Log to the default location.
 
        .PARAMETER LogPath
        Log to this file or directory.
 
        .PARAMETER Prefix
        The leading part of a generated file name, for example 'Detection' or 'Debug'.
 
        .EXAMPLE
        $Path = Resolve-LnvLogFilePath -LogFile:$LogFile -LogPath $LogPath -Prefix 'Detection'
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [switch]$LogFile,
        [string]$LogPath,
        [string]$Prefix = 'Detection'
    )

    $DefaultDirectory = "$env:ProgramData\Lenovo\Lenovo.Client.Update\Logs"
    $GeneratedName = "$Prefix-$(Get-Date -Format 'yyyyMMdd-HHmmss').log"

    $Resolved = if ($LogFile -and $LogPath) {
        Write-Warning "Both -LogFile and -LogPath specified. Using -LogPath value."
        $LogPath
    } elseif ($LogPath) {
        $LogPath
    } elseif ($LogFile) {
        Join-Path $DefaultDirectory $GeneratedName
    } else {
        $null
    }

    if (-not $Resolved) {
        return $null
    }

    if (Test-Path -Path $Resolved -PathType Container) {
        $Resolved = Join-Path $Resolved $GeneratedName
    } elseif (-not (Split-Path -Path $Resolved -Parent)) {
        # A bare file name goes to the default log directory
        $Resolved = Join-Path $DefaultDirectory $Resolved
    }

    $Directory = Split-Path -Path $Resolved -Parent
    if ($Directory -and -not (Test-Path -Path $Directory)) {
        try {
            New-Item -ItemType Directory -Path $Directory -Force -ErrorAction Stop | Out-Null
        }
        catch {
            Write-Warning "Could not create log directory '$Directory': $($_.Exception.Message)"
            return $null
        }
    }

    return $Resolved
}

function Get-LnvEnvironmentInfo {
    <#
        .SYNOPSIS
        Collects the OS, PowerShell, module and machine detail that is useful when
        troubleshooting a log file sent in by a customer.
 
        .DESCRIPTION
        Gathers the information that cannot be derived from the rest of a log but is almost
        always the first thing asked for during a support case:
 
        - the full numeric Windows version (Major.Minor.Build.UBR) plus the marketing release
          (for example 24H2), edition and installation type, so the exact patch level of the
          device is known
        - the version of this module that produced the log
        - the PowerShell version/edition and whether the session was elevated
        - the machine identity that packages are evaluated against (model, product name,
          serial number, BIOS and embedded controller version)
 
        The result is cached for the lifetime of the module session because none of it changes
        while a cmdlet runs. Every lookup is best-effort: anything that cannot be read is
        returned as $null instead of failing the caller, because this only feeds diagnostics.
 
        .PARAMETER Refresh
        Collect the information again instead of returning the cached result.
 
        .EXAMPLE
        Get-LnvEnvironmentInfo
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [switch]$Refresh
    )

    if ($script:LnvEnvironmentInfo -and -not $Refresh) {
        return $script:LnvEnvironmentInfo
    }

    # Get-WindowsVersion cross-checks cmd.exe, the registry, ntoskrnl.exe, .NET and WMI and
    # returns the highest, which is the most accurate reading of the actual patch level.
    [Version]$OSVersion = $null
    try {
        $OSVersion = Get-WindowsVersion
    }
    catch {
        Write-Verbose "Get-WindowsVersion failed, falling back to [Environment]::OSVersion: $($_.Exception.Message)"
    }
    if (-not $OSVersion) {
        $OSVersion = [Environment]::OSVersion.Version
    }

    # DisplayVersion (24H2, 23H2, ...) exists since Windows 10 20H2, ReleaseId before that.
    # ProductName in this key still reads "Windows 10" on Windows 11, so OSName comes from CIM.
    $CurrentVersion = Get-ItemProperty -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' -ErrorAction SilentlyContinue

    $CimOperatingSystem  = Get-CimInstance -ClassName Win32_OperatingSystem       -ErrorAction SilentlyContinue -Verbose:$false
    $CimComputerSystem   = Get-CimInstance -ClassName Win32_ComputerSystem        -ErrorAction SilentlyContinue -Verbose:$false
    $CimComputerProduct  = Get-CimInstance -ClassName Win32_ComputerSystemProduct -ErrorAction SilentlyContinue -Verbose:$false
    $CimBios             = Get-CimInstance -ClassName Win32_BIOS                  -ErrorAction SilentlyContinue -Verbose:$false

    [bool]$IsElevated = $false
    try {
        $IsElevated = Test-RunningAsAdmin
    }
    catch {
        Write-Verbose "Could not determine whether the session is elevated: $($_.Exception.Message)"
    }

    $Info = [PSCustomObject][ordered]@{
        # Module
        ModuleName                = 'Lenovo.Client.Update'
        ModuleVersion             = $script:LnvModuleVersion
        ModuleRoot                = $script:LnvModuleRoot

        # PowerShell / process
        PSVersion                 = $PSVersionTable.PSVersion.ToString()
        PSEdition                 = if ($PSVersionTable.Contains('PSEdition')) { $PSVersionTable.PSEdition } else { 'Desktop' }
        Is64BitProcess            = [Environment]::Is64BitProcess
        IsElevated                = $IsElevated
        UserName                  = "${env:USERDOMAIN}\${env:USERNAME}"
        ComputerName              = $env:COMPUTERNAME

        # Operating system
        OSName                    = if ($CimOperatingSystem) { $CimOperatingSystem.Caption } else { $CurrentVersion.ProductName }
        OSVersion                 = $OSVersion.ToString()
        OSBuild                   = $OSVersion.Build
        OSUpdateBuildRevision     = $OSVersion.Revision
        OSDisplayVersion          = if ($CurrentVersion.DisplayVersion) { $CurrentVersion.DisplayVersion } else { $CurrentVersion.ReleaseId }
        OSEdition                 = $CurrentVersion.EditionID
        OSInstallationType        = $CurrentVersion.InstallationType
        OSArchitecture            = if ($CimOperatingSystem) { $CimOperatingSystem.OSArchitecture } else { $env:PROCESSOR_ARCHITECTURE }
        OSLanguage                = [CultureInfo]::CurrentUICulture.Name
        OSLanguageCode            = [CultureInfo]::CurrentUICulture.ThreeLetterWindowsLanguageName
        # The value that _OS dependency tests are evaluated against
        OSFamily                  = if ($OSVersion -ge [Version]::new(10, 0, 22000, 0)) { '11' } else { '10' }
        OSInstallDate             = if ($CimOperatingSystem) { $CimOperatingSystem.InstallDate } else { $null }
        LastBootUpTime            = if ($CimOperatingSystem) { $CimOperatingSystem.LastBootUpTime } else { $null }

        # Machine
        Manufacturer              = if ($CimComputerSystem) { $CimComputerSystem.Manufacturer } else { $null }
        Model                     = if ($CimComputerSystem) { $CimComputerSystem.Model } else { $null }
        ProductName               = if ($CimComputerProduct) { $CimComputerProduct.Version } else { $null }
        SerialNumber              = if ($CimBios) { $CimBios.SerialNumber } else { $null }
        BiosVersion               = if ($CimBios) { $CimBios.SMBIOSBIOSVersion } else { $null }
        BiosReleaseDate           = if ($CimBios) { $CimBios.ReleaseDate } else { $null }
        EmbeddedControllerVersion = if ($CimBios) { @($CimBios.EmbeddedControllerMajorVersion, $CimBios.EmbeddedControllerMinorVersion) -join '.' } else { $null }
    }

    $script:LnvEnvironmentInfo = $Info
    return $Info
}

function Write-LnvLogEnvironmentHeader {
    <#
        .SYNOPSIS
        Writes the environment block at the top of a log file.
 
        .DESCRIPTION
        Emits the Get-LnvEnvironmentInfo detail as a readable block through Write-LnvLog, so
        that every log file a customer sends in states which module version produced it, on
        which exact Windows patch level, and on which machine.
 
        .PARAMETER Component
        The cmdlet the log belongs to, shown in the first line of the block.
 
        .PARAMETER Refresh
        Re-collect the environment information instead of using the cached result.
 
        .EXAMPLE
        Write-LnvLogEnvironmentHeader -Component 'Get-LnvUpdate'
    #>

    [CmdletBinding()]
    param (
        [string]$Component = 'Get-LnvUpdate',
        [switch]$Refresh
    )

    $Info = Get-LnvEnvironmentInfo -Refresh:$Refresh

    $Unknown = 'unknown'
    $Fields = [ordered]@{
        'Module version'  = "$($Info.ModuleVersion) ($($Info.ModuleRoot))"
        'PowerShell'      = "$($Info.PSVersion) ($($Info.PSEdition)), 64-bit process: $($Info.Is64BitProcess), elevated: $($Info.IsElevated)"
        'User'            = "$($Info.UserName) on $($Info.ComputerName)"
        'OS'              = "$($Info.OSName) [$($Info.OSEdition) / $($Info.OSInstallationType)]"
        'OS version'      = "$($Info.OSVersion) (build $($Info.OSBuild), UBR $($Info.OSUpdateBuildRevision), release $(if ($Info.OSDisplayVersion) { $Info.OSDisplayVersion } else { $Unknown }), Windows $($Info.OSFamily))"
        'OS architecture' = "$($Info.OSArchitecture), language $($Info.OSLanguage) ($($Info.OSLanguageCode))"
        'OS installed'    = "$(if ($Info.OSInstallDate) { $Info.OSInstallDate.ToString('yyyy-MM-dd') } else { $Unknown }), last boot $(if ($Info.LastBootUpTime) { $Info.LastBootUpTime.ToString('yyyy-MM-dd HH:mm:ss') } else { $Unknown })"
        'Machine'         = "$($Info.Manufacturer) $($Info.Model) ($($Info.ProductName))"
        'Serial number'   = "$($Info.SerialNumber)"
        'BIOS'            = "$($Info.BiosVersion), released $(if ($Info.BiosReleaseDate) { $Info.BiosReleaseDate.ToString('yyyy-MM-dd') } else { $Unknown }), EC $($Info.EmbeddedControllerVersion)"
    }

    Write-LnvLog -Always -NoTimestamp -Message ('=' * 78)
    Write-LnvLog -Always -NoTimestamp -Message " $($Info.ModuleName) - $Component - started $((Get-Date).ToString('yyyy-MM-dd HH:mm:ss zzz'))"
    Write-LnvLog -Always -NoTimestamp -Message ('-' * 78)
    foreach ($Field in $Fields.GetEnumerator()) {
        Write-LnvLog -Always -NoTimestamp -Message (' {0,-16}: {1}' -f $Field.Key, $Field.Value)
    }
    Write-LnvLog -Always -NoTimestamp -Message ('=' * 78)
}

function Format-LnvDependencyTrace {
    <#
        .SYNOPSIS
        Turns the trace collected by Resolve-XMLDependency -Trace into readable log lines.
 
        .DESCRIPTION
        Each trace entry becomes one line stating whether the test passed, which kind of test
        it was, and the 'Got / Expected' detail the test produced, for example:
 
            [FAIL] _WindowsBuildVersion [ Got: 26200, Expected: 22631 ]
 
        Entries are in evaluation order; because the parser recurses into a group before it
        records the group itself, a group node appears after its children.
 
        These lines are what a support engineer reads in a log file a customer sent in, so only
        the detail that says why a test came out the way it did is kept. Test-MachineSatisfiesDependency
        narrates its way through a test on the debug stream - which hardware IDs matched, which
        device is being looked at, which comparison is about to be tried - and on a test that
        passed, none of that narration adds anything to the comparison it ends in. A _Driver test
        for a chipset package listing three hardware IDs produces over two thousand characters of
        it. The narration is therefore dropped on a passing test and kept on a failing one, where
        'Matched devices: 0 exact, 0 wildcard', or which of several devices was being tested, is
        the answer.
 
        A package may list hundreds of hardware IDs as Or-branches - the ThinkPad monitor file
        package lists over 250 - and on a machine that has none of them every one of those tests
        reports the same failure. A run of consecutive tests that share a node name and a result
        and carry no per-test detail is therefore reported as a single line naming the first few
        values and the number of remaining ones:
 
            [FAIL] _PnPID x 258 - Monitor\IBM0A2E, Monitor\IBM0A41, Monitor\IBM0A4B, +255 more
 
        Tests that produced their own 'Got / Expected' detail are never collapsed, because each
        of those lines says something different.
 
        .PARAMETER Trace
        The trace collection filled in by Resolve-XMLDependency -Trace.
 
        .PARAMETER FailedOnly
        Only return the lines for tests that did not pass. This is what identifies the reason
        a package was reported as not applicable. A test that failed inside a group the machine
        satisfied anyway is left out, see the comment on $HasSatisfiedAncestor.
 
        .PARAMETER PassedOnly
        Only return the lines for tests that passed. This is what identifies an already installed
        package, where the tests that passed are the ones that prove it: the tests in an Or-tree
        that did not match are not a problem and only add noise.
 
        .PARAMETER IncludeGroups
        Also return a line for the And/Or/Not group nodes, not just the individual tests.
 
        .EXAMPLE
        Format-LnvDependencyTrace -Trace $Trace -FailedOnly
    #>

    [CmdletBinding()]
    [OutputType([string])]
    param (
        [Parameter(Position = 0)]
        [AllowNull()]
        [AllowEmptyCollection()]
        [object[]]$Trace,

        [switch]$FailedOnly,
        [switch]$PassedOnly,
        [switch]$IncludeGroups
    )

    if (-not $Trace) {
        return
    }

    # Runs shorter than this stay expanded: seeing two or three hardware IDs in full is more
    # useful than being told there were two or three of them.
    $CollapseThreshold = 5
    $SampleCount = 3

    # Debug records that never belong in a log file: they describe how a test went about its
    # work, not what it found. The per-pair 'Matched device' record in particular is written
    # once for every hardware ID the package lists times every ID the machine reports for a
    # device, which is where a _Driver test's thousands of characters come from.
    # A driver rank, and the note that a device looks like it is on a generic driver, are only
    # ever acted on when -FailInboxDrivers is in play, and that case reports itself with
    # 'Failed because device is using an inbox driver', which is kept.
    [string]$NarrationAlways = @(
        "^Matched device '.+' with required "
        '^Adding device - HardwareIDs matched only when using wildcards$'
        '^Added runspace process \d+ to job: '
        '^Passed Driver(?:Date|Version) test$'
        ' DriverRank is 0x'
        'using a generic or inbox driver$'
    ) -join '|'

    # Kept only on a test that failed, where they say which device, or which of several
    # candidate devices, the failure is about. On a passing test the comparison is the answer
    # and these only repeat what the package title already said.
    [string]$NarrationOnPass = @(
        '^Matched devices: '
        '^Testing '
        '^Got unsupported with ProductVersion, trying comparison with FileVersion$'
    ) -join '|'

    $GetStatus = {
        param ($Entry)

        if ($Entry.Unsupported) {
            'UNSUPPORTED'
        } elseif ($Entry.Passed) {
            'PASS'
        } else {
            'FAIL'
        }
    }

    # Reduces the records a test produced to the ones worth logging, and rewrites two of them:
    # 'Trying to match driver based on Date' only announces the comparison that follows, so the
    # word is carried onto that comparison rather than kept as a sentence of its own, and an
    # external detection is reported by command line without the temporary directory it was
    # expanded into, which is gone by the time anyone reads the log.
    $SelectDetail = {
        param ($Entry)

        $Kept = [System.Collections.Generic.List[string]]::new()
        [string]$Subject = $null

        foreach ($Line in @($Entry.Detail)) {
            if ([string]::IsNullOrWhiteSpace($Line)) {
                continue
            }

            if ($Line -match '^Trying to match driver based on (\w+)') {
                $Subject = $Matches[1]
                continue
            }

            [string]$Emit = $Line

            if ($Line -match '^\[\s*Got') {
                if ($Subject) {
                    $Emit = $Line -replace '^\[\s*Got', "[$Subject Got"
                    $Subject = $null
                }
            } elseif ($Line -match '^Starting external process: File: (?<File>.+?) Arguments: (?<Arguments>.*) WorkingDirectory: ') {
                $Emit = ('Ran {0} {1}' -f ($Matches['File'] -replace '^.*[\\/]'), $Matches['Arguments']).Trim()
            } elseif ($Line -match $NarrationAlways) {
                continue
            } elseif ($Entry.Passed -and $Line -match $NarrationOnPass) {
                continue
            }

            # An external detection that has to be retried (elevation, ShellExecute) reports its
            # command line once per attempt, and once the temporary directory is out of the line
            # the attempts are indistinguishable. Two identical lines in a row say nothing twice.
            if ($Kept.Count -and $Kept[$Kept.Count - 1] -eq $Emit) {
                continue
            }

            $Kept.Add($Emit)
        }

        $Kept
    }

    $FormatEntry = {
        param ($Entry)

        $Line = '[{0}] {1}' -f (& $GetStatus $Entry), $Entry.Node
        if ($Entry.Negated) {
            $Line += ' (negated by a Not node)'
        }

        [string[]]$Detail = @(& $SelectDetail $Entry)
        if ($Detail.Count) {
            $Line += ' ' + ($Detail -join ' | ')
        } elseif ($Entry.Value) {
            $Line += ' ' + $Entry.Value
        } elseif ($Entry.Summary) {
            $Line += ' ' + $Entry.Summary
        } elseif ($Entry.Xml) {
            $Line += ' ' + $Entry.Xml
        }

        $Line
    }

    # Only a test that is nothing but a value can be collapsed: one that reported detail worth
    # keeping, or that a Not node inverted, has to be read on its own line.
    $IsCollapsible = {
        param ($Entry)

        [bool]$Entry.Value -and -not $Entry.Negated -and @(& $SelectDetail $Entry).Count -eq 0
    }

    # A test that failed inside a group the machine satisfied anyway did not decide anything.
    # The common case is a package built for '10/11' whose Or-tree holds a WIN10-only and a
    # WIN11-only _OS test: on a Windows 11 machine the WIN10 test fails, the Or passes, and
    # reporting that failure tells a support engineer the OS was not supported when it was.
    # Entries are recorded in evaluation order with a group following its own children, so the
    # group enclosing an entry is the next entry recorded at a shallower depth.
    $HasSatisfiedAncestor = {
        param ([int]$Index)

        [int]$Depth = $Trace[$Index].Depth
        for ($i = $Index + 1; $i -lt $Trace.Count; $i++) {
            if ($Trace[$i].Depth -lt $Depth) {
                if ($Trace[$i].Passed) {
                    return $true
                }
                $Depth = $Trace[$i].Depth
            }
        }

        $false
    }

    $Selected = [System.Collections.Generic.List[object]]::new()
    for ($Position = 0; $Position -lt $Trace.Count; $Position++) {
        $Entry = $Trace[$Position]
        if (-not $IncludeGroups -and $Entry.Type -ne 'Test') {
            continue
        }
        if ($FailedOnly) {
            if ($Entry.Passed) {
                continue
            }
            if (& $HasSatisfiedAncestor $Position) {
                continue
            }
        }
        if ($PassedOnly -and -not $Entry.Passed) {
            continue
        }
        $Selected.Add($Entry)
    }

    $Index = 0
    while ($Index -lt $Selected.Count) {
        $First = $Selected[$Index]
        $Status = & $GetStatus $First

        # Find how far the run of identical-looking tests reaches
        $Last = $Index
        if (& $IsCollapsible $First) {
            while ($Last + 1 -lt $Selected.Count) {
                $Next = $Selected[$Last + 1]
                if ($Next.Node -ne $First.Node -or
                    -not (& $IsCollapsible $Next) -or
                    (& $GetStatus $Next) -ne $Status) {
                    break
                }
                $Last++
            }
        }

        $RunLength = $Last - $Index + 1
        if ($RunLength -ge $CollapseThreshold) {
            $Sample = @($Selected[$Index..($Index + $SampleCount - 1)].Value) -join ', '
            $Line = '[{0}] {1} x {2} - {3}' -f $Status, $First.Node, $RunLength, $Sample
            $Line += ', +{0} more' -f ($RunLength - $SampleCount)
            $Line
        } else {
            for ($i = $Index; $i -le $Last; $i++) {
                & $FormatEntry $Selected[$i]
            }
        }

        $Index = $Last + 1
    }
}
# SIG # Begin signature block
# MIItugYJKoZIhvcNAQcCoIItqzCCLacCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB
# gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR
# AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQURG+kM0Fxxua4Nq6W98sR/+5l
# QpyggibcMIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjANBgkqhkiG9w0B
# AQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYD
# VQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVk
# IElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1OTU5WjBiMQsw
# CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu
# ZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQw
# ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz
# 7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS
# 5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7
# bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfI
# SKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jH
# trHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14
# Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2
# h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt
# 6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPR
# iQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ER
# ElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4K
# Jpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/BAUwAwEB/zAd
# BgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgwFoAUReuir/SS
# y4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUFBwEBBG0wazAk
# BggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsGAQUFBzAC
# hjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURS
# b290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRpZ2ljZXJ0
# LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0gBAowCDAGBgRV
# HSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW1/e/Vwe9mqyh
# hyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH38nLeJLxSA8hO
# 0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMTdydE1Od/6Fmo
# 8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY9HdaXFSMb++h
# UD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyerbHbObyMt9H5x
# aiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmUMIIFkDCCA3ig
# AwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQG
# EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl
# cnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMTMw
# ODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJVUzEVMBMGA1UE
# ChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYD
# VQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3yithZwuEppz1Y
# q3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lX
# FllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxe
# TsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbu
# yntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I
# 9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/CNdaSaTC5qmg
# Z92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCiEhtmmnTK3kse
# 5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKy
# Ebe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwh
# HbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/
# Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwID
# AQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4E
# FgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQADggIBALth2X2p
# bL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2SV1EY+CtnJYY
# ZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd+SeuMIW59mdN
# Oj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWcfFqK1qI4mfN4
# i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqasjYUegbyJLkJ
# EVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9NcCOGDErcgdLM
# MpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N0XWs0Mr7QbhD
# parTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie4u1Ki7wb/UdK
# Dd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mIr/OSmbaz5mEP
# 0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1/YldvIViHTLS
# oCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCmgKDWHrO8Dw9T
# dSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+MIIGsDCCBJigAwIBAgIQCK1AsmDS
# nEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UE
# ChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYD
# VQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjEwNDI5MDAwMDAwWhcN
# MzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs
# IEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5n
# IFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
# MIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1M4zrPYGXcMW7xIUmMJ+kjmjYXPXr
# NCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZwZHMgQM+TXAkZLON4gh9NH1MgFcS
# a0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI8IrgnQnAZaf6mIBJNYc9URnokCF4
# RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGiTUyCEUhSaN4QvRRXXegYE2XFf7JP
# hSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLmysL0p6MDDnSlrzm2q2AS4+jWufcx
# 4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3SvUQakhCBj7A7CdfHmzJawv9qYFSL
# ScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tvk2E0XLyTRSiDNipmKF+wc86LJiUG
# soPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+960IHnWmZcy740hQ83eRGv7bUKJG
# yGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3sMJN2FKZbS110YU0/EpF23r9Yy3IQ
# KUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FKPkBHX8mBUHOFECMhWWCKZFTBzCEa
# 6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1Hs/q27IwyCQLMbDwMVhECAwEAAaOC
# AVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFGg34Ou2O/hfEYb7
# /mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1Ud
# DwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDAzB3BggrBgEFBQcBAQRrMGkw
# JAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcw
# AoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJv
# b3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQu
# Y29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwHAYDVR0gBBUwEzAHBgVngQwB
# AzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQADggIBADojRD2NCHbuj7w6mdNW4AIa
# pfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L/Z6jfCbVN7w6XUhtldU/SfQnuxaB
# RVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHVUHmImoqKwba9oUgYftzYgBoRGRjN
# YZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rdKOtfJqGVWEjVGv7XJz/9kNF2ht0c
# sGBc8w2o7uCJob054ThO2m67Np375SFTWsPK6Wrxoj7bQ7gzyE84FJKZ9d3OVG3Z
# XQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43Nb3Y3LIU/Gs4m6Ri+kAewQ3+ViCCC
# cPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4ZXDlx4b6cpwoG1iZnt5LmTl/eeqxJ
# zy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvmoLr9Oj9FpsToFpFSi0HASIRLlk2r
# REDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8y4+ICw2/O/TOHnuO77Xry7fwdxPm
# 5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMMB0ug0wcCampAMEhLNKhRILutG4UI
# 4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+FSCH5Vzu0nAPthkX0tGFuv2jiJmCG
# 6sivqf6UHedjGzqGVnhOMIIGtDCCBJygAwIBAgIQDcesVwX/IZkuQEMiDDpJhjAN
# BgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQg
# SW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2Vy
# dCBUcnVzdGVkIFJvb3QgRzQwHhcNMjUwNTA3MDAwMDAwWhcNMzgwMTE0MjM1OTU5
# WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNV
# BAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hB
# MjU2IDIwMjUgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtHgx
# 0wqYQXK+PEbAHKx126NGaHS0URedTa2NDZS1mZaDLFTtQ2oRjzUXMmxCqvkbsDpz
# 4aH+qbxeLho8I6jY3xL1IusLopuW2qftJYJaDNs1+JH7Z+QdSKWM06qchUP+AbdJ
# gMQB3h2DZ0Mal5kYp77jYMVQXSZH++0trj6Ao+xh/AS7sQRuQL37QXbDhAktVJMQ
# bzIBHYJBYgzWIjk8eDrYhXDEpKk7RdoX0M980EpLtlrNyHw0Xm+nt5pnYJU3Gmq6
# bNMI1I7Gb5IBZK4ivbVCiZv7PNBYqHEpNVWC2ZQ8BbfnFRQVESYOszFI2Wv82wnJ
# RfN20VRS3hpLgIR4hjzL0hpoYGk81coWJ+KdPvMvaB0WkE/2qHxJ0ucS638ZxqU1
# 4lDnki7CcoKCz6eum5A19WZQHkqUJfdkDjHkccpL6uoG8pbF0LJAQQZxst7VvwDD
# jAmSFTUms+wV/FbWBqi7fTJnjq3hj0XbQcd8hjj/q8d6ylgxCZSKi17yVp2NL+cn
# T6Toy+rN+nM8M7LnLqCrO2JP3oW//1sfuZDKiDEb1AQ8es9Xr/u6bDTnYCTKIsDq
# 1BtmXUqEG1NqzJKS4kOmxkYp2WyODi7vQTCBZtVFJfVZ3j7OgWmnhFr4yUozZtqg
# PrHRVHhGNKlYzyjlroPxul+bgIspzOwbtmsgY1MCAwEAAaOCAV0wggFZMBIGA1Ud
# EwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO9vU0rp5AZ8esrikFb2L9RJ7MtOMB8G
# A1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIBhjAT
# BgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUHMAGG
# GGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDovL2Nh
# Y2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQwQwYD
# VR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0
# VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9
# bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQAXzvsWgBz+Bz0RdnEwvb4LyLU0pn/N0IfF
# iBowf0/Dm1wGc/Do7oVMY2mhXZXjDNJQa8j00DNqhCT3t+s8G0iP5kvN2n7Jd2E4
# /iEIUBO41P5F448rSYJ59Ib61eoalhnd6ywFLerycvZTAz40y8S4F3/a+Z1jEMK/
# DMm/axFSgoR8n6c3nuZB9BfBwAQYK9FHaoq2e26MHvVY9gCDA/JYsq7pGdogP8HR
# trYfctSLANEBfHU16r3J05qX3kId+ZOczgj5kjatVB+NdADVZKON/gnZruMvNYY2
# o1f4MXRJDMdTSlOLh0HCn2cQLwQCqjFbqrXuvTPSegOOzr4EWj7PtspIHBldNE2K
# 9i697cvaiIo2p61Ed2p8xMJb82Yosn0z4y25xUbI7GIN/TpVfHIqQ6Ku/qjTY6hc
# 3hsXMrS+U0yy+GWqAXam4ToWd2UQ1KYT70kZjE4YtL8Pbzg0c1ugMZyZZd/BdHLi
# Ru7hAWE6bTEm4XYRkA6Tl4KSFLFk43esaUeqGkH/wyW4N7OigizwJWeukcyIPbAv
# jSabnf7+Pu0VrFgoiovRDiyx3zEdmcif/sYQsfch28bZeUz2rtY/9TCA6TD8dC3J
# E3rYkrhLULy7Dc90G6e8BlqmyIjlgp2+VqsS9/wQD7yFylIz0scmbKvFoW2jNrbM
# 1pD2T7m3XDCCBu0wggTVoAMCAQICEAhP3DNPfkVO28MPj/mSGDUwDQYJKoZIhvcN
# AQELBQAwaTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEw
# PwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0MDk2
# IFNIQTI1NiAyMDI1IENBMTAeFw0yNjA4MDUwMDAwMDBaFw0zNzExMDQyMzU5NTla
# MGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UE
# AxMyRGlnaUNlcnQgU0hBMjU2IFJTQTQwOTYgVGltZXN0YW1wIFJlc3BvbmRlciAy
# MDI2IDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC2e6byyf7NSvjU
# m0xls/04xjD4fAkOkbnGQi7+Wpx81iYxfzViaxSIctuH3KSl5YEYpMuFgGsA31N2
# D9ATMbfZdw5uaAhuWevQKhDdZIB4NnqcfpfpWQXJiQnDdAElETC+bhSEvNLGbA8D
# twUpFMQ4yyYQSPqomT92osQAv6hBi47ATZS6JfVWe6XxhF4jJZ3iSAuf2Cros1cz
# RSmWRHqMv9AfGZvp8ygYElhudpQjtcPpwoOl6QrZJUyV3iINvN4cO05prGV0fkjG
# 426xDr2d3z9lcSIHkdvGPdGUrXdxfVbgOUVcp2/8ISEzwKPW++Wa+E2ujI91EZtu
# kGWDJ/xZ27k3oHKEXBRGfRTqjOU+jE3ba/5++JSE/7oNHnjs5mekExYN96LV/mxU
# bCKJb8pBNY4r3uD7hEmk/M81XhVgwDA7aMzYC3LZBg9WY5BMmbSay5ecmtJuXaB/
# 0nKWmQmVZeqTVDgsmzHP5MQuhAJkiWNuC9MmCg9TZHXbJ2/yLVSov9p16UDTLtT0
# +aa1vN71fHeu1qMLlLNB3WOB/ADCxr3S/1hxI92Z6jKgEED/btwIvbfuXkNNhg8M
# tDg43c4tMZae9FvqMOt/9PvmAxF9TNIsIFB8G6yb36ZJZGUL8N/pL971DyLXcK6H
# M5PYnH5X+eVtczhCgHCVQCF6XDAlPQIDAQABo4IBlTCCAZEwDAYDVR0TAQH/BAIw
# ADAdBgNVHQ4EFgQUFMljijAu1Er7bpTz5uNAfvXszeIwHwYDVR0jBBgwFoAU729T
# SunkBnx6yuKQVvYv1Ensy04wDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoG
# CCsGAQUFBwMIMIGVBggrBgEFBQcBAQSBiDCBhTAkBggrBgEFBQcwAYYYaHR0cDov
# L29jc3AuZGlnaWNlcnQuY29tMF0GCCsGAQUFBzAChlFodHRwOi8vY2FjZXJ0cy5k
# aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2
# U0hBMjU2MjAyNUNBMS5jcnQwXwYDVR0fBFgwVjBUoFKgUIZOaHR0cDovL2NybDMu
# ZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0YW1waW5nUlNBNDA5
# NlNIQTI1NjIwMjVDQTEuY3JsMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsGCWCGSAGG
# /WwHATANBgkqhkiG9w0BAQsFAAOCAgEAjcU6YR6dUgrfmawJgH59KECxa9Ji8sEi
# 2g10CBDaMiqsaxWyW5cwlT/6ZF5sFznazqVsoC85U9dqLOYqQwst+UQQoNlDHgKR
# La3xoc+OReFreFhnTXSG0Vrd2E2CZqUfm+5a+He1MJ/h+tNLuA+0Zzhn/Fo+FDYA
# HWZHx4R79ZsfRFYe9UiXpXBDf6DkUo183Y38NYmR/XfDYf7YZ+oR9t3flbDwK+hg
# GMs0gNNp1w9Z2CyOyI5or/sSwomAuNQ0hWC9xoU4stD8aWsD7RkcmgVRs6vlIk3z
# PKQ+ylcheWkMlj+CoVRlFE55pv0ZWCaFt04lwP/rdGHE9qEVQZtyRE42ox7oNgC/
# r+Y4bSlZ3dw9K2x1xLtu6PkPKeLBFjzKigwfqm3Hm+k/+lnME8F5kPZTgiy2HLEH
# klpryqs6QHnPXrRNeIzkAMyylnRN8P0wmirS0WkU+ywpEWFZ4QNg+9xS43tTuW9x
# 0eXh7NDc1P/sV+zWxHXKH8tFt1ncHdVzqrZaYPyYMLSn2TOXajveJW1L3joiQSPs
# WRGxkbDDW15jERFE4LvjnGu2O9zD1nLJSMdlYZEikl4w2w+q4IN/R+TIe0H4ngCI
# 1moJCTbevGH4punIxM1Uoi0nmX3ZK+XbRT01uowE5ViXWHng0RgsmrX/EdYUo80r
# 3TfMlkD0/YMwggdWMIIFPqADAgECAhAFfARoFtaNDha0m0G6FxiVMA0GCSqGSIb3
# DQEBCwUAMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFB
# MD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5
# NiBTSEEzODQgMjAyMSBDQTEwHhcNMjYwNTE0MDAwMDAwWhcNMjcwNjE3MjM1OTU5
# WjBeMQswCQYDVQQGEwJVUzEXMBUGA1UECBMOTm9ydGggQ2Fyb2xpbmExFDASBgNV
# BAcTC01vcnJpc3ZpbGxlMQ8wDQYDVQQKEwZMZW5vdm8xDzANBgNVBAMTBkxlbm92
# bzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBALVacqHydjmqo6bF2tHw
# cETPkOZLgPv1tJeIerpLcfmxLoX40GsUiY5l96B13bcfKjKBJQvHsj5D08RfGnif
# K+sGsAX8xCgaYNLxq0COKn51GgLLMNgzlE4rR/8mUMuWK2fKXs24dmn7teE2+e+2
# dz/GfHXgrAIPvIr4PN+dZAFeoj6/wfpLLHZXtQLxKVmtDFc7gQZkM6Z8o0HrH2eX
# 2MVNQwbZZWvSQhqRN66/LMcXxbLP8SAb6nKRCExflbI5i+MpEq+xOdiJxkP5dC5s
# rQ31JGLDdhbuAcUJEdXATzgP1pT9is3uWZm3fro71Kvfa0XLwBZWR2ut8sQ6KUhd
# 0Nsmd5c0f2PiD2uTd9mDWHQ34bu9mDunZaeWZrIlUP9MJ8TMM82ao/4tjFNK5m3T
# hZUbCGwoepenXq8yjbKMKqHHvcoJY0SmkAIlUzWoRBbRC+uN0TwcR048sZDPo+ZC
# gdONLPrjnsIU+NxuhfDMnj4UYbkRbtvnJ+U0O4Eu+ajRS87Li+jN2jHrABvAUXtP
# J7DkkxTTfaspzlfxueoqVKcGLnu4PUAthKg3g4hleGgeo7s+krEDAzyvdBnY+b8U
# dRH/BCxDr7G5ys/bCMPEzcUFT5LrrVBHO6N2U65iYkamzEo4okrrCNFDJPZC1G/p
# NleTeiIadSPTT0tJGPK+nDo1AgMBAAGjggIDMIIB/zAfBgNVHSMEGDAWgBRoN+Dr
# tjv4XxGG+/5hewiIZfROQjAdBgNVHQ4EFgQU7R8eucYUNncCkcWYWfCltSt/mxYw
# PgYDVR0gBDcwNTAzBgZngQwBBAEwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3dy5k
# aWdpY2VydC5jb20vQ1BTMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEF
# BQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1odHRwOi8vY3JsMy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIx
# Q0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0
# VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwgZQG
# CCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2Vy
# dC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9E
# aWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEu
# Y3J0MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQADggIBAC0PiOF1B8Qdp2pcfb3P
# 6MuzaeTUw77bfMSCcnwY8xFtkyRRp9H6X4ZaSTdAQge3d6NG7OcHnGQ3FKraZqT3
# ugjNEXvH1MXr8SNgFb7t7TyKDK0RmRK9idsycLJfRsmbj0FSTL12jqrzyHrQqkTq
# KaDqYfIVan7VxP2On8gq3/OT368z1YJbRgfu/rNpXoYpXTKSfxaIXNE7rEiUwu+T
# vi8wgfLqC/a7ujC8vbYRWgaHHsz3bjFVN/h1p78adds2GdxPzYFoS12+JKq2cKGe
# Ma5Hbr2YQfHsbpQlzZglB3mGCci+uhN5YGaHleo3Ohd0BHci32RFOA1xdD8fS9TD
# pGRMGLKF9z5WTmiGZVR4x54Qwtf4clbA5V0P9d4ppmLZJKdeaHd/JKONVJ9nYV1H
# NA52CgvgRWpUc+jxnPL5A3J9qIvUXp98ZQifEyHBFX2VqWTdu9MldzklRo/eAT1k
# MUVheRAcd32yLDiFvn7lp2nasJs8Hh+6bUxfHKbKBrNrw7CmcTDzFK119BkykWS0
# vL7xsbCEbavLIhW/ZqEjWA9k/v2FS2wbBQ9YOC8F3magnslxWlZWsym3Dxdm0Hr1
# WKHbnFcBscMiBQ+fhLDAqIG8tPPnNhc7QhOOlxQlbfEDh8QmQbUo2H9hJJEGiHJS
# VwuAKbcWembFyAgLV02ASxS7MYIGSDCCBkQCAQEwfTBpMQswCQYDVQQGEwJVUzEX
# MBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0
# ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExAhAFfARo
# FtaNDha0m0G6FxiVMAkGBSsOAwIaBQCgeDAYBgorBgEEAYI3AgEMMQowCKACgACh
# AoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAM
# BgorBgEEAYI3AgEVMCMGCSqGSIb3DQEJBDEWBBSnPMO6nN4IbiYA2PVvsv6mRfqC
# xzANBgkqhkiG9w0BAQEFAASCAgBT0DqNOy1fu82zxrqdG6fi+kTPkI2vBckdM0mS
# f8cWNcZi4yD2wxem7sjvswDNZb1nkkh/O1gVnvrJQZ6zLiWZ/OuV54fYtPsnq7XG
# Kt0gdp+huFezvJmXRcqzpc9Zg/o4D2vsusqseWkbbb2mloMia67PLae72GU2knQq
# GN1Ov2daN8hem77xi0ToGYjW3qsh+6lCQdnhIvpg7a9ur4/5b/UPkUyDNeJMpL7O
# nqgoCIh+MytduTBWhiguuszuxYzp5KZVM1WSdz3Ao2Yd9O+xJSFjv7ePTuSt2ZCt
# gRUZOT5fIVCg7K82JeMKD+jtI3peQ0hZ6K7VIIEJZFhYnMSvyYjCHPm9ZnBkK4bS
# vkVAnscUIndjLyt6fJ0Qz3AG5UQmElSFehn2/RdQtz73Z9xBRZyGnasSgvr2GUGI
# ESGWmm9v7bZ0D57PZLRU+uUmq/U8YJmj5Z/PvHn81eASdQ6Y5vtctIBgcPsMrKzC
# 8VISXqqGq6aH+uh8MurlrlIY5cmsFr05DMYWF20p/MVUBPGjR8u6q+IE2l0pyEg9
# SvOuFpVmuqmxpu+F50T7EypRbNE0opRL3LMz6dB8F0i/Zxvh+jkg+bUox6tTzLw/
# qePVbGlnTmk3K28F5q2UGNhIqNWUpkdEP4o9ko7yuYBsHHKlfPmAXD1COwO2HsSO
# pD/TY6GCAyYwggMiBgkqhkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYT
# AlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQg
# VHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEC
# EAhP3DNPfkVO28MPj/mSGDUwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMx
# CwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yNjA5MjQxODEyMjNaMC8GCSqG
# SIb3DQEJBDEiBCAe4lUpeg1/Av9Ntq2z7XSX9yX33daPIJtsJgYF5CWRNTANBgkq
# hkiG9w0BAQEFAASCAgCTxh06fkE09v2lHYFapc3rlNkMtGMJ4iSbeVwwqAWMbxqP
# jbLXBve4ohK3+bKcwIU9Gx8QfF+zzgSpeEMcD9UtIKDUF6GMTK5LF3IVSJ0mkv1r
# sSwEpznI1iL33RwDvYAtbVzVvbQsgd4wXtIkot+mj5wLnXQgO1ufM/VFt0sbQRUQ
# JykvJ9e1g68g5WlUFroBK/klxi+R9SKpjGJV/oOq1H96T87pZcGmYlBIUcD/pBSW
# jmtOOV1HXjTdbfBu+/21+3Mrhs3NigNvhNQzfUuzhjs28NgJy3enGpok4saiom2L
# q2/fBJSn25sB25D5ozB4Z73Dm5JVufEz9MlPQVorh47jlS/kJxFcg8TO7sjnSU25
# 7ZkJPY4X8CO4blXIALsEtD4ym1eMPdB83Ug2QoH0p3C6GJ5z/Cy95AYW4jUHRKup
# NVKgOICUbBOLLaNhyDh+TlxkmCmclnUGctL9LxjUDH6LPzzdHTR55bVMKT/GDE4e
# EZuWGJAh8yS6Nky4Loux5CUW2ikQ9Ha3pgprLNKaUnZV34n1dnousERcYLoy5I4E
# UbQaFGbGXB+s4W0t8lETiARmRphYfJK2sz2syHpGPafDabAOqfzQ7PHeaqQEbULz
# 00YGCEOSOaRrTLgWwksEKSgYnqYq5yVAlDLcpd67mBjsNDKJ2EGAA7wmzXXo1Q==
# SIG # End signature block