# Shared helpers for the Windows scripts (setup.ps1, install.ps1, join.ps1, cs.ps1). # Windows PowerShell 5.1: keep this file ASCII and avoid PowerShell 7-only syntax. $ErrorActionPreference = 'Stop' $ProgressPreference = 'SilentlyContinue' # the progress bar makes downloads crawl in 5.1 # older Windows 10 installs still default to TLS 1.0: GitHub needs 1.2 [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 Add-Type -AssemblyName System.IO.Compression.FileSystem $Script:Utf8 = New-Object Text.UTF8Encoding $false # Defaults for settings.txt (same keys as the Linux .env) $Script:DefaultSettings = [ordered]@{ CS_HOSTNAME = 'CS 1.6 Deathmatch' CS_PASSWORD = '' CS_MAXPLAYERS = '16' CS_PORT = '27015' CS_MAP = 'de_dust2' CS_MODE = 'dm' CS_BOTS = '6' CS_BOT_DIFFICULTY = '2' CS_PUBLIC_ADDR = '' } function Say($text) { Write-Host ''; Write-Host "== $text" -ForegroundColor Cyan } function Step($text) { Write-Host " - $text" } function Warn($text) { Write-Host $text -ForegroundColor Yellow } # Writes text as UTF-8 without BOM (HLDS ignores UTF-16 cfg files, which is what 5.1's > writes) function Write-TextFile($path, $text) { [IO.File]::WriteAllText($path, $text, $Script:Utf8) } # ---------- settings.txt (KEY=value lines, # comments) ---------- function Read-Settings($path) { $s = [ordered]@{} foreach ($k in $Script:DefaultSettings.Keys) { $s[$k] = $Script:DefaultSettings[$k] } if (Test-Path -LiteralPath $path) { foreach ($line in [IO.File]::ReadAllLines($path)) { if ($line -match '^\s*#' -or $line -notmatch '=') { continue } $i = $line.IndexOf('=') $s[$line.Substring(0, $i).Trim()] = $line.Substring($i + 1).Trim() } } return $s } function Write-Setting($path, $key, $value) { $value = ([string]$value) -replace '["\r\n]', '' $lines = New-Object Collections.Generic.List[string] if (Test-Path -LiteralPath $path) { $lines.AddRange([string[]][IO.File]::ReadAllLines($path)) } $done = $false for ($i = 0; $i -lt $lines.Count; $i++) { if ($lines[$i] -match ('^' + [regex]::Escape($key) + '=')) { $lines[$i] = "$key=$value"; $done = $true } } if (-not $done) { $lines.Add("$key=$value") } Write-TextFile $path (($lines -join "`r`n") + "`r`n") } # ---------- install folder ---------- # Where to install by default: Documents\cs16, unless Documents is synced by OneDrive or the # path has characters SteamCMD/HLDS can't handle. Returns @{ Path = ...; Reason = ... }. function Resolve-InstallDir($documents, $userProfile, [string[]]$oneDriveRoots) { $path = Join-Path $documents 'cs16' $reason = '' foreach ($od in $oneDriveRoots) { if ($od -and $documents.StartsWith($od, [StringComparison]::OrdinalIgnoreCase)) { $path = Join-Path $userProfile 'cs16' $reason = 'Your Documents folder is synced by OneDrive, which would upload ~1 GB of server files and can break them.' } } if ($path -match '[^\x20-\x7e]') { $path = 'C:\cs16' $reason = 'Your user folder name has special characters that the game server cannot handle.' } return @{ Path = $path; Reason = $reason } } # Returns a problem description for a folder the user typed, or $null when it is usable function Test-InstallDir($path) { if (-not [IO.Path]::IsPathRooted($path)) { return 'give a full path, like C:\cs16' } if ($path -match '[^\x20-\x7e]') { return 'use only plain letters and digits in the path (no accents or other alphabets)' } if ($path -match '[\[\]]') { return 'leave out [ and ] in the folder name' } foreach ($pf in @($env:ProgramFiles, ${env:ProgramFiles(x86)})) { if ($pf -and $path.StartsWith($pf, [StringComparison]::OrdinalIgnoreCase)) { return 'Program Files needs admin rights: pick another folder' } } return $null } # ---------- pinned downloads ---------- # tools/versions.txt minus Linux-only files, plus tools/versions-windows.txt # Each item also gets a Mirror URL (backup copy on the project's Gitea, '#mirror ' line) function Get-PinnedDownloads($repo) { $out = @(); $mirror = '' foreach ($f in @('versions.txt', 'versions-windows.txt')) { $p = Join-Path $repo "tools\$f" if (-not (Test-Path -LiteralPath $p)) { continue } foreach ($line in [IO.File]::ReadAllLines($p)) { $line = $line.Trim() if ($line.StartsWith('#mirror ')) { $mirror = $line.Substring(8).Trim(); continue } if ($line -eq '' -or $line.StartsWith('#')) { continue } $parts = $line -split '\s+' if ($parts.Count -lt 3 -or $parts[0] -match '-linux') { continue } $out += [pscustomobject]@{ File = $parts[0]; Sha = $parts[1].ToLower(); Url = $parts[2]; Mirror = '' } } } if ($mirror) { foreach ($i in $out) { $i.Mirror = $mirror + ($i.File -split '/')[-1] } } return $out } function Get-Sha256($path) { # .NET directly: Get-FileHash is missing when 5.1 inherits PowerShell 7's module path $sha = [Security.Cryptography.SHA256]::Create(); $f = [IO.File]::OpenRead($path) try { return (-join ($sha.ComputeHash($f) | ForEach-Object { $_.ToString('x2') })) } finally { $f.Dispose(); $sha.Dispose() } } function Get-Url($url, $outFile) { for ($try = 1; $try -le 3; $try++) { try { Invoke-WebRequest -UseBasicParsing -Uri $url -OutFile $outFile -TimeoutSec 120; return } catch { if ($try -eq 3) { throw "download failed: $url ($($_.Exception.Message))" }; Start-Sleep -Seconds (2 * $try) } } } # Downloads one pinned file into $dlDir unless it is already there with the right hash function Get-Pinned($item, $dlDir) { $dest = Join-Path $dlDir ($item.File -replace '/', '\') if ((Test-Path -LiteralPath $dest) -and (Get-Sha256 $dest) -eq $item.Sha) { return $dest } New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null $part = "$dest.part" # main site first; if it fails or sends something else (an error page, a blocked-site page), # the backup copy. Whatever arrives must match the pinned hash. $got = $null try { Get-Url $item.Url $part; $got = Get-Sha256 $part } catch { if (-not $item.Mirror) { throw } } if ($got -ne $item.Sha -and $item.Mirror) { Write-Host " $($item.File): main site failed, trying the backup copy" try { Get-Url $item.Mirror $part } catch { throw "download failed: $($item.Url) (and the backup copy)" } $got = Get-Sha256 $part } if ($got -ne $item.Sha) { Remove-Item -LiteralPath $part -Force throw "checksum mismatch for $($item.File): expected $($item.Sha), got $got" } Move-Item -LiteralPath $part -Destination $dest -Force return $dest } # Extracts the entries of $zip under $prefix (e.g. 'bin/win32/') into $dest, without the prefix function Expand-ZipPart($zip, $prefix, $dest) { $z = [IO.Compression.ZipFile]::OpenRead($zip) try { $n = 0 foreach ($e in $z.Entries) { $name = $e.FullName -replace '\\', '/' if (-not $name.StartsWith($prefix) -or $name -eq $prefix) { continue } $target = Join-Path $dest (($name.Substring($prefix.Length)) -replace '/', '\') if ($name.EndsWith('/')) { New-Item -ItemType Directory -Force -Path $target | Out-Null; continue } # e.g. amxmodx\logs New-Item -ItemType Directory -Force -Path (Split-Path $target) | Out-Null [IO.Compression.ZipFileExtensions]::ExtractToFile($e, $target, $true) $n++ } if ($n -eq 0) { throw "nothing under '$prefix' in $zip" } } finally { $z.Dispose() } } # Old 6-field CSDM spawn lines (de_prodigy, de_train) -> the 10 fields CSDM-ReAPI reads function Convert-SpawnLine($line) { $f = @($line.Trim() -split '\s+' | Where-Object { $_ -ne '' }) if ($f.Count -eq 6) { return "$($line.TrimEnd()) 0 0 0 0" } return $line } # ---------- talking to the server (A2S query, RCON) ---------- $Script:Hdr = [byte[]](255, 255, 255, 255) function Send-Udp($client, [byte[]]$data) { [void]$client.Send($data, $data.Length) } function Receive-Udp($client) { $ep = New-Object Net.IPEndPoint ([Net.IPAddress]::Any), 0 try { return $client.Receive([ref]$ep) } catch { return $null } } function Read-CString([byte[]]$d, [ref]$pos) { $start = $pos.Value while ($pos.Value -lt $d.Length -and $d[$pos.Value] -ne 0) { $pos.Value++ } $s = [Text.Encoding]::UTF8.GetString($d, $start, $pos.Value - $start) $pos.Value++ return $s } # Parses an A2S_INFO reply ('I' packet with the 4-byte header) function ConvertFrom-A2SInfo([byte[]]$d) { if ($d.Length -lt 6 -or $d[4] -ne 0x49) { return $null } $pos = 6 $name = Read-CString $d ([ref]$pos); $map = Read-CString $d ([ref]$pos) [void](Read-CString $d ([ref]$pos)); [void](Read-CString $d ([ref]$pos)) $pos += 2 # app id return [pscustomobject]@{ Name = $name; Map = $map; Players = $d[$pos]; Max = $d[$pos + 1]; Bots = $d[$pos + 2]; Password = ($d[$pos + 5] -ne 0) } } function Get-A2SInfo($hostName, [int]$port, [int]$timeoutMs = 3000) { $c = New-Object Net.Sockets.UdpClient try { $c.Client.ReceiveTimeout = $timeoutMs $c.Connect($hostName, $port) $q = $Script:Hdr + [Text.Encoding]::ASCII.GetBytes("TSource Engine Query") + [byte[]](0) Send-Udp $c $q $d = Receive-Udp $c if ($d -and $d.Length -ge 9 -and $d[4] -eq 0x41) { # challenge first: ask again with it Send-Udp $c ($q + $d[5..8]) $d = Receive-Udp $c } if (-not $d) { return $null } return ConvertFrom-A2SInfo $d } catch { return $null } finally { $c.Close() } } function Format-A2SInfo($i) { "$($i.Name) | map $($i.Map) | players $($i.Players)/$($i.Max) (bots $($i.Bots))" } function Invoke-Rcon([int]$port, $password, $cmd) { $c = New-Object Net.Sockets.UdpClient try { $c.Client.ReceiveTimeout = 3000 $c.Connect('127.0.0.1', $port) Send-Udp $c ($Script:Hdr + [Text.Encoding]::ASCII.GetBytes("challenge rcon`n")) $d = Receive-Udp $c if (-not $d) { return '(no answer: server down or changing map?)' } $parts = [Text.Encoding]::ASCII.GetString($d, 4, $d.Length - 4).Trim() -split '\s+' if ($parts.Count -lt 3) { return '(no answer: server down or changing map?)' } Send-Udp $c ($Script:Hdr + [Text.Encoding]::UTF8.GetBytes("rcon $($parts[2]) `"$password`" $cmd")) $sb = New-Object Text.StringBuilder while ($true) { # long replies come in several packets $d = Receive-Udp $c if (-not $d) { break } if ($d.Length -ge 5 -and $d[0] -eq 255 -and $d[1] -eq 255 -and $d[2] -eq 255 -and $d[3] -eq 255) { [void]$sb.Append([Text.Encoding]::UTF8.GetString($d, 5, $d.Length - 5)) } else { [void]$sb.Append([Text.Encoding]::UTF8.GetString($d)) } $c.Client.ReceiveTimeout = 400 } $text = ($sb.ToString() -split "`r?`n" | Where-Object { $_ -match '^Bad rcon_password' -or $_ -notmatch 'rcon_password|sv_password' }) -join "`n" if ($text.Trim() -eq '') { return '(ok, no output)' } return $text.TrimEnd() } catch { return '(no answer: server down or changing map?)' } finally { $c.Close() } } # ---------- kill counter ---------- $Script:KillRx = New-Object regex '^L (\d\d/\d\d/\d{4}) - [\d:]+: "(.+?)<\d+><[^>]*><[^>]*>" killed ".+?<\d+><[^>]*><[^>]*>" with "([^"]+)"' function Get-KillLines([string[]]$lines) { foreach ($l in $lines) { $m = $Script:KillRx.Match($l) if ($m.Success) { [pscustomobject]@{ Day = $m.Groups[1].Value; Killer = $m.Groups[2].Value; Weapon = $m.Groups[3].Value } } } } # Reads a text file the server may still be writing (File.ReadAllLines fails on it) function Read-SharedLines($path) { $fs = New-Object IO.FileStream $path, ([IO.FileMode]::Open), ([IO.FileAccess]::Read), ([IO.FileShare]::ReadWrite) $r = New-Object IO.StreamReader $fs, $Script:Utf8 try { return @($r.ReadToEnd() -split "`r?`n") } finally { $r.Dispose() } } function ConvertTo-Hash($o) { $h = @{} if ($o) { foreach ($p in $o.PSObject.Properties) { $h[$p.Name] = [int]$p.Value } } return $h } # Counts the kill lines in the server logs. Per-log counts are kept in $stateFile so totals # survive deleted logs. Returns @{ Total; Days; Maps; Who (hashtable); Weapons (hashtable) }. function Update-KillTally($logsDir, $stateFile) { $files = @{} if (Test-Path -LiteralPath $stateFile) { try { $st = [IO.File]::ReadAllText($stateFile) | ConvertFrom-Json foreach ($p in $st.files.PSObject.Properties) { $v = $p.Value $files[$p.Name] = @{ size = [long]$v.size; kills = [int]$v.kills; who = (ConvertTo-Hash $v.who); wpn = (ConvertTo-Hash $v.wpn); days = @($v.days) } } } catch { $files = @{} } } if (Test-Path -LiteralPath $logsDir) { foreach ($f in Get-ChildItem -LiteralPath $logsDir -Filter *.log) { if ($files.ContainsKey($f.Name) -and $files[$f.Name].size -eq $f.Length) { continue } $who = @{}; $wpn = @{}; $days = @{}; $n = 0 foreach ($k in Get-KillLines (Read-SharedLines $f.FullName)) { $n++; $days[$k.Day] = 1; $who[$k.Killer] = 1 + [int]$who[$k.Killer]; $wpn[$k.Weapon] = 1 + [int]$wpn[$k.Weapon] } $files[$f.Name] = @{ size = $f.Length; kills = $n; who = $who; wpn = $wpn; days = @($days.Keys) } } } $json = @{ files = $files } | ConvertTo-Json -Depth 6 -Compress Write-TextFile "$stateFile.tmp" $json Move-Item -LiteralPath "$stateFile.tmp" -Destination $stateFile -Force $t = @{ Total = 0; Days = @{}; Maps = $files.Count; Who = @{}; Weapons = @{} } foreach ($v in $files.Values) { $t.Total += $v.kills foreach ($d in $v.days) { if ($d) { $t.Days[$d] = 1 } } foreach ($k in $v.who.Keys) { $t.Who[$k] = $v.who[$k] + [int]$t.Who[$k] } foreach ($k in $v.wpn.Keys) { $t.Weapons[$k] = $v.wpn[$k] + [int]$t.Weapons[$k] } } return $t } # ---------- the server process ---------- function Get-ServerProcess($hldsDir) { $exe = Join-Path $hldsDir 'hlds.exe' Get-Process -Name hlds -ErrorAction SilentlyContinue | Where-Object { try { $_.Path -and ($_.Path -ieq $exe) } catch { $false } } | Select-Object -First 1 } function Test-UdpPortFree([int]$port) { try { $u = New-Object Net.Sockets.UdpClient $port; $u.Close(); return $true } catch { return $false } } function Get-RconPassword($root) { ([IO.File]::ReadAllText((Join-Path $root 'secrets\rcon-password.txt'))).Trim() } # Writes the per-start configs from settings.txt (twin of tools/start.sh), then starts hlds.exe # in its own window. Does nothing when this install's server is already running. function Start-CsServer($root) { $h = Join-Path $root 'hlds'; $c = Join-Path $h 'cstrike' if (Get-ServerProcess $h) { Write-Host ' the server is already running'; return $false } $s = Read-Settings (Join-Path $root 'settings.txt') $clean = { param($v) ([string]$v) -replace '["\r\n]', '' } Write-TextFile (Join-Path $c 'env.cfg') ("// written from settings.txt at every start: edit settings.txt instead`r`nhostname `"" + (& $clean $s.CS_HOSTNAME) + "`"`r`n") Write-TextFile (Join-Path $c 'secrets.cfg') ("// written at every start (RCON: secrets\rcon-password.txt, join: CS_PASSWORD in settings.txt)`r`n" + "rcon_password `"" + (Get-RconPassword $root) + "`"`r`nsv_password `"" + (& $clean $s.CS_PASSWORD) + "`"`r`n") $ycfg = Join-Path $c 'addons\yapb\conf\yapb.cfg' $y = [IO.File]::ReadAllText($ycfg) $y = $y -replace '(?m)^yb_quota ".*"', ('yb_quota "' + [int]$s.CS_BOTS + '"') $y = $y -replace '(?m)^yb_difficulty ".*"', ('yb_difficulty "' + [int]$s.CS_BOT_DIFFICULTY + '"') Write-TextFile $ycfg $y $hargs = "-console -condebug -game cstrike -port $([int]$s.CS_PORT) +maxplayers $([int]$s.CS_MAXPLAYERS) +map $($s.CS_MAP) +log on" # A fresh install has no steam_appid.txt. hlds writes it while starting, but too late: Steam # has already failed and the server sits on a "Fatal error: Unable to initialize Steam" box. # 10 = Counter-Strike. $appid = Join-Path $h 'steam_appid.txt' if (-not (Test-Path -LiteralPath $appid) -or [IO.File]::ReadAllText($appid).Trim() -ne '10') { Write-TextFile $appid "10`r`n" } # If that box still shows up, close the server and start it again. The window starts minimized # so it doesn't take the keyboard away from whatever you're doing. for ($try = 1; $try -le 3; $try++) { $p = Start-Process -FilePath (Join-Path $h 'hlds.exe') -WorkingDirectory $h -ArgumentList $hargs -PassThru -WindowStyle Minimized if (-not (Test-SteamFailure $p ([int]$s.CS_PORT))) { break } Stop-Process -Id $p.Id -Force -ErrorAction SilentlyContinue Start-Sleep -Seconds 2 Write-Host " (Steam wasn't ready yet, starting again)" } return $true } # Waits up to 40 s for the server to answer or to show its "Fatal error" box. # Returns $true for that box. function Test-SteamFailure($p, [int]$port) { $end = (Get-Date).AddSeconds(40) while ((Get-Date) -lt $end -and -not $p.HasExited) { if (Get-A2SInfo '127.0.0.1' $port 1000) { return $false } if (@(Get-ProcessWindowTitles $p.Id | Where-Object { $_ -like 'Fatal error*' }).Count) { return $true } Start-Sleep -Milliseconds 500 } return $false } # Titles of all top-level windows of a process (the error box is not always its main window) function Get-ProcessWindowTitles([int]$processId) { if (-not ('Cs16Win' -as [type])) { Add-Type @' using System; using System.Text; using System.Collections.Generic; using System.Runtime.InteropServices; public static class Cs16Win { delegate bool EnumProc(IntPtr h, IntPtr l); [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc f, IntPtr l); [DllImport("user32.dll")] static extern int GetWindowThreadProcessId(IntPtr h, out int pid); [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr h, StringBuilder s, int n); public static string[] Titles(int want) { var r = new List(); EnumWindows((h, l) => { int pid; GetWindowThreadProcessId(h, out pid); if (pid == want) { var t = new StringBuilder(256); GetWindowText(h, t, 256); r.Add(t.ToString()); } return true; }, IntPtr.Zero); return r.ToArray(); } } '@ } [Cs16Win]::Titles($processId) } # An earlier version (2026-10-15) hid this Steam setting while starting the server; this puts it # back if a start was interrupted then. Harmless otherwise. $Script:SteamKey = 'HKCU:\Software\Valve\Steam\ActiveProcess' $Script:SteamHidden = 'SteamClientDll_hidden_by_cs16' function Restore-SteamClientSetting { $v = Get-ItemProperty -Path $Script:SteamKey -ErrorAction SilentlyContinue if (-not $v -or -not $v.($Script:SteamHidden)) { return } if ($v.SteamClientDll) { Remove-ItemProperty -Path $Script:SteamKey -Name $Script:SteamHidden } # Steam wrote a fresh one else { Rename-ItemProperty -Path $Script:SteamKey -Name $Script:SteamHidden -NewName SteamClientDll } } function Stop-CsServer($root) { $h = Join-Path $root 'hlds' $p = Get-ServerProcess $h if (-not $p) { return $false } $s = Read-Settings (Join-Path $root 'settings.txt') [void](Invoke-Rcon ([int]$s.CS_PORT) (Get-RconPassword $root) 'quit') if (-not $p.WaitForExit(8000)) { Stop-Process -Id $p.Id -Force } return $true } # Waits for the server to answer a query on 127.0.0.1; returns the info or $null function Wait-CsServer([int]$port, [int]$seconds = 60) { $end = (Get-Date).AddSeconds($seconds) while ((Get-Date) -lt $end) { $i = Get-A2SInfo '127.0.0.1' $port 2000 if ($i) { return $i } Write-Host -NoNewline '.' Start-Sleep -Seconds 2 } return $null } function Get-ConsoleTail($root, [int]$n = 20) { $log = Join-Path $root 'hlds\qconsole.log' # -condebug writes it next to hlds.exe if (-not (Test-Path -LiteralPath $log)) { return @() } Get-Content -LiteralPath $log -Tail $n | Where-Object { $_ -notmatch 'rcon_password|sv_password|Rcon:' } } function Get-ServerMode($root) { if (Test-Path -LiteralPath (Join-Path $root 'hlds\cstrike\addons\amxmodx\configs\plugins-gungame.ini')) { 'GunGame' } else { 'deathmatch' } } # ---------- network addresses ---------- $Script:VirtualIf = 'vEthernet|Loopback|VirtualBox|VMware|Hyper-V|WSL|Tailscale|ZeroTier|Bluetooth' function Get-LanIPv4 { @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -notmatch '^(127\.|169\.254\.)' -and $_.InterfaceAlias -notmatch $Script:VirtualIf -and -not (Test-CgnatAddress $_.IPAddress) } | ForEach-Object { $_.IPAddress }) } function Test-CgnatAddress($ip) { # 100.64.0.0/10: Tailscale uses it $o = $ip -split '\.' return ($o[0] -eq '100' -and [int]$o[1] -ge 64 -and [int]$o[1] -le 127) } function Get-VpnIPv4 { @(Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.InterfaceAlias -match 'Tailscale|ZeroTier' -or (Test-CgnatAddress $_.IPAddress) } | ForEach-Object { [pscustomobject]@{ IP = $_.IPAddress; Name = $_.InterfaceAlias } }) } # ---------- firewall ---------- $Script:FwRule = 'CS 1.6 server (cs16-dm-server)' # Normal (non-admin) sessions often can't read firewall rules, so the admin step below leaves a # marker with the exact server path once its rule is in place. function Get-FirewallMarker($root) { Join-Path $root 'secrets\firewall-rule.txt' } function Test-FirewallRule($root) { $m = Get-FirewallMarker $root if (-not (Test-Path -LiteralPath $m)) { return $false } return ([IO.File]::ReadAllText($m).Trim() -ieq (Join-Path $root 'hlds\hlds.exe')) } # Asks Windows (admin prompt) to let hlds.exe receive connections on every network type, and # removes the block rule Windows creates when its own "allow access" popup gets cancelled. # The elevated part checks its own result and reports it through its exit code. function Add-FirewallRule($root) { $exePath = Join-Path $root 'hlds\hlds.exe' $exe = $exePath -replace "'", "''" # inside single quotes below $ps = "`$ErrorActionPreference = 'Stop'; try { " + "Get-NetFirewallApplicationFilter -Program '$exe' -ErrorAction SilentlyContinue | Get-NetFirewallRule | Where-Object { `$_.Direction -eq 'Inbound' -and `$_.Action -eq 'Block' } | Remove-NetFirewallRule; " + "Get-NetFirewallRule -DisplayName '$($Script:FwRule)' -ErrorAction SilentlyContinue | Remove-NetFirewallRule; " + "New-NetFirewallRule -DisplayName '$($Script:FwRule)' -Direction Inbound -Action Allow -Program '$exe' -Profile Any | Out-Null; " + "if (Get-NetFirewallRule -DisplayName '$($Script:FwRule)' -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 } } catch { exit 1 }" $enc = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($ps)) try { $p = Start-Process -FilePath powershell.exe -Verb RunAs -Wait -PassThru -WindowStyle Hidden -ArgumentList "-NoProfile -EncodedCommand $enc" if ($p.ExitCode -ne 0) { return $false } Write-TextFile (Get-FirewallMarker $root) $exePath return $true } catch { return $false } # the admin prompt was answered No } # repair a Steam setting left hidden by an interrupted Start-CsServer Restore-SteamClientSetting