Private/Core.Inspection.ps1

function ConvertTo-OIOffset {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateNotNullOrEmpty()]
        [string]$InputValue
    )

    $value = $InputValue.Trim()
    $parsed = $null

    try {
        if ($value -match '(?i)^0x[0-9a-f]+$') {
            $parsed = [Convert]::ToInt64($value.Substring(2), 16)
        }
        elseif ($value -match '(?i)^[0-9a-f]+h$') {
            $parsed = [Convert]::ToInt64($value.Substring(0, $value.Length - 1), 16)
        }
        elseif ($value -match '^[0-9]+$') {
            $parsed = [Convert]::ToInt64($value, 10)
        }
        elseif ($value -match '(?i)^[0-9a-f]+$' -and $value -match '(?i)[a-f]') {
            $parsed = [Convert]::ToInt64($value, 16)
        }
    }
    catch {
        throw "Offset '$InputValue' exceeds the signed 64-bit range."
    }

    if ($null -eq $parsed) {
        throw "Invalid offset '$InputValue'. Use decimal, 0x-prefixed hexadecimal, hexadecimal ending in h, or unprefixed hexadecimal containing A-F."
    }

    if ([int64]$parsed -lt 0) {
        throw "Offset '$InputValue' exceeds the signed 64-bit range."
    }

    return [int64]$parsed
}

function New-OIInspectionPlan {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [string[]]$Files,

        [Parameter(Mandatory = $true)]
        [string[]]$Offsets
    )

    $plan = New-Object 'System.Collections.Generic.List[object]'
    $planIndex = 0

    if ($Files.Count -eq 1) {
        foreach ($offset in $Offsets) {
            $plan.Add([pscustomobject]@{ Index = $planIndex; FilePath = $Files[0]; OffsetInput = $offset })
            $planIndex++
        }
        return $plan.ToArray()
    }

    if ($Offsets.Count -eq 1) {
        foreach ($file in $Files) {
            $plan.Add([pscustomobject]@{ Index = $planIndex; FilePath = $file; OffsetInput = $Offsets[0] })
            $planIndex++
        }
        return $plan.ToArray()
    }

    if ($Offsets.Count -eq $Files.Count) {
        for ($index = 0; $index -lt $Files.Count; $index++) {
            $plan.Add([pscustomobject]@{ Index = $index; FilePath = $Files[$index]; OffsetInput = $Offsets[$index] })
        }
        return $plan.ToArray()
    }

    throw "Offset count ($($Offsets.Count)) must be one, equal the file count ($($Files.Count)), or contain multiple offsets for a single file."
}

function Format-OIOffsetHex {
    [CmdletBinding()]
    param(
        [AllowNull()]
        [object]$Value
    )

    if ($null -eq $Value) { return $null }
    return ('0x{0:X}' -f ([int64]$Value))
}

function Format-OIHexDump {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [byte[]]$Data,

        [Parameter(Mandatory = $true)]
        [int64]$StartOffset,

        [Parameter(Mandatory = $true)]
        [int64]$HighlightOffset,

        [Parameter(Mandatory = $true)]
        [int64]$FileSize
    )

    $rows = New-Object 'System.Collections.Generic.List[object]'
    $offsetWidth = if ($FileSize -gt [uint32]::MaxValue) { 16 } else { 8 }

    for ($index = 0; $index -lt $Data.Length; $index += 16) {
        $count = [Math]::Min(16, $Data.Length - $index)
        $hexParts = New-Object 'System.Collections.Generic.List[object]'
        $ascii = New-Object System.Text.StringBuilder

        for ($byteIndex = 0; $byteIndex -lt $count; $byteIndex++) {
            $value = $Data[$index + $byteIndex]
            $absoluteOffset = $StartOffset + $index + $byteIndex
            $hexParts.Add([pscustomobject]@{
                Text        = $value.ToString('X2')
                IsHighlight = ($absoluteOffset -eq $HighlightOffset)
            })

            if ($value -ge 32 -and $value -le 126) {
                $null = $ascii.Append([char]$value)
            }
            else {
                $null = $ascii.Append('.')
            }
        }

        $offsetFormat = '{0:X' + $offsetWidth + '}'
        $rows.Add([pscustomobject]@{
            OffsetDecimal = $StartOffset + $index
            Offset        = ($offsetFormat -f ($StartOffset + $index))
            HexParts      = $hexParts.ToArray()
            Hex           = (($hexParts.ToArray() | ForEach-Object { $_.Text }) -join ' ')
            Ascii         = $ascii.ToString()
        })
    }

    return $rows.ToArray()
}

