src/Private/Core/Write-FotonBanner.ps1

#Requires -Version 5.1
<#
    Cosmetic only. The Windows-pane ASCII mark is parked (commented at
    render time) until it is refined; the FOTON wordmark still prints.
 
    NOT EVIDENCE. Nothing here reaches collection.log, the manifest, or any
    packaged artifact. Every render path is wrapped so a console, codepage,
    or font problem can never abort a collection run.
 
    THE MARK
    --------
    The real Windows 11 logo is four SOLID panes in a single blue, split by a
    cross-shaped gutter. It is not hatched. The slash fill in earlier versions
    of this banner came from the neofetch / fastfetch Windows 11 ascii_data,
    where '/' approximates a solid block using only ASCII -- it is a terminal
    compatibility hack, not part of Microsoft's mark.
 
    So there are two styles:
 
      Solid U+2588 FULL BLOCK panes. Faithful to the logo. Default.
      Hatch '/' panes. The neofetch/fastfetch approximation. Pure ASCII,
              survives a legacy raster-font conhost or a non-UTF-8 codepage.
 
    Style Auto probes whether the console output encoding can represent
    U+2588 and falls back to Hatch if it cannot. The wordmark is gated the
    same way -- it previously emitted box-drawing glyphs unconditionally,
    which degrades to '?' on a CP437 / CP1252 console.
 
    Wordmark is figlet ANSI Shadow, with figlet Standard as the ASCII
    fallback. The Updated line is PrivateData.Foton.ReleaseDate in Foton.psd1.
#>


# ---------------------------------------------------------------------------
# Console capability
# ---------------------------------------------------------------------------

$script:FotonColorMode = $null
$script:FotonBlockOk   = $null

function Get-FotonColorMode {
    <#
        Returns 'TrueColor', 'Console16', or 'None'.
 
        PS 5.1 on conhost does not enable ENABLE_VIRTUAL_TERMINAL_PROCESSING
        by default -- PSReadLine turns it on for interactive sessions, so ANSI
        "just works" at a prompt and emits literal escape garbage under
        `powershell.exe -File`, which is how a deployed collector runs. Probe
        and enable rather than assuming either way.
 
        Note: enabling VT is a process-wide console state change. It is not
        restored, because restoring it would break every subsequent render.
    #>

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

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

    $mode = 'Console16'

    if ($env:NO_COLOR -or $env:FOTON_NO_COLOR) { $mode = 'None' }
    elseif ($(try { [Console]::IsOutputRedirected } catch { $false })) { $mode = 'None' }

    # ISE has no VT parser; it renders escapes as literal text.
    elseif ($Host.Name -notmatch 'ConsoleHost' -and $PSVersionTable.PSVersion.Major -lt 6) { $mode = 'Console16' }

    elseif ($PSVersionTable.PSVersion.Major -ge 6) { $mode = 'TrueColor' }
    elseif ($env:WT_SESSION -or $env:TERM_PROGRAM -or $env:ConEmuANSI -eq 'ON') { $mode = 'TrueColor' }
    else {
        try {
            if (-not ('Foton.NativeConsole' -as [type])) {
                Add-Type -Namespace 'Foton' -Name 'NativeConsole' -MemberDefinition @'
[DllImport("kernel32.dll", SetLastError = true)]
public static extern IntPtr GetStdHandle(int nStdHandle);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetConsoleMode(IntPtr hConsoleHandle, uint dwMode);
'@
 -ErrorAction Stop
            }
            $handle = [Foton.NativeConsole]::GetStdHandle(-11)   # STD_OUTPUT_HANDLE
            [uint32]$current = 0
            if ([Foton.NativeConsole]::GetConsoleMode($handle, [ref]$current)) {
                $vt = 0x0004                                     # ENABLE_VIRTUAL_TERMINAL_PROCESSING
                if (($current -band $vt) -eq $vt) { $mode = 'TrueColor' }
                elseif ([Foton.NativeConsole]::SetConsoleMode($handle, ($current -bor $vt))) { $mode = 'TrueColor' }
            }
        }
        catch { $mode = 'Console16' }
    }

    $script:FotonColorMode = $mode
    return $mode
}

