Windows version: host the server from your own PC

setup.cmd installs the same DM/GunGame server natively on Windows 10/11
(no Docker or Python) into Documents\cs16, then helps set up how friends
join (LAN, playit.gg, port forwarding, VPN). cs.cmd is the menu.

Also: setup falls back to backup copies of the freely licensed mod
downloads on this Gitea when a main site is down (tools/make-mirror.sh
fills them), and the README is rewritten as a plain guide.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
esefandClaude Opus 5.5 committed 2026-10-15 20:55:00 +03:00
1 parent 7e9cbee14d
commit 4b7fdcaa23
16 files changed
+1412 -72

No files matched your search

+461
View File
@@ -0,0 +1,461 @@
# 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 <base>' 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"
Restore-SteamClientSetting
# With Steam installed, the server's steam_api.dll loads the Steam client's steamclient.dll
# (found through this registry value) instead of its own, and stops with "Unable to initialize
# Steam". So the value is hidden until the server answers queries (Steam is set up by then:
# a few seconds) or gives up. Steam writes it again at every start, and
# Restore-SteamClientSetting (run whenever a script loads this file) repairs an interrupted run.
# (A 64-bit PowerShell can't see the 32-bit server's DLLs, so the query is the signal.)
$hidden = $false
if ((Get-ItemProperty -Path $Script:SteamKey -ErrorAction SilentlyContinue).SteamClientDll) {
Rename-ItemProperty -Path $Script:SteamKey -Name SteamClientDll -NewName $Script:SteamHidden
$hidden = $true
}
try {
$p = Start-Process -FilePath (Join-Path $h 'hlds.exe') -WorkingDirectory $h -ArgumentList $hargs -PassThru
if ($hidden) {
$end = (Get-Date).AddSeconds(90)
while ((Get-Date) -lt $end -and -not $p.HasExited) {
if (Get-A2SInfo '127.0.0.1' ([int]$s.CS_PORT) 1000) { break }
Start-Sleep -Milliseconds 500
}
}
} finally { if ($hidden) { Restore-SteamClientSetting } }
return $true
}
$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
+123
View File
@@ -0,0 +1,123 @@
# Control menu for Windows: double-click cs.cmd (pick a number)
# or directly: cs.cmd status | start | stop | restart | map <name> | mode dm|gg | bots <n> | kills
# | say <text> | join | console | update
param([Parameter(ValueFromRemainingArguments = $true)][string[]]$Cmd)
. (Join-Path $PSScriptRoot 'common.ps1')
trap { Write-Host "error: $($_.Exception.Message)" -ForegroundColor Red; exit 1 }
$Root = Split-Path -Parent $PSScriptRoot
$settingsFile = Join-Path $Root 'settings.txt'
if (-not (Test-Path -LiteralPath $settingsFile)) { throw 'not set up yet: double-click setup.cmd first' }
$H = Join-Path $Root 'hlds'; $C = Join-Path $H 'cstrike'
function Port { [int](Read-Settings $settingsFile).CS_PORT }
function Rcon($command) { Invoke-Rcon (Port) (Get-RconPassword $Root) $command }
function Answered($reply) { -not $reply.StartsWith('(no answer') }
function Show-Status {
if (-not (Get-ServerProcess $H)) { Write-Host ' server is stopped (start it: 9)'; return }
$i = Get-A2SInfo '127.0.0.1' (Port) 2000
if ($i) { Write-Host " $(Format-A2SInfo $i)" } else { Write-Host ' server window open, not answering yet (starting or changing map?)' }
Write-Host " mode: $(Get-ServerMode $Root)"
}
function Show-Players {
Show-Status
if (Get-ServerProcess $H) { (Rcon 'status') -split "`n" | Where-Object { $_ -match '^(#|map|players)' } | ForEach-Object { Write-Host " $_" } }
}
function Set-Map($m) {
$maps = @(Get-ChildItem -LiteralPath "$C\maps" -Filter *.bsp -ErrorAction SilentlyContinue | ForEach-Object { $_.BaseName } | Sort-Object)
if (-not $m) {
Write-Host ' Maps on this server:'
for ($i = 0; $i -lt $maps.Count; $i++) { Write-Host (' {0,3}) {1}' -f ($i + 1), $maps[$i]) }
$m = (Read-Host ' map name or number').Trim()
if ($m -match '^\d+$' -and [int]$m -ge 1 -and [int]$m -le $maps.Count) { $m = $maps[[int]$m - 1] }
}
if (-not $m) { return }
if ($maps -notcontains $m) { Write-Host " no map called '$m' in $C\maps"; return }
$r = Rcon "changelevel $m"
if (Answered $r) { Write-Host " changing to $m" } else { Write-Host " $r" }
}
function Set-Mode($m) {
if (-not $m) { $m = (Read-Host ' mode: dm = deathmatch, gg = GunGame').Trim() }
if ($m -ne 'dm' -and $m -ne 'gg') { Write-Host ' use dm or gg'; return }
$r = Rcon "esef_mode $m"
if (Answered $r) { ($r -split "`n" | Where-Object { $_ -match 'esef_mode' }) | ForEach-Object { Write-Host " $_" } } else { Write-Host " $r" }
}
function Set-Bots($n) {
$s = Read-Settings $settingsFile
if (-not $n) { $n = (Read-Host " fill the server up to how many players with bots? (0 = no bots, now $($s.CS_BOTS))").Trim() }
if ($n -notmatch '^\d+$') { Write-Host ' give a number'; return }
Write-Setting $settingsFile 'CS_BOTS' $n # remembered for the next start
$r = Rcon "yb_quota $n"
if (Answered $r) { Write-Host " bots: up to $n players (saved)" } else { Write-Host " saved, applies when the server runs" }
}
function Show-Kills {
$t = Update-KillTally "$C\logs" (Join-Path $Root 'kills-tally.json')
Write-Host ''
Write-Host (' ALL-TIME KILLS: {0:N0} ({1} days of carnage, {2} maps played)' -f $t.Total, $t.Days.Count, $t.Maps)
Write-Host ''; Write-Host ' top fraggers:'
$i = 0
foreach ($e in ($t.Who.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 5)) { $i++; Write-Host (' {0}. {1,-18} {2:N0}' -f $i, $e.Key, $e.Value) }
Write-Host ''; Write-Host ' weapons of choice:'
foreach ($e in ($t.Weapons.GetEnumerator() | Sort-Object Value -Descending | Select-Object -First 5)) { Write-Host (' {0,-18} {1:N0}' -f $e.Key, $e.Value) }
}
function Start-Server {
if (Start-CsServer $Root) {
Write-Host -NoNewline ' starting'
$i = Wait-CsServer (Port) 60
Write-Host ''
if ($i) { Write-Host " up: $(Format-A2SInfo $i)" } else { Write-Host ' no answer after 60 s, last lines:'; Get-ConsoleTail $Root 20 | ForEach-Object { Write-Host " $_" } }
}
}
function Stop-Server { if (Stop-CsServer $Root) { Write-Host ' stopped' } else { Write-Host ' the server is not running' } }
function Open-Console {
Write-Host ' CS 1.6 console: status, changelevel de_nuke, yb_quota 6, esef_mode gg, say hi. Empty line or exit to leave.'
while ($true) {
$l = (Read-Host 'cs16').Trim()
if ($l -eq '' -or $l -eq 'exit' -or $l -eq 'quit') { break }
Write-Host (Rcon $l)
}
}
function Run($choice, $rest) {
switch ($choice) {
{ $_ -in 'status', '1' } { Show-Players }
{ $_ -in 'map', '2' } { Set-Map $rest }
{ $_ -in 'mode', '3' } { Set-Mode $rest }
{ $_ -in 'bots', '4' } { Set-Bots $rest }
{ $_ -in 'kills', '5' } { Show-Kills }
{ $_ -in 'say', '6' } {
$t = $rest; if (-not $t) { $t = (Read-Host ' message').Trim() }
if ($t) { [void](Rcon "say $t"); Write-Host ' sent' }
}
{ $_ -in 'restart', '7' } { [void](Stop-CsServer $Root); Start-Server }
{ $_ -in 'stop', '8' } { Stop-Server }
{ $_ -in 'start', '9' } { Start-Server }
{ $_ -in 'join', '10' } { & (Join-Path $PSScriptRoot 'join.ps1') -Root $Root }
{ $_ -in 'console', '11' } { Open-Console }
{ $_ -in 'update', '12' } { & (Join-Path $PSScriptRoot 'setup.ps1') -Yes -Update }
default { Write-Host " unknown: $choice" }
}
}
if ($Cmd.Count -gt 0) { Run $Cmd[0] (($Cmd | Select-Object -Skip 1) -join ' '); exit 0 }
while ($true) {
$s = Read-Settings $settingsFile
Write-Host ''
Write-Host "== $($s.CS_HOSTNAME) == (UDP $($s.CS_PORT))" -ForegroundColor Cyan
Show-Status
Write-Host @'
1) who's playing 7) restart server
2) change map 8) stop server
3) game mode (DM / GG) 9) start server
4) bots 10) how friends join
5) kill counter 11) console (type server commands)
6) message to players 12) update game + mods
0) quit
'@
$choice = (Read-Host '>').Trim()
if ($choice -eq '' -or $choice -eq '0' -or $choice -eq 'q') { break }
Run $choice ''
}
+130
View File
@@ -0,0 +1,130 @@
# Puts the mod stack on top of the Valve game files in hlds\ (Windows twin of tools/install-addons.sh).
# setup.ps1 runs it after every game download/update: a Steam "validate" puts Valve's engine back.
# Safe to run again. Kept on re-runs: maps, stats, bot navigation, and your edits to users.ini
# (admins), amxx.cfg, csdm\config.ini, mapcycle.txt and custom.cfg.
param([Parameter(Mandatory = $true)][string]$Root)
. (Join-Path $PSScriptRoot 'common.ps1')
$DL = Join-Path $Root 'dl'; $H = Join-Path $Root 'hlds'; $C = Join-Path $H 'cstrike'
$A = Join-Path $C 'addons'; $CF = Join-Path $A 'amxmodx\configs'
if (-not (Test-Path -LiteralPath (Join-Path $H 'hlds.exe'))) { throw "no game files in $H (the Steam download didn't finish)" }
function Zip($pattern) {
$f = @(Get-ChildItem -LiteralPath $DL -Filter $pattern | Sort-Object Name | Select-Object -Last 1)
if ($f.Count -eq 0) { throw "missing download $pattern in $DL" }
return $f[0].FullName
}
function Read-ZipText($zip, $entry) {
$z = [IO.Compression.ZipFile]::OpenRead($zip)
try {
$e = $z.GetEntry($entry); if (-not $e) { throw "$entry not found in $zip" }
$r = New-Object IO.StreamReader $e.Open(); try { return $r.ReadToEnd() } finally { $r.Close() }
} finally { $z.Dispose() }
}
function Edit-File($path, [scriptblock]$change) { Write-TextFile $path (& $change ([IO.File]::ReadAllText($path))) }
Step 'downloads (pinned, checked against tools/versions*.txt)'
foreach ($item in Get-PinnedDownloads $Root) { [void](Get-Pinned $item $DL) }
# Game mode in use right now (re-run) or from settings.txt (first install)
# (the plugin lists only say something after a finished install: the repo itself ships an active
# plugins-gungame.ini, so an interrupted first install would otherwise come back as GunGame)
$settings = Read-Settings (Join-Path $Root 'settings.txt')
$first = -not (Test-Path -LiteralPath "$C\.cs16-dm-server")
if ($first) { $mode = $settings.CS_MODE }
elseif (Test-Path -LiteralPath "$CF\plugins-gungame.ini") { $mode = 'gg' }
else { $mode = 'dm' }
if ($mode -ne 'dm' -and $mode -ne 'gg') { throw "CS_MODE must be dm or gg (is: $mode)" }
# Files people edit: save them now, put them back after the archives are unpacked
$keepDir = Join-Path $DL '.keep'
if (Test-Path -LiteralPath $keepDir) { Remove-Item -LiteralPath $keepDir -Recurse -Force }
if (-not $first) {
foreach ($f in 'addons\amxmodx\configs\users.ini', 'addons\amxmodx\configs\amxx.cfg', 'addons\amxmodx\configs\csdm\config.ini') {
if (Test-Path -LiteralPath "$C\$f") {
New-Item -ItemType Directory -Force -Path (Split-Path "$keepDir\$f") | Out-Null
Copy-Item -LiteralPath "$C\$f" -Destination "$keepDir\$f" -Force
}
}
}
Step 'ReHLDS (engine)'
Expand-ZipPart (Zip 'rehlds-bin-*.zip') 'bin/win32/' $H
Step 'ReGameDLL (game logic)'
Expand-ZipPart (Zip 'regamedll-bin-*.zip') 'bin/win32/cstrike/' $C
# game.cfg runs at every map start and would force alltalk off: server.cfg decides that instead
Edit-File "$C\game.cfg" { param($t) $t -replace '(?m)^sv_alltalk "0"', '// sv_alltalk "0" // cs16-dm-server: set in server.cfg instead' }
Step 'Metamod-R'
Expand-ZipPart (Zip 'metamod-bin-*.zip') 'addons/' $A
if (-not (Test-Path -LiteralPath "$C\liblist.gam.orig")) { Copy-Item -LiteralPath "$C\liblist.gam" -Destination "$C\liblist.gam.orig" }
Edit-File "$C\liblist.gam" { param($t) $t -replace '(?m)^gamedll ".*"', 'gamedll "addons\metamod\metamod.dll"' }
Step 'AMX Mod X + ReAPI'
Expand-ZipPart (Zip 'amxmodx-*-base-windows.zip') '' $C
Expand-ZipPart (Zip 'amxmodx-*-cstrike-windows.zip') '' $C
Expand-ZipPart (Zip 'reapi-bin-*.zip') 'addons/' $A
Step 'ReUnion (non-Steam clients)'
$reu = Zip 'reunion-*.zip'
Expand-ZipPart $reu 'bin/Windows/' "$A\reunion"
$salt = ([IO.File]::ReadAllText((Join-Path $Root 'secrets\reunion-salt.txt'))).Trim()
if (-not $salt) { throw 'secrets\reunion-salt.txt is empty' }
# IDClientsLimit 0: several non-Steam players with the same placeholder ID can all join
$cfg = (Read-ZipText $reu 'reunion.cfg') -replace '(?m)^SteamIdHashSalt =.*$', "SteamIdHashSalt = $salt" -replace '(?m)^IDClientsLimit = .*$', 'IDClientsLimit = 0'
Write-TextFile "$C\reunion.cfg" $cfg
Step 'CSDM-ReAPI (deathmatch) + spawn points'
Expand-ZipPart (Zip 'CSDM-ReAPI-*.zip') 'cstrike/' $C
New-Item -ItemType Directory -Force -Path "$CF\csdm\spawns" | Out-Null
foreach ($f in Get-ChildItem -LiteralPath "$DL\spawns" -Filter *.spawns.cfg) {
$lines = [IO.File]::ReadAllLines($f.FullName) | ForEach-Object { Convert-SpawnLine $_ }
$text = ($lines -join "`n") + "`n"
Write-TextFile "$CF\csdm\spawns\$($f.Name)" $text
Write-TextFile "$CF\csdm\$($f.Name)" $text # GunGame looks for them one folder up (Linux: a symlink)
}
Step 'YaPB (bots)'
Expand-ZipPart (Zip 'yapb-*-windows.zip') '' $C
# FFA-aware bots; quota = fill up to CS_BOTS players (Start-CsServer sets the number)
Edit-File "$A\yapb\conf\yapb.cfg" { param($t) $t -replace '(?m)^yb_csdm_mode ".*"', 'yb_csdm_mode "2"' -replace '(?m)^yb_quota_mode ".*"', 'yb_quota_mode "fill"' }
Step 'GunGame + announcer sounds'
Copy-Item -LiteralPath "$DL\gg\gungame.sma" -Destination "$A\amxmodx\scripting\" -Force
Copy-Item -LiteralPath "$DL\gg\gungame.txt" -Destination "$A\amxmodx\data\lang\" -Force
New-Item -ItemType Directory -Force -Path "$C\sound\gungame", "$C\sound\misc" | Out-Null
Copy-Item -Path "$DL\gg\*.wav" -Destination "$C\sound\gungame\" -Force
Copy-Item -Path "$DL\snd\*.wav" -Destination "$C\sound\misc\" -Force
Step 'configs from the repo'
Copy-Item -Path (Join-Path $Root 'cstrike\*') -Destination $C -Recurse -Force
if ($first) { Copy-Item -LiteralPath (Join-Path $Root 'defaults\mapcycle.txt') -Destination "$C\mapcycle.txt" -Force }
if (-not (Test-Path -LiteralPath "$C\custom.cfg")) { Copy-Item -LiteralPath (Join-Path $Root 'defaults\custom.cfg') -Destination "$C\custom.cfg" }
if (Test-Path -LiteralPath $keepDir) {
Copy-Item -Path "$keepDir\*" -Destination $C -Recurse -Force
Remove-Item -LiteralPath $keepDir -Recurse -Force
}
Step 'compiling plugins'
Copy-Item -Path (Join-Path $Root 'plugins\*.sma') -Destination "$A\amxmodx\scripting\" -Force
Push-Location "$A\amxmodx\scripting"
try {
$ErrorActionPreference = 'Continue' # 5.1 turns a native program's stderr into a fatal error under 'Stop'
foreach ($p in 'rtv', 'esef_extras', 'esef_mode', 'gungame') {
$out = & .\amxxpc.exe "$p.sma" "-o..\plugins\$p.amxx" 2>&1
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath "..\plugins\$p.amxx")) { $out | Write-Host; throw "compiling $p.sma failed" }
}
} finally { Pop-Location; $ErrorActionPreference = 'Stop' }
Step "game mode: $mode"
# exactly one of the two plugin lists is active (.ini), the other one is .ini.off
foreach ($ini in 'plugins-gungame.ini', 'plugins-CSDM_ReAPI.ini') {
if (Test-Path -LiteralPath "$CF\$ini") { Remove-Item -LiteralPath "$CF\$ini.off" -Force -ErrorAction SilentlyContinue } # fresh copy wins
else { Move-Item -LiteralPath "$CF\$ini.off" -Destination "$CF\$ini" }
}
if ($mode -eq 'gg') { Move-Item -LiteralPath "$CF\plugins-CSDM_ReAPI.ini" -Destination "$CF\plugins-CSDM_ReAPI.ini.off" }
else { Move-Item -LiteralPath "$CF\plugins-gungame.ini" -Destination "$CF\plugins-gungame.ini.off" }
Write-TextFile "$C\.cs16-dm-server" ''
Write-Host ' mod stack installed'
+111
View File
@@ -0,0 +1,111 @@
# "How will your friends join?" - the last step of setup.cmd, and option 10 in the cs.cmd menu.
# Each choice either sets things up or walks you through it.
param([Parameter(Mandatory = $true)][string]$Root)
. (Join-Path $PSScriptRoot 'common.ps1')
$settingsFile = Join-Path $Root 'settings.txt'
$s = Read-Settings $settingsFile
$port = [int]$s.CS_PORT
function Pause-Enter($text = 'Press Enter when done') { [void](Read-Host " $text") }
function Ensure-Firewall {
if (Test-FirewallRule $Root) { Write-Host ' Windows Firewall: the server is allowed in.'; return }
Write-Host ' Windows Firewall blocks players from other PCs until you allow the server.'
Write-Host ' Windows will now ask for admin permission (one time).'
if (Add-FirewallRule $Root) { Write-Host ' Firewall: allowed.' -ForegroundColor Green }
else {
Warn ' Firewall not changed. Only players on this PC can join until you allow it:'
Warn ' run cs.cmd -> 10 (how friends join) and say Yes to the admin prompt.'
}
}
function Show-Connect($addr) { Write-Host " connect $addr" -ForegroundColor Green }
function Join-Lan {
Ensure-Firewall
$ips = @(Get-LanIPv4)
Write-Host ''
Write-Host ' Players in your home open Counter-Strike, press ~ for the console and type:'
if ($ips.Count -eq 0) { Show-Connect "<this PC's IP>:$port"; Write-Host ' (no network address found: are you connected to your router?)' }
foreach ($ip in $ips) { Show-Connect "${ip}:$port" }
Write-Host ' On this same PC:'; Show-Connect "127.0.0.1:$port"
}
function Join-Playit {
Write-Host ''
Write-Host ' playit.gg gives your server a public address without touching your router. It is free.'
Write-Host ' 1. Your browser opens playit.gg/download: download and install the Windows app.'
Write-Host ' 2. Start playit. It opens a page to sign up (or log in) and link this PC: follow it.'
Write-Host ' 3. On playit.gg, add a tunnel:'
Write-Host ' type: UDP' -ForegroundColor Green
Write-Host " local address: 127.0.0.1 local port: $port" -ForegroundColor Green
Write-Host ' 4. playit shows the address of the tunnel, something like name.ply.gg:12345'
Write-Host ' Keep the playit app running while you play.'
Pause-Enter 'Press Enter to open playit.gg'
Start-Process 'https://playit.gg/download'
$addr = (Read-Host ' Paste the tunnel address here (or Enter to do it later)').Trim()
if ($addr) {
$addr = $addr -replace '^(udp|https?)://', '' -replace '/.*$', ''
Write-Setting $settingsFile 'CS_PUBLIC_ADDR' $addr
Write-Host ' Saved. Your friends open Counter-Strike, press ~ and type:'
Show-Connect $addr
$hp = $addr -split ':'
if ($hp.Count -eq 2 -and (Get-A2SInfo $hp[0] ([int]$hp[1]) 4000)) { Write-Host ' Checked: the server answers through the tunnel.' -ForegroundColor Green }
else { Write-Host " (could not reach the server through the tunnel yet: check the tunnel is UDP and points to 127.0.0.1:$port)" }
}
}
function Join-PortForward {
Ensure-Firewall
$gw = (Get-NetRoute -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | Sort-Object RouteMetric | Select-Object -First 1).NextHop
$lan = @(Get-LanIPv4)
$pub = $null
try { $pub = (Invoke-WebRequest -UseBasicParsing -Uri 'https://api.ipify.org' -TimeoutSec 10).Content.Trim() } catch { }
Write-Host ''
Write-Host ' Your router has to send game traffic to this PC:'
Write-Host " 1. Open your router's page in a browser: http://$gw (the login is often on a sticker on the router)"
Write-Host ' 2. Find "Port forwarding" (sometimes under NAT, Virtual server or Gaming) and add:'
Write-Host " protocol: UDP external port: $port internal port: $port" -ForegroundColor Green
Write-Host " device / internal IP: $(if ($lan.Count) { $lan[0] } else { "this PC's IP" })" -ForegroundColor Green
Write-Host ' 3. Save. Some routers need a restart.'
Write-Host ' Tip: in your router, give this PC a fixed (reserved) IP, or the rule breaks when the IP changes.'
Write-Host ''
if ($pub) { Write-Host ' Your friends then type in the Counter-Strike console:'; Show-Connect "${pub}:$port"; Write-Setting $settingsFile 'CS_PUBLIC_ADDR' "${pub}:$port" }
else { Write-Host " Your friends then use connect <your public IP>:$port (look it up on whatismyip.com)" }
Write-Host ' You cannot test this from your own PC (most routers do not loop back): ask a friend to connect.'
Write-Host ' If it never works, your internet may be behind CGNAT (common on mobile and some fiber):'
Write-Host ' use playit.gg instead (option 2).'
}
function Join-Vpn {
$v = @(Get-VpnIPv4) # @(): one address would otherwise come back as a single object
Write-Host ''
Write-Host ' With a VPN like Tailscale or ZeroTier, everyone installs the same app and joins your network.'
Write-Host ' Good for a fixed group of friends; no router setup.'
if ($v.Count) {
Ensure-Firewall
Write-Host ' Found on this PC:'
foreach ($x in $v) { Write-Host " $($x.Name):"; Show-Connect "$($x.IP):$port" }
} else {
Write-Host ' Not installed yet. Get one (free for small groups):'
Write-Host ' Tailscale: https://tailscale.com/download'
Write-Host ' ZeroTier: https://www.zerotier.com/download/'
Write-Host ' Invite your friends to your network, then run this step again (cs.cmd -> 10) to see your address.'
}
}
Say 'How will your friends join?'
Write-Host ' 1) same house / LAN'
Write-Host ' 2) over the internet with playit.gg (easiest, no router setup)'
Write-Host ' 3) over the internet with port forwarding on your router'
Write-Host ' 4) VPN (Tailscale / ZeroTier)'
Write-Host ' 0) skip for now (cs.cmd -> 10 brings you back here)'
$c = (Read-Host ' >').Trim()
switch ($c) {
'1' { Join-Lan }
'2' { Join-Playit }
'3' { Join-PortForward }
'4' { Join-Vpn }
default { Write-Host ' skipped: double-click cs.cmd and pick 10 any time.' }
}
+205
View File
@@ -0,0 +1,205 @@
# cs16-dm-server setup for Windows 10/11: double-click setup.cmd (which runs this).
# setup.cmd first install (asks a few questions), or re-run safely any time
# setup.cmd -Yes no questions: use settings.txt as it is (or the defaults)
# setup.cmd -Update also update the game files from Steam (then re-installs the mods)
# Steps: checks -> install folder -> questions -> secrets -> SteamCMD + CS 1.6 (~600 MB)
# -> mod stack -> firewall -> start -> "how will your friends join?" -> summary.
param([switch]$Yes, [switch]$Update)
. (Join-Path $PSScriptRoot 'common.ps1')
trap {
Write-Host ''
Write-Host "error: $($_.Exception.Message)" -ForegroundColor Red
Write-Host 'Fix the problem above, then double-click setup.cmd again (it continues where it stopped).'
exit 1
}
function Ask($question, $default) {
if ($Yes) { return $default }
$r = Read-Host " $question [$default]"
if ($r -eq '') { return $default }
return $r.Trim()
}
function New-Secret {
$chars = [char[]]'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
$bytes = New-Object byte[] 32
(New-Object Security.Cryptography.RNGCryptoServiceProvider).GetBytes($bytes)
-join ($bytes | ForEach-Object { $chars[$_ % $chars.Length] })
}
$repo = Split-Path -Parent $PSScriptRoot
Say 'Checking this PC'
if (-not [Environment]::Is64BitOperatingSystem) { throw 'this needs 64-bit Windows 10 or 11' }
if ([Environment]::OSVersion.Version.Major -lt 10) { throw 'this needs Windows 10 or 11' }
Write-Host ' ok'
Say 'Install folder'
$def = Resolve-InstallDir ([Environment]::GetFolderPath('MyDocuments')) $env:USERPROFILE @($env:OneDrive, $env:OneDriveConsumer, $env:OneDriveCommercial)
# Remembered install folder, so running setup.cmd from the downloaded copy again updates that install
$rememberFile = Join-Path $env:LOCALAPPDATA 'cs16-dm-server\install-folder.txt'
$known = $null
if (Test-Path -LiteralPath $rememberFile) {
$k = [IO.File]::ReadAllText($rememberFile).Trim()
if ($k -and (Test-Path -LiteralPath (Join-Path $k 'settings.txt'))) { $known = $k }
}
$repoFull = [IO.Path]::GetFullPath($repo).TrimEnd('\')
if ((Test-Path -LiteralPath (Join-Path $repo 'settings.txt')) -or ($repo -ieq $def.Path)) {
$Root = $repo # already installed here: update in place
Write-Host " $Root"
} else {
if ($known) {
$Root = $known
Write-Host " updating your server in $Root"
} else {
if ($def.Reason) { Warn " $($def.Reason)" }
while ($true) {
$Root = (Ask 'Install the server in' $def.Path).Trim().Trim('"').TrimEnd('\') # "Copy as path" adds quotes
$problem = Test-InstallDir $Root
if (-not $problem) {
$full = [IO.Path]::GetFullPath($Root)
if ($full -ieq $repoFull -or $full.StartsWith($repoFull + '\', [StringComparison]::OrdinalIgnoreCase)) {
$problem = 'pick a folder outside the downloaded files'
}
}
if (-not $problem) { break }
Warn " $problem"
if ($Yes) { throw $problem }
}
}
New-Item -ItemType Directory -Force -Path $Root | Out-Null
$skip = 'hlds', 'dl', 'steamcmd', 'secrets', 'settings.txt', 'kills-tally.json', '.git'
foreach ($item in Get-ChildItem -LiteralPath $repo -Force) {
if ($skip -contains $item.Name) { continue }
Copy-Item -LiteralPath $item.FullName -Destination $Root -Recurse -Force
}
# files from a browser download are marked "from the internet": Windows would warn on every run
Get-ChildItem -LiteralPath $Root -Recurse -File | Unblock-File
Write-Host " copied the server files to $Root"
}
$drive = Get-PSDrive -Name $Root.Substring(0, 1) -ErrorAction SilentlyContinue
if ($drive -and $drive.Free -lt 2GB -and -not (Test-Path -LiteralPath (Join-Path $Root 'hlds\hlds.exe'))) {
throw "not enough disk space on $($drive.Name): (needs 2 GB free)"
}
New-Item -ItemType Directory -Force -Path (Split-Path $rememberFile) | Out-Null
Write-TextFile $rememberFile $Root
$H = Join-Path $Root 'hlds'
$settingsFile = Join-Path $Root 'settings.txt'
Say 'Settings'
if (-not (Test-Path -LiteralPath $settingsFile)) {
$d = $Script:DefaultSettings
$name = Ask 'Server name' $d.CS_HOSTNAME
$pw = Ask 'Join password (Enter = open server, anyone can join)' ''
$m = Ask 'Game mode: dm = deathmatch, gg = GunGame' $d.CS_MODE
if ($m -ne 'gg') { $m = 'dm' }
$b = Ask 'Bots (fill the server up to this many players, 0 = none)' $d.CS_BOTS
if ($b -notmatch '^\d+$') { $b = '0' }
$diff = Ask 'Bot difficulty (0 easy .. 4 expert)' $d.CS_BOT_DIFFICULTY
if ($diff -notmatch '^[0-4]$') { $diff = $d.CS_BOT_DIFFICULTY }
Write-TextFile $settingsFile "# cs16-dm-server settings. Edit, then restart the server (cs.cmd -> 7). No double quotes in values.`r`n"
foreach ($k in $d.Keys) { Write-Setting $settingsFile $k $d[$k] }
Write-Setting $settingsFile 'CS_HOSTNAME' $name
Write-Setting $settingsFile 'CS_PASSWORD' $pw
Write-Setting $settingsFile 'CS_MODE' $m
Write-Setting $settingsFile 'CS_BOTS' $b
Write-Setting $settingsFile 'CS_BOT_DIFFICULTY' $diff
Write-Host ' saved in settings.txt'
} else { Write-Host ' using settings.txt' }
$sec = Join-Path $Root 'secrets'
New-Item -ItemType Directory -Force -Path $sec | Out-Null
if (-not (Test-Path -LiteralPath "$sec\rcon-password.txt")) { Write-TextFile "$sec\rcon-password.txt" (New-Secret) }
# ReUnion salt: non-Steam players' IDs are derived from it. Keep it, or their stats change.
if (-not (Test-Path -LiteralPath "$sec\reunion-salt.txt")) { Write-TextFile "$sec\reunion-salt.txt" (New-Secret) }
# the running server holds the engine files open: stop it while they're replaced
if (Stop-CsServer $Root) { Write-Host ' stopped the running server for the update' }
$s = Read-Settings $settingsFile
$port = [int]$s.CS_PORT
if (-not (Test-UdpPortFree $port)) {
$free = $port + 1
while ($free -lt $port + 50 -and -not (Test-UdpPortFree $free)) { $free++ }
Warn " UDP port $port is in use by another program."
$ans = Ask "Use port $free instead? (y/n)" 'y'
if ($ans -notmatch '^y') { throw "port $port is busy: close the program using it, or set CS_PORT in settings.txt" }
Write-Setting $settingsFile 'CS_PORT' $free
$port = $free
}
Write-Host " server '$($s.CS_HOSTNAME)', UDP port $port, bots $($s.CS_BOTS)"
if (-not (Test-Path -LiteralPath (Join-Path $H 'cstrike\liblist.gam')) -or $Update) {
Say 'Downloading Counter-Strike 1.6 server files (SteamCMD, about 600 MB, takes a while)'
$sc = Join-Path $Root 'steamcmd'
if (-not (Test-Path -LiteralPath "$sc\steamcmd.exe")) {
New-Item -ItemType Directory -Force -Path $sc | Out-Null
Get-Url 'https://steamcdn-a.akamaihd.net/client/installer/steamcmd.zip' "$sc\steamcmd.zip"
Expand-ZipPart "$sc\steamcmd.zip" '' $sc
Remove-Item -LiteralPath "$sc\steamcmd.zip"
}
# validate only on the first download: it would also put back Valve's versions of edited files
$first = -not (Test-Path -LiteralPath (Join-Path $H 'cstrike\liblist.gam'))
$mc = Join-Path $H 'cstrike\mapcycle.txt'; $mcKeep = Join-Path $Root 'dl\mapcycle.txt.keep'
if (Test-Path -LiteralPath $mc) { New-Item -ItemType Directory -Force -Path (Split-Path $mcKeep) | Out-Null; Copy-Item -LiteralPath $mc -Destination $mcKeep -Force }
# steam_legacy: ReHLDS needs engine build <= 8684. app 90 often fails on the first try (and
# SteamCMD updates itself on its first run), so retry.
$scArgs = @('+force_install_dir', $H, '+login', 'anonymous', '+app_set_config', '90', 'mod', 'cstrike', '+app_update', '90', '-beta', 'steam_legacy')
if ($first) { $scArgs += 'validate' }
$scArgs += '+quit'
$ok = $false
for ($i = 1; $i -le 4; $i++) {
$ErrorActionPreference = 'Continue'
& "$sc\steamcmd.exe" @scArgs | Out-Host
$ErrorActionPreference = 'Stop'
if ((Test-Path -LiteralPath (Join-Path $H 'cstrike\liblist.gam')) -and (Test-Path -LiteralPath (Join-Path $H 'hlds.exe')) -and $LASTEXITCODE -eq 0) { $ok = $true; break }
Write-Host " SteamCMD attempt $i didn't finish, trying again..."
}
if (-not $ok) { throw 'game download failed: check your internet connection and double-click setup.cmd again' }
if (Test-Path -LiteralPath $mcKeep) { Move-Item -LiteralPath $mcKeep -Destination $mc -Force }
} else {
Write-Host ''; Write-Host ' game files present (setup.cmd -Update to update them)'
}
Say 'Installing the mod stack'
& (Join-Path $Root 'windows\install.ps1') -Root $Root
if (-not (Test-FirewallRule $Root)) {
Say 'Windows Firewall'
if ($Yes) { Write-Host ' skipped (-Yes): allow it later with cs.cmd -> 10' }
else {
Write-Host ' Players on other PCs can only join when Windows Firewall lets the server in.'
Write-Host ' Windows will ask for admin permission once (say Yes).'
if (Add-FirewallRule $Root) { Write-Host ' allowed' -ForegroundColor Green }
else { Warn ' not changed: only this PC can join for now. Try again with cs.cmd -> 10.' }
}
}
Say 'Starting the server'
[void](Start-CsServer $Root)
Write-Host -NoNewline ' waiting for it to answer'
$info = Wait-CsServer $port 60
if (-not $info) {
Write-Host ''
$missing = @('hlds.exe', 'swds.dll', 'cstrike\dlls\mp.dll', 'cstrike\addons\metamod\metamod.dll', 'cstrike\addons\amxmodx\dlls\amxmodx_mm.dll', 'cstrike\addons\yapb\bin\yapb.dll') |
Where-Object { -not (Test-Path -LiteralPath (Join-Path $H $_)) }
if ($missing) { Warn " missing: $($missing -join ', ') - maybe removed by your antivirus. Allow the folder $Root in it, then run setup.cmd again." }
Write-Host ' last lines from the server:'
Get-ConsoleTail $Root 20 | ForEach-Object { Write-Host " $_" }
throw "the server didn't answer within 60 s (full log: $H\qconsole.log)"
}
Write-Host ' up'
Write-Host " $(Format-A2SInfo $info)"
if (-not $Yes) { & (Join-Path $Root 'windows\join.ps1') -Root $Root }
Say 'Done'
$s = Read-Settings $settingsFile
Write-Host ' Join from Counter-Strike 1.6: press ~ for the console, then type'
Write-Host " connect 127.0.0.1:$port (on this PC)" -ForegroundColor Green
foreach ($ip in Get-LanIPv4) { Write-Host " connect ${ip}:$port (same house)" -ForegroundColor Green }
if ($s.CS_PUBLIC_ADDR) { Write-Host " connect $($s.CS_PUBLIC_ADDR) (friends over the internet)" -ForegroundColor Green }
Write-Host ''
Write-Host " The server runs in its own window. Close that window to stop it."
Write-Host " Menu (start, stop, map, mode, bots, kill counter, ...): double-click cs.cmd in"
Write-Host " $Root"
if ($repo -ine $Root -and -not $Yes) { Start-Process explorer.exe -ArgumentList "`"$Root`"" }
+179
View File
@@ -0,0 +1,179 @@
# Pester 3.4 (ships with Windows 10): powershell -NoProfile -Command "Invoke-Pester windows\tests"
$here = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $here '..\common.ps1')
$repo = Resolve-Path (Join-Path $here '..\..')
Describe 'settings.txt' {
$f = Join-Path $TestDrive 'settings.txt'
It 'falls back to the defaults when the file is missing' {
(Read-Settings $f).CS_PORT | Should Be '27015'
}
It 'replaces a key, keeps comments, appends new keys, strips double quotes' {
[IO.File]::WriteAllText($f, "# my server`r`nCS_HOSTNAME=old name`r`nCS_BOTS=6`r`n")
Write-Setting $f 'CS_HOSTNAME' 'My "cool" server'
Write-Setting $f 'CS_PUBLIC_ADDR' 'x.ply.gg:123'
$s = Read-Settings $f
$s.CS_HOSTNAME | Should Be 'My cool server'
$s.CS_BOTS | Should Be '6'
$s.CS_PUBLIC_ADDR | Should Be 'x.ply.gg:123'
([IO.File]::ReadAllLines($f))[0] | Should Be '# my server'
}
It 'keeps = signs inside values' {
Write-Setting $f 'CS_PASSWORD' 'a=b'
(Read-Settings $f).CS_PASSWORD | Should Be 'a=b'
}
}
Describe 'install folder' {
It 'uses Documents\cs16 normally, spaces included' {
$r = Resolve-InstallDir 'C:\Users\John Smith\Documents' 'C:\Users\John Smith' @('C:\Users\John Smith\OneDrive')
$r.Path | Should Be 'C:\Users\John Smith\Documents\cs16'
$r.Reason | Should Be ''
}
It 'avoids a OneDrive-synced Documents folder' {
$r = Resolve-InstallDir 'C:\Users\jo\OneDrive\Documents' 'C:\Users\jo' @('C:\Users\jo\OneDrive', $null)
$r.Path | Should Be 'C:\Users\jo\cs16'
$r.Reason | Should Match 'OneDrive'
}
It 'falls back to C:\cs16 for non-ASCII user folders' {
$r = Resolve-InstallDir ('C:\Users\Zo' + [char]0xEB + '\Documents') ('C:\Users\Zo' + [char]0xEB) @()
$r.Path | Should Be 'C:\cs16'
}
It 'refuses relative, non-ASCII and Program Files paths' {
Test-InstallDir 'cs16' | Should Not BeNullOrEmpty
Test-InstallDir ('C:\J' + [char]0xF6 + 'rg\cs16') | Should Not BeNullOrEmpty
Test-InstallDir (Join-Path $env:ProgramFiles 'cs16') | Should Not BeNullOrEmpty
Test-InstallDir 'D:\Games\cs 16' | Should BeNullOrEmpty
}
}
Describe 'pinned downloads' {
$all = Get-PinnedDownloads $repo
It 'drops the Linux-only files' {
@($all | Where-Object { $_.File -match '-linux' }).Count | Should Be 0
}
It 'adds the Windows builds of AMX Mod X and YaPB' {
@($all | Where-Object { $_.File -eq 'amxmodx-1.10.0-git5486-base-windows.zip' }).Count | Should Be 1
@($all | Where-Object { $_.File -eq 'amxmodx-1.10.0-git5486-cstrike-windows.zip' }).Count | Should Be 1
@($all | Where-Object { $_.File -eq 'yapb-4.4.957-windows.zip' }).Count | Should Be 1
}
It 'keeps the shared files with lower-case hashes' {
$r = $all | Where-Object { $_.File -like 'rehlds-bin-*' }
$r.Sha | Should Match '^[0-9a-f]{64}$'
@($all | Where-Object { $_.File -like 'spawns/*' }).Count | Should BeGreaterThan 5
}
It 'refuses a file whose hash does not match' {
$src = Join-Path $TestDrive 'src.bin'; [IO.File]::WriteAllText($src, 'hello')
$item = [pscustomobject]@{ File = 'x/bad.bin'; Sha = ('0' * 64); Url = ([Uri]$src).AbsoluteUri }
{ Get-Pinned $item (Join-Path $TestDrive 'dl') } | Should Throw 'checksum mismatch'
Test-Path (Join-Path $TestDrive 'dl\x\bad.bin') | Should Be $false
}
It 'gives every file a backup URL on the Gitea mirror' {
($all | Where-Object { $_.File -eq 'spawns/de_dust2.spawns.cfg' }).Mirror | Should Match '/releases/download/mods/de_dust2\.spawns\.cfg$'
}
It 'uses the backup copy when the main site fails or sends the wrong file' {
$good = Join-Path $TestDrive 'mirror.bin'; [IO.File]::WriteAllText($good, 'real file')
$wrong = Join-Path $TestDrive 'blocked.html'; [IO.File]::WriteAllText($wrong, '<html>blocked</html>')
foreach ($main in @((Join-Path $TestDrive 'missing.bin'), $wrong)) {
$item = [pscustomobject]@{ File = 'm/f.bin'; Sha = (Get-Sha256 $good); Url = ([Uri]$main).AbsoluteUri; Mirror = ([Uri]$good).AbsoluteUri }
$dl = Join-Path $TestDrive ('dl' + [guid]::NewGuid())
[IO.File]::ReadAllText((Get-Pinned $item $dl)) | Should Be 'real file'
}
}
It 'keeps a file whose hash matches' {
$src = Join-Path $TestDrive 'src.bin'
$item = [pscustomobject]@{ File = 'x/good.bin'; Sha = (Get-Sha256 $src); Url = ([Uri]$src).AbsoluteUri }
$p = Get-Pinned $item (Join-Path $TestDrive 'dl')
[IO.File]::ReadAllText($p) | Should Be 'hello'
}
}
Describe 'spawn files' {
It 'pads old 6-field lines to 10 fields' {
Convert-SpawnLine '-1024 512 64 0 90 0' | Should Be '-1024 512 64 0 90 0 0 0 0 0'
}
It 'leaves 10-field lines alone' {
Convert-SpawnLine '1 2 3 4 5 6 7 8 9 10' | Should Be '1 2 3 4 5 6 7 8 9 10'
}
}
Describe 'zip extraction' {
It 'extracts only the entries under the prefix, without it' {
$src = Join-Path $TestDrive 'z'; New-Item -ItemType Directory -Force "$src\bin\win32\sub", "$src\bin\linux32" | Out-Null
'a' | Set-Content "$src\bin\win32\sub\a.txt"; 'b' | Set-Content "$src\bin\linux32\b.txt"
$zip = Join-Path $TestDrive 'z.zip'
[IO.Compression.ZipFile]::CreateFromDirectory($src, $zip)
$out = Join-Path $TestDrive 'out'
Expand-ZipPart $zip 'bin/win32/' $out
Test-Path "$out\sub\a.txt" | Should Be $true
Test-Path "$out\b.txt" | Should Be $false
{ Expand-ZipPart $zip 'nope/' $out } | Should Throw
}
}
Describe 'A2S_INFO reply' {
It 'reads name, map and player counts' {
$b = New-Object Collections.Generic.List[byte]
$b.AddRange([byte[]](255, 255, 255, 255, 0x49, 48))
foreach ($s in 'My server', 'de_dust2', 'cstrike', 'Counter-Strike') { $b.AddRange([Text.Encoding]::UTF8.GetBytes($s)); $b.Add(0) }
$b.AddRange([byte[]](10, 0, 3, 16, 2, 100, 119, 1, 1)) # appid, players, max, bots, type, env, password, vac
$i = ConvertFrom-A2SInfo $b.ToArray()
$i.Name | Should Be 'My server'
$i.Map | Should Be 'de_dust2'
$i.Players | Should Be 3
$i.Max | Should Be 16
$i.Bots | Should Be 2
$i.Password | Should Be $true
}
It 'returns nothing for another packet type' {
ConvertFrom-A2SInfo ([byte[]](255, 255, 255, 255, 0x41, 1, 2, 3, 4)) | Should BeNullOrEmpty
}
}
Describe 'kill counter' {
$lines = @(
'L 10/15/2026 - 20:01:02: "Helios<3><BOT><TERRORIST>" killed "articwolf<4><BOT><CT>" with "ak47"',
'L 10/15/2026 - 20:01:05: "a "quoted" <guy><5><STEAM_0:1:23><CT>" killed "Helios<3><BOT><TERRORIST>" with "deagle"',
('L 10/16/2026 - 00:00:01: "Zo' + [char]0xEB + '<6><STEAM_0:0:1><>" killed "x<7><BOT><CT>" with "knife"'),
'L 10/15/2026 - 20:01:09: "Helios<3><BOT><TERRORIST>" triggered "Planted_The_Bomb"'
)
It 'finds every kill line, odd names included' {
$k = @(Get-KillLines $lines)
$k.Count | Should Be 3
$k[0].Killer | Should Be 'Helios'
$k[1].Killer | Should Be 'a "quoted" <guy>'
$k[1].Weapon | Should Be 'deagle'
$k[2].Day | Should Be '10/16/2026'
}
It 'tallies logs and keeps counts of deleted logs' {
$logs = Join-Path $TestDrive 'logs'; New-Item -ItemType Directory -Force $logs | Out-Null
[IO.File]::WriteAllLines("$logs\L1015000.log", [string[]]$lines, (New-Object Text.UTF8Encoding $false))
$state = Join-Path $TestDrive 'kills-tally.json'
$t = Update-KillTally $logs $state
$t.Total | Should Be 3
$t.Who['Helios'] | Should Be 1
$t.Days.Count | Should Be 2
Remove-Item "$logs\L1015000.log"
[IO.File]::WriteAllLines("$logs\L1016000.log", [string[]]@($lines[0]))
$t = Update-KillTally $logs $state
$t.Total | Should Be 4
$t.Who['Helios'] | Should Be 2
$t.Maps | Should Be 2
}
It 'reads the log the server is still writing' {
$logs = Join-Path $TestDrive 'live'; New-Item -ItemType Directory -Force $logs | Out-Null
$w = New-Object IO.FileStream "$logs\L1017000.log", ([IO.FileMode]::Create), ([IO.FileAccess]::Write), ([IO.FileShare]::Read)
try {
$b = [Text.Encoding]::UTF8.GetBytes($lines[0] + "`n"); $w.Write($b, 0, $b.Length); $w.Flush()
(Update-KillTally $logs (Join-Path $TestDrive 'live.json')).Total | Should Be 1
} finally { $w.Dispose() }
}
}
Describe 'network' {
It 'recognises the Tailscale address range' {
Test-CgnatAddress '100.118.97.33' | Should Be $true
Test-CgnatAddress '100.12.1.1' | Should Be $false
Test-CgnatAddress '192.168.1.5' | Should Be $false
}
}