function New-OILineDescriptor {
    [CmdletBinding()]
    param(
        [int64]$Number,
        [int64]$Start,
        [int64]$EndExclusive
    )

    return [pscustomobject]@{
        Number       = $Number
        Start        = $Start
        EndExclusive = $EndExclusive
    }
}

function Complete-OILine {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Descriptor,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[object]]$CurrentLineRecords,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.List[object]]$PendingNextRecords,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [System.Collections.Generic.Queue[object]]$PreviousLines,

        [Parameter(Mandatory = $true)]
        [int]$ContextLines
    )

    if ($PendingNextRecords.Count -gt 0) {
        $stillPending = New-Object 'System.Collections.Generic.List[object]'
        foreach ($record in $PendingNextRecords) {
            $record.NextLines.Add($Descriptor)
            if ($record.NextLines.Count -lt $ContextLines) {
                $stillPending.Add($record)
            }
        }

        $PendingNextRecords.Clear()
        foreach ($record in $stillPending) {
            $PendingNextRecords.Add($record)
        }
    }

    foreach ($record in $CurrentLineRecords) {
        $record.TargetLine = $Descriptor
        if ($ContextLines -gt 0) {
            $PendingNextRecords.Add($record)
        }
    }
    $CurrentLineRecords.Clear()

    if ($ContextLines -gt 0) {
        $PreviousLines.Enqueue($Descriptor)
        while ($PreviousLines.Count -gt $ContextLines) {
            $null = $PreviousLines.Dequeue()
        }
    }
}