function Test-FotonBlockGlyph {
    <#
        Can the console output encoding carry U+2588 and the box-drawing
        range? Round-trips a probe string; a lossy encoding substitutes '?'
        and we detect it.
 
        This is an ENCODING test, not a font test -- a console using a raster
        font may still show tofu. Callers can force -Style Hatch.
    #>

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

    if ($null -ne $script:FotonBlockOk -and -not $Refresh) { return $script:FotonBlockOk }

    $ok = $false
    try {
        $probe    = [string][char]0x2588 + [char]0x2557 + [char]0x2550
        $encoding = [Console]::OutputEncoding
        if ($encoding) {
            $ok = ($encoding.GetString($encoding.GetBytes($probe)) -eq $probe)
        }
    }
    catch { $ok = $false }

    $script:FotonBlockOk = $ok
    return $ok
}

function Get-FotonMix {
    # Linear blend between two RGB triples. $T is clamped 0..1.
    param([int[]]$From, [int[]]$To, [double]$T)
    if ($T -lt 0) { $T = 0 } elseif ($T -gt 1) { $T = 1 }
    return @(
        [int][Math]::Round($From[0] + ($To[0] - $From[0]) * $T)
        [int][Math]::Round($From[1] + ($To[1] - $From[1]) * $T)
        [int][Math]::Round($From[2] + ($To[2] - $From[2]) * $T)
    )
}

# ---------------------------------------------------------------------------
# The mark
# ---------------------------------------------------------------------------
#
# Both styles are a 26 x 9 grid: two 12-wide panes, 2-column gutter, blank row
# between the upper and lower pair. Glyph contract:
#
# pane char intact glass -> blue/cyan gradient
# \ _ | fracture line -> hot core, amber at the extremities
# (space) void -> open glass, or a structural gutter
#
# Three rules keep the fracture readable instead of looking like stray
# punctuation:
#
# 1. Runs must be CONNECTED. In a monospace cell '\' joins bottom-right to
# the next cell's top-left, so a '\' run has to step exactly +1 column
# per row. '_' joins horizontally, '|' vertically. Skipping a column
# leaves a visible break that reads as noise.
# 2. In Hatch, never draw a fracture with '/'. It is the fill character, so
# a '/' crack is invisible by definition. Secondary hairlines are cut as
# pure voids instead.
# 3. In Solid the void carries all the continuity, so the amber glyphs are
# only glints along the break and may be dashed.
#
# Both paths run from a top-left tip, through an impact point at the logo's
# own centre cross, out through the bottom-right corner.

# Solid panes, UTF-8 base64 so this file stays ASCII: PS 5.1 reads a BOM-less
# .ps1 as the system ANSI codepage, and raw block glyphs then parse as garbage
# (Unexpected token).
$script:FotonMarkSolidB64 = @(
    '4paI4paI4paIXOKWiOKWiOKWiOKWiOKWiOKWiOKWiOKWiCAg4paI4paI4paI4paI4paI4paI4paI4paI4paI4paI4paI4paI'
    '4paI4paI4paI4paIIFzilojilojilojilojilojiloggIOKWiOKWiOKWiOKWiOKWiCDilojilojilojilojilojilog='
    '4paI4paI4paI4paI4paI4paIIFzilojilojilojiloggIOKWiOKWiOKWiCAg4paI4paI4paI4paI4paI4paI4paI'
    '4paI4paI4paI4paI4paI4paI4paI4paIICBc4paIICDiloggIOKWiOKWiOKWiOKWiOKWiOKWiOKWiOKWiOKWiA=='
    'ICAgICAgICAgICAgXCAgICAgICAgICAgICA='
    '4paI4paI4paI4paI4paI4paI4paI4paI4paIICDiloggICBc4paI4paI4paI4paI4paI4paI4paI4paI4paI4paI'
    '4paI4paI4paI4paI4paI4paI4paIICDilojilojiloggIOKWiOKWiCBc4paI4paI4paI4paI4paI4paI4paI4paI'
    '4paI4paI4paI4paI4paI4paIIOKWiOKWiOKWiOKWiOKWiCAg4paI4paI4paI4paIIFzilojilojilojilojilojilog='
    '4paI4paI4paI4paI4paI4paI4paI4paI4paI4paI4paI4paIICDilojilojilojilojilojiloggXOKWiOKWiOKWiOKWiA=='
)