function Get-OILineRecords {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Context,

        [Parameter(Mandatory = $true)]
        [int64[]]$Offsets,

        [Parameter(Mandatory = $true)]
        [ValidateRange(0, 100)]
        [int]$ContextLines
    )

    $result = @{}
    if ($Offsets.Count -eq 0 -or $Context.Length -eq 0) { return $result }

    $targets = @($Offsets | Sort-Object -Unique)
    $previousLines = New-Object 'System.Collections.Generic.Queue[object]'
    $currentLineRecords = New-Object 'System.Collections.Generic.List[object]'
    $pendingNextRecords = New-Object 'System.Collections.Generic.List[object]'
    $pattern = $Context.EncodingInfo.NewlineBytes
    $unitSize = [int]$Context.EncodingInfo.UnitSize
    $preambleLength = [int]$Context.EncodingInfo.PreambleLength
    $targetIndex = 0
    $lineNumber = [int64]1
    $lineStart = [int64]0
    $matchIndex = 0
    $absolutePosition = [int64]0
    $buffer = New-Object byte[] (1024 * 1024)
    $stop = $false

    $null = $Context.Stream.Seek(0, [System.IO.SeekOrigin]::Begin)

    while (-not $stop) {
        $read = $Context.Stream.Read($buffer, 0, $buffer.Length)
        if ($read -le 0) { break }

        for ($bufferIndex = 0; $bufferIndex -lt $read; $bufferIndex++) {
            $currentByte = $buffer[$bufferIndex]

            while ($targetIndex -lt $targets.Count -and $targets[$targetIndex] -eq $absolutePosition) {
                $record = [pscustomobject]@{
                    Offset        = [int64]$targets[$targetIndex]
                    LineNumber    = $lineNumber
                    LineStart     = $lineStart
                    PreviousLines = @($previousLines.ToArray())
                    TargetLine    = $null
                    NextLines     = New-Object 'System.Collections.Generic.List[object]'
                }
                $currentLineRecords.Add($record)
                $result[[string]$record.Offset] = $record
                $targetIndex++
            }

            if ($currentByte -eq $pattern[$matchIndex]) {
                $matchIndex++
            }
            elseif ($currentByte -eq $pattern[0]) {
                $matchIndex = 1
            }
            else {
                $matchIndex = 0
            }

            if ($matchIndex -eq $pattern.Length) {
                $sequenceStart = $absolutePosition - $pattern.Length + 1
                $aligned = ($unitSize -eq 1) -or ((($sequenceStart - $preambleLength) % $unitSize) -eq 0)

                if ($aligned) {
                    $descriptor = New-OILineDescriptor -Number $lineNumber -Start $lineStart -EndExclusive $sequenceStart
                    Complete-OILine `
                        -Descriptor $descriptor `
                        -CurrentLineRecords $currentLineRecords `
                        -PendingNextRecords $pendingNextRecords `
                        -PreviousLines $previousLines `
                        -ContextLines $ContextLines
                    $lineNumber++
                    $lineStart = $absolutePosition + 1

                    if ($targetIndex -ge $targets.Count -and $pendingNextRecords.Count -eq 0) {
                        $stop = $true
                    }
                }

                $matchIndex = 0
            }

            $absolutePosition++

            if ($stop) { break }
        }
    }

    if (-not $stop) {
        $finalDescriptor = New-OILineDescriptor -Number $lineNumber -Start $lineStart -EndExclusive $Context.Length
        Complete-OILine `
            -Descriptor $finalDescriptor `
            -CurrentLineRecords $currentLineRecords `
            -PendingNextRecords $pendingNextRecords `
            -PreviousLines $previousLines `
            -ContextLines $ContextLines
    }

    return $result
}

function ConvertFrom-OIByteSequence {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Text.Encoding]$Encoding,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [byte[]]$Bytes
    )

    if ($Bytes.Length -eq 0) { return [string]::Empty }

    $decoder = $Encoding.GetDecoder()
    $characters = New-Object char[] ($Encoding.GetMaxCharCount($Bytes.Length))
    $bytesUsed = 0
    $charactersUsed = 0
    $completed = $false
    $decoder.Convert(
        $Bytes,
        0,
        $Bytes.Length,
        $characters,
        0,
        $characters.Length,
        $false,
        [ref]$bytesUsed,
        [ref]$charactersUsed,
        [ref]$completed
    )

    $builder = New-Object System.Text.StringBuilder($charactersUsed)
    $null = $builder.Append($characters, 0, $charactersUsed)
    return $builder.ToString()
}

function Get-OICharacterCount {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [System.Text.Encoding]$Encoding,

        [Parameter(Mandatory = $true)]
        [AllowEmptyCollection()]
        [byte[]]$Bytes,

        [switch]$RemoveLeadingBom
    )

    if ($Bytes.Length -eq 0) { return 0 }

    $decoder = $Encoding.GetDecoder()
    $count = $decoder.GetCharCount($Bytes, 0, $Bytes.Length, $false)

    if ($RemoveLeadingBom -and $count -gt 0) {
        $decoded = ConvertFrom-OIByteSequence -Encoding $Encoding -Bytes $Bytes
        if ($decoded.Length -gt 0 -and $decoded[0] -eq [char]0xFEFF) {
            $count--
        }
    }

    return [Math]::Max(0, $count)
}

function Get-OIAlignedUtf8PreviewStart {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Context,

        [Parameter(Mandatory = $true)]
        [int64]$LineStart,

        [Parameter(Mandatory = $true)]
        [int64]$ProposedStart
    )

    if ($Context.EncodingInfo.DetectedName -ne 'UTF8' -or $ProposedStart -le $LineStart) {
        return $ProposedStart
    }

    $candidate = $ProposedStart
    for ($step = 0; $step -lt 3 -and $candidate -gt $LineStart; $step++) {
        $current = Read-OIFileRange -Stream $Context.Stream -Start $candidate -Length 1
        if ($current.Length -eq 0 -or ($current[0] -band 0xC0) -ne 0x80) {
            break
        }
        $candidate--
    }

    return $candidate
}

function Get-OIDecodedLine {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Context,

        [Parameter(Mandatory = $true)]
        [object]$Descriptor,

        [AllowNull()]
        [object]$TargetOffset,

        [Parameter(Mandatory = $true)]
        [ValidateRange(1024, 16777216)]
        [int]$MaxLineBytes
    )

    $lineLength = [Math]::Max([int64]0, $Descriptor.EndExclusive - $Descriptor.Start)
    $previewStart = [int64]$Descriptor.Start
    $previewLength = [int][Math]::Min([int64]$MaxLineBytes, $lineLength)
    $truncatedBefore = $false
    $truncatedAfter = ($lineLength -gt $MaxLineBytes)

    if ($null -ne $TargetOffset -and $lineLength -gt $MaxLineBytes) {
        $target = [int64]$TargetOffset
        $half = [int64][Math]::Floor($MaxLineBytes / 2)
        $previewStart = [Math]::Max([int64]$Descriptor.Start, $target - $half)
        $maximumStart = [Math]::Max([int64]$Descriptor.Start, $Descriptor.EndExclusive - $MaxLineBytes)
        $previewStart = [Math]::Min($previewStart, $maximumStart)

        $unitSize = [int]$Context.EncodingInfo.UnitSize
        if ($unitSize -gt 1) {
            $relative = $previewStart - $Descriptor.Start
            $previewStart -= ($relative % $unitSize)
        }
        else {
            $previewStart = Get-OIAlignedUtf8PreviewStart -Context $Context -LineStart $Descriptor.Start -ProposedStart $previewStart
        }

        $previewLength = [int][Math]::Min([int64]$MaxLineBytes, $Descriptor.EndExclusive - $previewStart)
        $truncatedBefore = ($previewStart -gt $Descriptor.Start)
        $truncatedAfter = (($previewStart + $previewLength) -lt $Descriptor.EndExclusive)
    }

    $bytes = Read-OIFileRange -Stream $Context.Stream -Start $previewStart -Length $previewLength
    $text = ConvertFrom-OIByteSequence -Encoding $Context.EncodingInfo.Encoding -Bytes $bytes
    $text = $text.TrimEnd([char[]]"`r`n")

    if ($Descriptor.Number -eq 1 -and $text.Length -gt 0 -and $text[0] -eq [char]0xFEFF) {
        $text = $text.Substring(1)
    }

    if ($truncatedBefore) { $text = [string][char]0x2026 + $text }
    if ($truncatedAfter) { $text = $text + [char]0x2026 }

    $characterPosition = $null
    $previewCharacterPosition = $null

    if ($null -ne $TargetOffset) {
        $target = [int64]$TargetOffset
        $bytePosition = [Math]::Max([int64]0, $target - $Descriptor.Start)
        $previewBytePosition = [Math]::Max([int64]0, $target - $previewStart)

        if ($bytePosition -le $MaxLineBytes) {
            $prefixLength = [int][Math]::Min($bytePosition, [int64][int]::MaxValue)
            $prefixBytes = Read-OIFileRange -Stream $Context.Stream -Start $Descriptor.Start -Length $prefixLength
            $characterPosition = Get-OICharacterCount `
                -Encoding $Context.EncodingInfo.Encoding `
                -Bytes $prefixBytes `
                -RemoveLeadingBom:($Descriptor.Number -eq 1)
        }

        if ($previewBytePosition -le $previewLength) {
            $previewPrefixBytes = Read-OIFileRange -Stream $Context.Stream -Start $previewStart -Length ([int]$previewBytePosition)
            $previewCharacterPosition = (Get-OICharacterCount `
                -Encoding $Context.EncodingInfo.Encoding `
                -Bytes $previewPrefixBytes `
                -RemoveLeadingBom:($Descriptor.Number -eq 1 -and $previewStart -eq $Descriptor.Start)) + `
                $(if ($truncatedBefore) { 1 } else { 0 })
        }
    }

    return [pscustomobject]@{
        Text                     = $text
        IsTruncated              = ($truncatedBefore -or $truncatedAfter)
        TruncatedBefore          = $truncatedBefore
        TruncatedAfter           = $truncatedAfter
        PreviewStartByte         = $previewStart
        CharacterPosition        = $characterPosition
        PreviewCharacterPosition = $previewCharacterPosition
    }
}

function Get-OISingleInspectionFromContext {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Context,

        [Parameter(Mandatory = $true)]
        [ValidateScript({ $_ -ge 0 })]
        [int64]$Offset,

        [ValidateRange(0, 4096)]
        [int]$ByteWindow = 32,

        [ValidateRange(0, 100)]
        [int]$ContextLines = 3,

        [ValidateRange(1024, 16777216)]
        [int]$MaxLineBytes = 1048576
    )

    if ($Context.Length -eq 0) {
        throw "File is empty: $($Context.Path)"
    }
    if ($Offset -ge $Context.Length) {
        throw "Offset $Offset is outside the valid range 0-$($Context.Length - 1)."
    }

    $lineRecords = Get-OILineRecords -Context $Context -Offsets @($Offset) -ContextLines $ContextLines
    $lineRecord = $lineRecords[[string]$Offset]
    if ($null -eq $lineRecord -or $null -eq $lineRecord.TargetLine) {
        throw 'Unable to map the offset to a source line.'
    }

    $result = Get-OIInspectionResult `
        -Context $Context `
        -OffsetInput ([string]$Offset) `
        -Offset $Offset `
        -LineRecord $lineRecord `
        -ByteWindow $ByteWindow `
        -CompareContext $null `
        -CompareError $null `
        -CompareRequestedPath $null `
        -MaxLineBytes $MaxLineBytes

    return $result
}