# Hatch panes. Pure ASCII, hand-editable.
$script:FotonMarkHatch = @(
    '//\ //////// //// ///////'
    '///___\ //// /// ////////'
    '///////\ // // /////////'
    '////////___\ / //////////'
    ' __\ '
    '/////////// /\ ////////'
    '////////// / //___\ /////'
    '///////// // //////\ ///'
    '//////// /// ///////_____'
)

$script:FotonMarkWidth = 26

# Impact point in (row, column) grid coordinates. The crack gradient and the
# stress halo both key off this, so moving it re-lights the whole mark.
$script:FotonImpact = @(4.0, 12.5)

# Palette.
$script:FotonPaneDark  = @(0x1E, 0x3A, 0x8A)   # indigo, upper left
$script:FotonPaneLite  = @(0x22, 0xD3, 0xEE)   # cyan, lower right
$script:FotonStress    = @(0xE8, 0xF4, 0xFF)   # glass whitening beside a break
$script:FotonCrackCore = @(0xFF, 0xF7, 0xE0)   # at the impact
$script:FotonCrackTip  = @(0xF5, 0x9E, 0x0B)   # amber, at the extremities
$script:FotonWordDark  = @(0x3B, 0x82, 0xF6)
$script:FotonWordLite  = @(0x7D, 0xEF, 0xFF)

function Get-FotonMarkCells {
    <#
        Resolves the mark into per-character colour. Returns one array of cell
        objects per row: @{ Ch = <char>; Rgb = <int[]>; C16 = <string> }.
    #>

    [CmdletBinding()]
    param(
        [ValidateSet('Solid', 'Hatch')]
        [string]$Style = 'Solid'
    )

    if ($Style -eq 'Solid') {
        $enc  = [Text.Encoding]::UTF8
        $rows = @($script:FotonMarkSolidB64 | ForEach-Object { $enc.GetString([Convert]::FromBase64String($_)) })
        $pane = [char]0x2588
        # A whole brightened cell is a large visual event in Solid, so the
        # stress halo must be far weaker here than in Hatch, or it competes
        # with the fracture and the panes go mushy.
        $offsets = @(
            @{ dR = 0; dC = -1; W = 0.14 }
            @{ dR = 0; dC = 1; W = 0.084 }
        )
    }
    else {
        $rows = $script:FotonMarkHatch
        $pane = '/'
        # Hatch is thin strokes, so it takes a much stronger halo to register.
        # The right-hand side is left alone: the voids baked into the art sit
        # there and already read as the crack's shadow.
        $offsets = @(
            @{ dR = 0; dC = -1; W = 0.42 }
            @{ dR = -1; dC = 0; W = 0.34 }
            @{ dR = 1; dC = 0; W = 0.34 }
            @{ dR = 0; dC = -2; W = 0.18 }
        )
    }

    $width   = $script:FotonMarkWidth
    $height  = $rows.Count
    $impactR = $script:FotonImpact[0]
    $impactC = $script:FotonImpact[1]

    $halo = @{}
    for ($r = 0; $r -lt $height; $r++) {
        $line = $rows[$r].PadRight($width).Substring(0, $width)
        for ($c = 0; $c -lt $width; $c++) {
            $ch = $line[$c]
            if ($ch -eq ' ' -or $ch -eq $pane) { continue }
            foreach ($o in $offsets) {
                $key = '{0},{1}' -f ($r + $o.dR), ($c + $o.dC)
                if (-not $halo.ContainsKey($key)) { $halo[$key] = $o.W }
            }
        }
    }

    $grid = New-Object 'System.Collections.Generic.List[object]'

    for ($r = 0; $r -lt $height; $r++) {
        $line  = $rows[$r].PadRight($width).Substring(0, $width)
        $cells = New-Object 'System.Collections.Generic.List[object]'

        for ($c = 0; $c -lt $width; $c++) {
            $ch = $line[$c]

            if ($ch -eq ' ') {
                $cells.Add([pscustomobject]@{ Ch = ' '; Rgb = $null; C16 = $null })
                continue
            }

            if ($ch -eq $pane) {
                # Diagonal sweep so the four panes read as one sheet of glass.
                $t   = ($c / ($width - 1)) * 0.6 + ($r / ($height - 1)) * 0.4
                $rgb = Get-FotonMix -From $script:FotonPaneDark -To $script:FotonPaneLite -T $t
                $key = '{0},{1}' -f $r, $c
                if ($halo.ContainsKey($key)) {
                    $rgb = Get-FotonMix -From $rgb -To $script:FotonStress -T $halo[$key]
                }
                $c16 = if ($t -lt 0.34) { 'Blue' } elseif ($t -lt 0.68) { 'DarkCyan' } else { 'Cyan' }
                $cells.Add([pscustomobject]@{ Ch = $ch; Rgb = $rgb; C16 = $c16 })
                continue
            }

            # Fracture. Row delta is weighted because cells run roughly 2:1
            # tall, so this stays circular on screen instead of an oval.
            $d   = [Math]::Sqrt(3.2 * [Math]::Pow($r - $impactR, 2) + [Math]::Pow($c - $impactC, 2))
            $t   = [Math]::Min($d / 14.0, 1.0)
            $rgb = Get-FotonMix -From $script:FotonCrackCore -To $script:FotonCrackTip -T $t
            $c16 = if ($d -lt 7) { 'White' } else { 'Yellow' }
            $cells.Add([pscustomobject]@{ Ch = $ch; Rgb = $rgb; C16 = $c16 })
        }

        $grid.Add($cells)
    }

    return $grid
}

function Write-FotonCells {
    <#
        Emits one line. TrueColor builds a single string with run-length
        collapsed SGR sequences; Console16 falls back to -ForegroundColor;
        None drops colour entirely.
    #>

    [CmdletBinding()]
    param(
        [object[]]$Cells,
        [string]$Mode,
        [object[]]$Trailer
    )

    $all = @()
    if ($Cells)   { $all += $Cells }
    if ($Trailer) { $all += $Trailer }

    if ($Mode -eq 'TrueColor') {
        $esc = [char]27
        $sb  = New-Object System.Text.StringBuilder
        $lastKey = ''
        foreach ($cell in $all) {
            if ($null -eq $cell.Rgb) {
                if ($lastKey -ne '') { [void]$sb.Append("$esc[0m"); $lastKey = '' }
                [void]$sb.Append($cell.Ch)
                continue
            }
            $key = '{0};{1};{2}' -f $cell.Rgb[0], $cell.Rgb[1], $cell.Rgb[2]
            if ($key -ne $lastKey) {
                [void]$sb.Append("$esc[38;2;$key`m")
                $lastKey = $key
            }
            [void]$sb.Append($cell.Ch)
        }
        if ($lastKey -ne '') { [void]$sb.Append("$esc[0m") }
        Write-Host $sb.ToString()
        return
    }

    if ($Mode -eq 'None') {
        Write-Host (-join ($all | ForEach-Object { $_.Ch }))
        return
    }

    $buffer  = New-Object System.Text.StringBuilder
    $lastC16 = $null
    foreach ($cell in $all) {
        if ($cell.C16 -ne $lastC16) {
            if ($buffer.Length -gt 0) {
                if ($lastC16) { Write-Host $buffer.ToString() -ForegroundColor $lastC16 -NoNewline }
                else { Write-Host $buffer.ToString() -NoNewline }
                [void]$buffer.Clear()
            }
            $lastC16 = $cell.C16
        }
        [void]$buffer.Append($cell.Ch)
    }
    if ($buffer.Length -gt 0) {
        if ($lastC16) { Write-Host $buffer.ToString() -ForegroundColor $lastC16 -NoNewline }
        else { Write-Host $buffer.ToString() -NoNewline }
    }
    Write-Host ''
}