function New-OIErrorResult {
    [CmdletBinding()]
    param(
        [string]$File,
        [string]$OffsetInput,
        [AllowNull()][object]$Offset,
        [AllowNull()][object]$FileSize,
        [string]$EncodingRequested,
        [string]$CompareFile,
        [string]$Message
    )

    $result = [pscustomobject]@{
        Success                 = $false
        File                    = $File
        OffsetInput             = $OffsetInput
        OffsetDecimal           = if ($null -ne $Offset) { [int64]$Offset } else { $null }
        OffsetHex               = Format-OIOffsetHex -Value $Offset
        FileSize                = if ($null -ne $FileSize) { [int64]$FileSize } else { $null }
        EncodingRequested       = $EncodingRequested
        EncodingDetected        = $null
        LineNumber              = $null
        LineText                = $null
        LineTextTruncated       = $false
        CharacterPosition       = $null
        PreviewCharacterPosition = $null
        BytePositionInLine      = $null
        ContextLines            = @()
        TargetByteHex           = $null
        TargetByteDecimal       = $null
        CompareFile             = $CompareFile
        CompareByteHex          = $null
        CompareByteDecimal      = $null
        BytesDiffer             = $null
        WindowStartOffset       = $null
        WindowEndOffset         = $null
        HexDump                 = @()
        DurationMs              = 0
        Warnings                = @()
        Error                   = $Message
    }
    $result.PSObject.TypeNames.Insert(0, 'OffsetInspect.Result')
    return $result
}

function Get-OIInspectionResult {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory = $true)]
        [object]$Context,

        [Parameter(Mandatory = $true)]
        [string]$OffsetInput,

        [Parameter(Mandatory = $true)]
        [int64]$Offset,

        [Parameter(Mandatory = $true)]
        [object]$LineRecord,

        [Parameter(Mandatory = $true)]
        [int]$ByteWindow,

        [AllowNull()]
        [object]$CompareContext,

        [AllowNull()]
        [string]$CompareError,

        [AllowNull()]
        [string]$CompareRequestedPath,

        [Parameter(Mandatory = $true)]
        [int]$MaxLineBytes
    )

    $watch = [System.Diagnostics.Stopwatch]::StartNew()
    $warnings = New-Object 'System.Collections.Generic.List[string]'

    $targetBytes = Read-OIFileRange -Stream $Context.Stream -Start $Offset -Length 1
    $targetByte = $targetBytes[0]
    $start = [Math]::Max([int64]0, $Offset - $ByteWindow)
    $end = [Math]::Min($Context.Length - 1, $Offset + $ByteWindow)
    $windowLength = [int]($end - $start + 1)
    $windowBytes = Read-OIFileRange -Stream $Context.Stream -Start $start -Length $windowLength
    $hexDump = @(Format-OIHexDump -Data $windowBytes -StartOffset $start -HighlightOffset $Offset -FileSize $Context.Length)

    $targetLine = Get-OIDecodedLine -Context $Context -Descriptor $LineRecord.TargetLine -TargetOffset $Offset -MaxLineBytes $MaxLineBytes
    if ($Offset -lt $Context.EncodingInfo.PreambleLength) {
        $warnings.Add('The target offset lies inside the encoding preamble; the source caret is anchored at the beginning of decoded content.')
    }
    if ($targetLine.IsTruncated) {
        $warnings.Add("The target line exceeded $MaxLineBytes bytes and was previewed in a bounded window.")
    }

    $contextOutput = New-Object 'System.Collections.Generic.List[object]'
    foreach ($descriptor in $LineRecord.PreviousLines) {
        $decoded = Get-OIDecodedLine -Context $Context -Descriptor $descriptor -TargetOffset $null -MaxLineBytes $MaxLineBytes
        $contextOutput.Add([pscustomobject]@{
            LineNumber  = $descriptor.Number
            Text        = $decoded.Text
            IsTarget    = $false
            IsTruncated = $decoded.IsTruncated
        })
    }

    $contextOutput.Add([pscustomobject]@{
        LineNumber  = $LineRecord.TargetLine.Number
        Text        = $targetLine.Text
        IsTarget    = $true
        IsTruncated = $targetLine.IsTruncated
    })

    foreach ($descriptor in $LineRecord.NextLines) {
        $decoded = Get-OIDecodedLine -Context $Context -Descriptor $descriptor -TargetOffset $null -MaxLineBytes $MaxLineBytes
        $contextOutput.Add([pscustomobject]@{
            LineNumber  = $descriptor.Number
            Text        = $decoded.Text
            IsTarget    = $false
            IsTruncated = $decoded.IsTruncated
        })
    }

    $compareByte = $null
    $bytesDiffer = $null
    $errorMessage = $CompareError
    $comparePath = $CompareRequestedPath

    if ($null -ne $CompareContext) {
        $comparePath = $CompareContext.Path
        if ($Offset -lt $CompareContext.Length) {
            $compareBytes = Read-OIFileRange -Stream $CompareContext.Stream -Start $Offset -Length 1
            $compareByte = $compareBytes[0]
            $bytesDiffer = ($targetByte -ne $compareByte)
        }
        else {
            $errorMessage = "Compare file is smaller than offset $Offset."
        }
    }


    $watch.Stop()
    $result = [pscustomobject]@{
        Success                  = [string]::IsNullOrEmpty($errorMessage)
        File                     = $Context.Path
        OffsetInput              = $OffsetInput
        OffsetDecimal            = $Offset
        OffsetHex                = Format-OIOffsetHex -Value $Offset
        FileSize                 = $Context.Length
        EncodingRequested        = $Context.EncodingInfo.RequestedName
        EncodingDetected         = $Context.EncodingInfo.DetectedName
        LineNumber               = $LineRecord.LineNumber
        LineText                 = $targetLine.Text
        LineTextTruncated        = $targetLine.IsTruncated
        CharacterPosition        = $targetLine.CharacterPosition
        PreviewCharacterPosition = $targetLine.PreviewCharacterPosition
        BytePositionInLine       = $Offset - $LineRecord.LineStart
        ContextLines             = $contextOutput.ToArray()
        TargetByteHex            = $targetByte.ToString('X2')
        TargetByteDecimal        = [int]$targetByte
        CompareFile              = $comparePath
        CompareByteHex           = if ($null -ne $compareByte) { $compareByte.ToString('X2') } else { $null }
        CompareByteDecimal       = if ($null -ne $compareByte) { [int]$compareByte } else { $null }
        BytesDiffer              = $bytesDiffer
        WindowStartOffset        = $start
        WindowEndOffset          = $end
        HexDump                  = $hexDump
        DurationMs               = [Math]::Round($watch.Elapsed.TotalMilliseconds, 3)
        Warnings                 = $warnings.ToArray()
        Error                    = $errorMessage
    }
    $result.PSObject.TypeNames.Insert(0, 'OffsetInspect.Result')
    return $result
}