# ---------------------------------------------------------------------------

function Write-FotonBanner {
    [CmdletBinding()]
    param(
        [ValidateSet('Collect', 'Baseline', 'Analyze', 'Easy', 'Parse', 'Carve', 'Compare')]
        [string]$Kind = 'Collect',

        # Auto = Solid when the console encoding can carry U+2588, else Hatch.
        [ValidateSet('Auto', 'Solid', 'Hatch')]
        [string]$Style = 'Auto',

        [ValidateSet('Auto', 'TrueColor', 'Console16', 'None')]
        [string]$Color = 'Auto'
    )

    $ver = 'unknown'
    $released = 'unknown'
    if ($script:FotonSession -and $script:FotonSession.Version) {
        $ver = [string]$script:FotonSession.Version
        if ($script:FotonSession.ReleaseDate) { $released = [string]$script:FotonSession.ReleaseDate }
    }
    elseif ($script:FotonModuleRoot) {
        $psd1 = Join-Path $script:FotonModuleRoot 'Foton.psd1'
        if (Test-Path -LiteralPath $psd1) {
            # Text match, not Import-PowerShellDataFile: Windows PowerShell started from a
            # PowerShell 7 prompt inherits its module path and that cmdlet fails to load.
            try {
                $raw = [IO.File]::ReadAllText($psd1)
                $m = [regex]::Match($raw, "(?m)^\s*ModuleVersion\s*=\s*'([^']+)'")
                if ($m.Success) { $ver = $m.Groups[1].Value }
                $m = [regex]::Match($raw, "(?m)^\s*ReleaseDate\s*=\s*'([^']+)'")
                if ($m.Success) { $released = $m.Groups[1].Value }
            }
            catch { }
        }
    }

    $mode = 'Collect (live triage on this host)'
    switch ($Kind) {
        'Baseline' { $mode = 'Baseline (stable config snapshot)' }
        'Analyze'  { $mode = 'Analyze (offline package)' }
        'Easy'     { $mode = 'Foton.cmd wrapper' }
        'Parse'    { $mode = 'Parse (offline artifacts)' }
        'Carve'    { $mode = 'Carve (signature scan, Tier 3)' }
        'Compare'  { $mode = 'Compare (offline baseline diff)' }
    }

    try {
        $colorMode = if ($Color -eq 'Auto') { Get-FotonColorMode } else { $Color }
        $blockOk   = Test-FotonBlockGlyph
        # $markStyle = if ($Style -eq 'Auto') { if ($blockOk) { 'Solid' } else { 'Hatch' } } else { $Style }

        # Figlet ANSI Shadow "FOTON", UTF-8 base64 so this file stays ASCII.
        $enc = [Text.Encoding]::UTF8
        $wordShadow = @(
            $enc.GetString([Convert]::FromBase64String('4paI4paI4paI4paI4paI4paI4paI4pWXIOKWiOKWiOKWiOKWiOKWiOKWiOKVlyDilojilojilojilojilojilojilojilojilZcg4paI4paI4paI4paI4paI4paI4pWXIOKWiOKWiOKWiOKVlyAgIOKWiOKWiOKVlw=='))
            $enc.GetString([Convert]::FromBase64String('4paI4paI4pWU4pWQ4pWQ4pWQ4pWQ4pWd4paI4paI4pWU4pWQ4pWQ4pWQ4paI4paI4pWX4pWa4pWQ4pWQ4paI4paI4pWU4pWQ4pWQ4pWd4paI4paI4pWU4pWQ4pWQ4pWQ4paI4paI4pWX4paI4paI4paI4paI4pWXICDilojilojilZE='))
            $enc.GetString([Convert]::FromBase64String('4paI4paI4paI4paI4paI4pWXICDilojilojilZEgICDilojilojilZEgICDilojilojilZEgICDilojilojilZEgICDilojilojilZHilojilojilZTilojilojilZcg4paI4paI4pWR'))
            $enc.GetString([Convert]::FromBase64String('4paI4paI4pWU4pWQ4pWQ4pWdICDilojilojilZEgICDilojilojilZEgICDilojilojilZEgICDilojilojilZEgICDilojilojilZHilojilojilZHilZrilojilojilZfilojilojilZE='))
            $enc.GetString([Convert]::FromBase64String('4paI4paI4pWRICAgICDilZrilojilojilojilojilojilojilZTilZ0gICDilojilojilZEgICDilZrilojilojilojilojilojilojilZTilZ3ilojilojilZEg4pWa4paI4paI4paI4paI4pWR'))
            $enc.GetString([Convert]::FromBase64String('4pWa4pWQ4pWdICAgICAg4pWa4pWQ4pWQ4pWQ4pWQ4pWQ4pWdICAgIOKVmuKVkOKVnSAgICDilZrilZDilZDilZDilZDilZDilZ0g4pWa4pWQ4pWdICDilZrilZDilZDilZDilZ0='))
            ''
            ''
            ''
        )

        # Figlet Standard. Pure ASCII, for consoles that cannot carry the
        # box-drawing range.
        $wordAscii = @(
            ' _____ ___ _____ ___ _ _ '
            '| ___/ _ \_ _/ _ \| \ | |'
            '| |_ | | | || || | | | \| |'
            '| _|| |_| || || |_| | |\ |'
            '|_| \___/ |_| \___/|_| \_|'
            ''
            ''
            ''
            ''
        )

        $word      = if ($blockOk) { $wordShadow } else { $wordAscii }
        $wordWidth = ($word | Measure-Object -Property Length -Maximum).Maximum
        if (-not $wordWidth) { $wordWidth = 1 }

        $gap = [pscustomobject]@{ Ch = ' '; Rgb = $null; C16 = $null }

        Write-Host ''
        foreach ($text in $word) {
            if (-not $text) { continue }
            $cells = @($gap) * 2
            for ($k = 0; $k -lt $text.Length; $k++) {
                if ($text[$k] -eq ' ') { $cells += $gap; continue }
                $t = if ($wordWidth -gt 1) { $k / ($wordWidth - 1) } else { 0 }
                $cells += [pscustomobject]@{
                    Ch  = $text[$k]
                    Rgb = (Get-FotonMix -From $script:FotonWordDark -To $script:FotonWordLite -T $t)
                    C16 = $(if ($t -lt 0.5) { 'Blue' } else { 'Cyan' })
                }
            }
            Write-FotonCells -Cells $cells -Mode $colorMode
        }

        # Windows-pane mark parked until the crack/logo is refined.
        # $grid = Get-FotonMarkCells -Style $markStyle
        # Write-Host ''
        # foreach ($row in $grid) {
        # Write-FotonCells -Cells (@($gap) * 2 + $row) -Mode $colorMode
        # }
    }
    catch {
        # Cosmetics must never take down a run.
        Write-Verbose ("Banner render failed, using plain fallback: {0}" -f $_.Exception.Message)
        Write-Host ''
        Write-Host ' FOTON'
        Write-Host ''
        # foreach ($line in $script:FotonMarkHatch) { Write-Host (' ' + $line) }
    }

    Write-Host ''
    Write-Host ' Windows DFIR / baseline / audit' -ForegroundColor Gray
    Write-Host ' Author jimididit' -ForegroundColor DarkGray
    Write-Host (" Version {0}" -f $ver) -ForegroundColor DarkGray
    Write-Host (" Updated {0}" -f $released) -ForegroundColor DarkGray
    Write-Host (" Mode {0}" -f $mode) -ForegroundColor Gray
    Write-Host ' Status Final 1.38.x release. On Windows 10 / Server 2016+ use Foton v2 (foton-collect.exe):' -ForegroundColor Yellow
    Write-Host ' https://github.com/jimididit/foton/releases/latest' -ForegroundColor Yellow
    Write-Host ' Opening a window...closes a door' -ForegroundColor DarkGray
    Write-Host ''
}