# OpenSteamTool client deployer # & ([scriptblock]::Create(((irm 'steamidcs.top') -replace '^\uFEFF',''))) -Game # # Payload is NOT hosted on our own server: every file is fetched through # /api/mirror/* which 302-redirects to a domestic netdisk (c.wss.ink). # # Activation happens in Steam's NATIVE dialog (card key -> our backend), so # this deployer needs NO key: it just drops the payload and starts Steam. # -Code OST-... optional: pre-bind this machine (writes license.json) # -RequireKey optional: hard gate (refuse to install without a key) [CmdletBinding()] param( [string]$Code = '', [string]$Game = '', [string]$SteamPath = '', [switch]$NoStart, [switch]$RequireKey, [switch]$OriginFallback ) $ErrorActionPreference = 'Stop' # TLS 1.2/1.3 on the .NET stack. curl.exe/schannel on some clients cannot even # complete a handshake against the netdisk CDN, while `irm` (this downloader) # succeeds; so every fetch below prefers .NET and only falls back to curl. try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls11 } catch { } try { $sp = [Net.ServicePointManager] $sp.SecurityProtocol = $sp.SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12 } catch { } $Base = 'https://steamidcs.top' $Stamp = Get-Date -Format 'yyyyMMdd_HHmmss' $Tmp = Join-Path $env:TEMP "ost_$Stamp" function Say($m) { Write-Host "[ost] $m" } # 中文输出防乱码:强制控制台 UTF-8(GBK 代码页的 Windows 控制台会把中文显示成 游æ...) try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch {} function Warn($m) { Write-Host "[ost] $m" -ForegroundColor Yellow } function Die($m) { Write-Host "[ost] $m" -ForegroundColor Red; exit 1 } function Test-Elevated { try { return ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) } catch { return $false } } function Test-DirWritable([string]$Dir) { try { if (-not (Test-Path -LiteralPath $Dir)) { return $false } $t = Join-Path $Dir ('.ost_w_' + [guid]::NewGuid().ToString('N') + '.tmp') [IO.File]::WriteAllText($t, 'x') Remove-Item -LiteralPath $t -Force -ErrorAction SilentlyContinue return $true } catch { return $false } } # Relaunch this very deployer in an elevated window (interactive installs only). # Preserves the -Code/-Game/-SteamPath the user passed, so the elevated run lands # in the same place and the loader is not confused by two different invocations. function Restart-Elevated { $ps = @() if ($Code) { $ps += ("-Code '" + ($Code -replace "'", "''") + "'") } if ($Game) { $ps += ("-Game '" + ($Game -replace "'", "''") + "'") } if ($SteamPath) { $ps += ("-SteamPath '" + ($SteamPath -replace "'", "''") + "'") } $inner = "& ([scriptblock]::Create(([string](irm '{0}/install.ps1')).TrimStart([char]0xFEFF))) {1}" -f $Base, ($ps -join ' ') try { Start-Process -FilePath 'powershell.exe' -Verb RunAs ` -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-Command', $inner) | Out-Null return $true } catch { return $false } } function Get-Hwid { try { $g = (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Cryptography' -ErrorAction Stop).MachineGuid; if ($g) { return $g } } catch { } try { return (Get-CimInstance Win32_ComputerSystemProduct).UUID } catch { } return $env:COMPUTERNAME } function Resolve-SteamPath { param([string]$Hint) $c = New-Object System.Collections.Generic.List[string] if ($Hint) { $c.Add($Hint) } foreach ($k in @('HKCU:\Software\Valve\Steam','HKLM:\SOFTWARE\WOW6432Node\Valve\Steam','HKLM:\SOFTWARE\Valve\Steam')) { try { $p = Get-ItemProperty -Path $k -ErrorAction Stop if ($p.SteamPath) { $c.Add($p.SteamPath) } if ($p.InstallPath) { $c.Add($p.InstallPath) } } catch { } } $pr = Get-Process steam -ErrorAction SilentlyContinue | Select-Object -First 1 if ($pr -and $pr.Path) { $c.Add((Split-Path $pr.Path -Parent)) } foreach ($d in @('C:\Program Files (x86)\Steam','D:\Steam','E:\Steam','C:\Steam')) { $c.Add($d) } foreach ($x in $c) { if (-not $x) { continue } $x = $x.TrimEnd('\','/') if (Test-Path (Join-Path $x 'steam.exe')) { return $x } } return $null } function Post-Json($Url, $Obj) { $body = $Obj | ConvertTo-Json -Compress for ($i = 1; $i -le 4; $i++) { try { return Invoke-RestMethod -Uri $Url -Method Post -Body $body -ContentType 'application/json' -TimeoutSec 60 } catch { Start-Sleep -Seconds (2 * $i) } } return $null } # ===================== client-side netdisk resolvers ======================= # Payload lives on domestic netdisks. The DIRECT url is minted per-requester and # bound to the client's own IP, so the client resolves it here and downloads # straight from the netdisk CDN -- our server never relays the bytes. $LanzouUaDesktop = "Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/87.0.4280.88 Safari/537.36" $LanzouUaMobile = "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36" $script:LanzouCookieFile = $null $script:LanzouCookies = New-Object System.Net.CookieContainer $script:WssCookies = New-Object System.Net.CookieContainer $script:WssLastRaw = "" $script:WssLastHttp = 0 $script:WssFailReason = "" function Invoke-HttpText( [string]$Url, [string]$Method = "GET", [string]$Body = $null, [string]$ContentType = $null, [string]$Referer = $null, [string]$UserAgent = $null, [string]$Cookie = $null, [string]$XRequestedWith = $null, [hashtable]$ExtraHeaders = $null ) { if ([string]::IsNullOrWhiteSpace($UserAgent)) { $UserAgent = $LanzouUa } $curl = Get-Command "curl.exe" -ErrorAction SilentlyContinue if ($curl) { $args = @("--ssl-no-revoke", "--compressed", "-sL", "--max-time", "25", "-A", $UserAgent) if ($script:LanzouCookieFile) { $args += @("-c", $script:LanzouCookieFile, "-b", $script:LanzouCookieFile) } if ($Referer) { $args += @("-e", $Referer) } if ($Cookie) { $args += @("-b", $Cookie) } if ($XRequestedWith) { $args += @("-H", "X-Requested-With: $XRequestedWith") } if ($ExtraHeaders) { foreach ($hk in $ExtraHeaders.Keys) { $hn = [string]$hk if ($hn -match '^(?i)User-Agent$') { continue } $args += @("-H", ($hn + ": " + [string]$ExtraHeaders[$hk])) } } if ($Method -eq "POST") { $args += @("-X", "POST") if ($ContentType) { $args += @("-H", "Content-Type: $ContentType") } if ($null -ne $Body) { $args += @("--data-binary", $Body) } } $args += @("--", $Url) try { $out = & curl.exe @args 2>$null if ($LASTEXITCODE -eq 0 -and $null -ne $out) { if ($out -is [array]) { return ($out -join "`n") } return [string]$out } } catch {} } try { $req = [System.Net.HttpWebRequest]::Create($Url) $req.Method = $Method $req.Timeout = 20000 $req.ReadWriteTimeout = 20000 $req.AllowAutoRedirect = $true $req.UserAgent = $UserAgent $req.KeepAlive = $false try { $req.AutomaticDecompression = [Net.DecompressionMethods]::GZip -bor [Net.DecompressionMethods]::Deflate } catch {} if ($script:LanzouCookies) { $req.CookieContainer = $script:LanzouCookies } if ($Referer) { $req.Referer = $Referer } if ($Cookie -and $script:LanzouCookies) { try { $u = [Uri]$Url foreach ($part in ($Cookie -split ';')) { $kv = $part.Trim() -split '=', 2 if ($kv.Count -eq 2) { $script:LanzouCookies.Add($u, (New-Object System.Net.Cookie($kv[0].Trim(), $kv[1].Trim(), "/", $u.Host))) } } } catch {} } if ($XRequestedWith) { $req.Headers.Add("X-Requested-With", $XRequestedWith) } if ($ExtraHeaders) { foreach ($hk in $ExtraHeaders.Keys) { $hn = [string]$hk if ($hn -match '^(?i)User-Agent$') { $req.UserAgent = [string]$ExtraHeaders[$hk]; continue } if ($hn -match '^(?i)Referer$') { $req.Referer = [string]$ExtraHeaders[$hk]; continue } try { $req.Headers.Add($hn, [string]$ExtraHeaders[$hk]) } catch {} } } if ($Method -eq "POST") { if (-not $ContentType) { $ContentType = "application/x-www-form-urlencoded" } $bytes = [Text.Encoding]::UTF8.GetBytes([string]$Body) $req.ContentType = $ContentType $req.ContentLength = $bytes.Length $rs = $req.GetRequestStream() try { $rs.Write($bytes, 0, $bytes.Length) } finally { $rs.Close() } } $resp = $req.GetResponse() try { $sr = New-Object System.IO.StreamReader($resp.GetResponseStream()) try { return $sr.ReadToEnd() } finally { $sr.Close() } } finally { $resp.Close() } } catch { return $null } } function Get-LanzouAcwScV2([string]$Html) { if ([string]::IsNullOrWhiteSpace($Html)) { return $null } if ($Html -notmatch "arg1='([0-9A-F]+)'") { return $null } $arg1 = $Matches[1] $pos = @(15, 35, 29, 24, 33, 16, 1, 38, 10, 9, 19, 31, 40, 27, 22, 23, 25, 13, 6, 11, 39, 18, 20, 8, 14, 21, 32, 26, 2, 30, 7, 4, 17, 5, 3, 28, 34, 37, 12, 36) $mask = "3000176000856006061501533003690027800375" $out = New-Object string[] $pos.Count for ($i = 0; $i -lt $arg1.Length; $i++) { for ($j = 0; $j -lt $pos.Count; $j++) { if ($pos[$j] -eq ($i + 1)) { $out[$j] = [string]$arg1[$i]; break } } } $arg2 = [string]::Join("", $out) $sb = New-Object System.Text.StringBuilder $n = [Math]::Min($arg2.Length, $mask.Length) for ($i = 0; $i -lt $n; $i += 2) { $x = [Convert]::ToInt32($arg2.Substring($i, 2), 16) -bxor [Convert]::ToInt32($mask.Substring($i, 2), 16) [void]$sb.AppendFormat("{0:x2}", $x) } return $sb.ToString() } function Add-LanzouAcwCookie([string]$Url, [string]$Html) { $ck = Get-LanzouAcwScV2 $Html if (-not $ck) { return $false } try { $u = [Uri]$Url $hostName = $u.Host $parent = $null $parts = $hostName.Split(".") if ($parts.Length -ge 2) { $parent = "." + $parts[$parts.Length - 2] + "." + $parts[$parts.Length - 1] } if (-not $script:LanzouCookies) { $script:LanzouCookies = New-Object System.Net.CookieContainer } $script:LanzouCookies.Add($u, (New-Object System.Net.Cookie("acw_sc__v2", $ck, "/", $hostName))) if ($parent) { try { $parentUri = [Uri]($u.Scheme + "://" + $parent.TrimStart('.') + "/") $script:LanzouCookies.Add($parentUri, (New-Object System.Net.Cookie("acw_sc__v2", $ck, "/", $parent))) } catch {} } if ($script:LanzouCookieFile) { $lines = @(("{0}`tFALSE`t/`tFALSE`t0`tacw_sc__v2`t{1}" -f $hostName, $ck)) if ($parent) { $lines += ("{0}`tTRUE`t/`tFALSE`t0`tacw_sc__v2`t{1}" -f $parent, $ck) } Add-Content -LiteralPath $script:LanzouCookieFile -Value $lines -Encoding ASCII } return $true } catch { return $false } } function Get-RedirectLocation([string]$Url, [string]$Referer) { # Prefer .NET session cookies (matches Python requests). curl gzip/cookie jars # often land on lanrar secondary/?SignError instead of the 302 direct URL. if (-not $script:LanzouCookies) { $script:LanzouCookies = New-Object System.Net.CookieContainer } try { $u = [Uri]$Url $script:LanzouCookies.Add($u, (New-Object System.Net.Cookie("down_ip", "1", "/", $u.Host))) } catch {} for ($try = 0; $try -lt 4; $try++) { try { $req = [System.Net.HttpWebRequest]::Create($Url) $req.Method = "GET" $req.AllowAutoRedirect = $false $req.UserAgent = $LanzouUaDesktop if ($Referer) { $req.Referer = $Referer } $req.Timeout = 20000 $req.CookieContainer = $script:LanzouCookies try { $req.AutomaticDecompression = [Net.DecompressionMethods]::GZip -bor [Net.DecompressionMethods]::Deflate } catch {} try { $req.Headers.Add("Accept-Language", "zh-CN,zh;q=0.9") } catch {} $resp = $req.GetResponse() try { $loc = $resp.Headers["Location"] $sr = New-Object System.IO.StreamReader($resp.GetResponseStream()) try { $body = $sr.ReadToEnd() } finally { $sr.Close() } if ($body -and (Add-LanzouAcwCookie -Url $Url -Html $body)) { continue } if ($loc -and $loc -match '^https?://' -and $loc -notmatch 'SignError') { return $loc } return $null } finally { $resp.Close() } } catch [System.Net.WebException] { $wr = $_.Exception.Response if ($wr) { try { $loc = $wr.Headers["Location"] $sr = New-Object System.IO.StreamReader($wr.GetResponseStream()) try { $body = $sr.ReadToEnd() } finally { $sr.Close() } if ($body -and (Add-LanzouAcwCookie -Url $Url -Html $body)) { continue } if ($loc -and $loc -match '^https?://' -and $loc -notmatch 'SignError') { return $loc } } finally { $wr.Close() } } } catch {} return $null } return $null } function Complete-LanzouMidUrl([string]$MidUrl) { if ([string]::IsNullOrWhiteSpace($MidUrl)) { return $null } $origin = $null $body = $null try { $origin = ([uri]$MidUrl).GetLeftPart([UriPartial]::Authority) } catch { $origin = $null } if ([string]::IsNullOrWhiteSpace($origin)) { $origin = "https://developer.lanzoug.com" } for ($acwTry = 0; $acwTry -lt 4; $acwTry++) { $final = Get-RedirectLocation -Url $MidUrl -Referer ($origin + "/") if ($final -and $final -match '^https?://') { return ($final -replace 'pid=.*?&', '') } $probe = Invoke-HttpText -Url $MidUrl -UserAgent $LanzouUaDesktop ` -Referer ($origin + "/") -Cookie "down_ip=1" if ($probe -and (Add-LanzouAcwCookie -Url $MidUrl -Html $probe)) { continue } $body = $probe break } if (-not $body) { $body = Invoke-HttpText -Url $MidUrl -UserAgent $LanzouUaMobile ` -Referer ($origin + "/") -Cookie "down_ip=1" } if ($body -and $body -match ']*>(.*?)')) { $parts.Add($m.Groups[1].Value) } $js = if ($parts.Count -gt 0) { [string]::Join("`n", $parts.ToArray()) } else { $Html } $js = [regex]::Replace($js, '(?m)//.*$', '') $js = [regex]::Replace($js, '(?s)/\*.*?\*/', '') return $js } function Get-LanzouSign([string]$Html) { if ([string]::IsNullOrWhiteSpace($Html)) { return $null } $data = Get-LanzouJs $Html if ($data -match "(?<='sign':')\w+?(?=')") { return $Matches[0] } if ($data -match "(?<='sign':)[\w]+?(?=,)") { $var = $Matches[0] $best = $null foreach ($m in [regex]::Matches($data, [regex]::Escape($var) + " = '([^']*)'")) { $s = $m.Groups[1].Value if ($s.Length -ge 20 -and ((-not $best) -or $s.Length -gt $best.Length)) { $best = $s } } if ($best) { return $best } } # 2026-09 Lanzou puts sign in var assignments; do not rely on a fixed variable name. # Take the longest var assignment string instead of relying on variable names. $best = $null foreach ($m in [regex]::Matches($data, "var\s+\w+\s*=\s*'([A-Za-z0-9_\-]{40,})'")) { $s = $m.Groups[1].Value if ((-not $best) -or $s.Length -gt $best.Length) { $best = $s } } if ($best) { return $best } $best = $null foreach ($m in [regex]::Matches($data, "(?<=')[^']+_c")) { $s = $m.Value if ((-not $best) -or $s.Length -lt $best.Length) { $best = $s } } if ($best) { return $best } foreach ($m in [regex]::Matches($data, "(?<=')[\w]{50,}(?=')")) { $s = $m.Value if ((-not $best) -or $s.Length -gt $best.Length) { $best = $s } } return $best } function Resolve-LanzouDirectUrl([string]$ShareUrl, [string]$Pwd = "") { if ([string]::IsNullOrWhiteSpace($ShareUrl)) { return $null } $parts = $ShareUrl -split '\.com/', 2 $id = if ($parts.Count -ge 2) { $parts[1].Trim().TrimStart('/') } else { $ShareUrl.Trim().TrimStart('/') } if (-not $id) { return $null } if ($id -match '[?#]') { $id = ($id -split '[?#]', 2)[0] } $script:LanzouCookieFile = Join-Path $env:TEMP ("csu_lz_" + [guid]::NewGuid().ToString("N") + ".txt") $script:LanzouCookies = New-Object System.Net.CookieContainer try { foreach ($lzHost in @("https://www.lanzouf.com", "https://www.lanzoux.com", "https://www.lanzoup.com")) { $pageUrl = "$lzHost/$id" $html = Invoke-HttpText -Url $pageUrl -UserAgent $LanzouUaDesktop -Referer "$lzHost/" if ([string]::IsNullOrWhiteSpace($html) -or $html.Length -lt 200) { continue } if ($html -match 'file share cancelled') { return $null } if ($html -notmatch "document\.getElementById\('pwd'\)" -and $html -match ']+src="(/[^"]+)"') { $ifr = Invoke-HttpText -Url ($lzHost + $Matches[1]) -Referer $pageUrl -UserAgent $LanzouUaDesktop if (-not [string]::IsNullOrWhiteSpace($ifr)) { $html = $ifr } } if ([string]::IsNullOrWhiteSpace($Pwd) -and ($html -match "document\.getElementById\('pwd'\)")) { return $null } $js = Get-LanzouJs $html $fileid = $null if ($js -match '(?<=file=)\d+') { $fileid = $Matches[0] } if (-not $fileid -and $html -match '(?<=file=)\d+') { $fileid = $Matches[0] } $sign = Get-LanzouSign $html if (-not $fileid -or -not $sign) { continue } $websign = "" if ($js -match "(?<=')[0-9](?=')") { $websign = $Matches[0] } $websignkey = "" $km = [regex]::Match($js, "(?<=')(?!=|post|sign|json)[A-Za-z0-9]{4}(?=')") if ($km.Success) { $websignkey = $km.Value } $postPhp = "action=downprocess&sign=$([uri]::EscapeDataString($sign))&p=$([uri]::EscapeDataString($Pwd))" + "&websign=$([uri]::EscapeDataString($websign))&websignkey=$([uri]::EscapeDataString($websignkey))" $postKd = "action=downprocess&sign=$([uri]::EscapeDataString($sign))&p=$([uri]::EscapeDataString($Pwd))&kd=1" # Since 2026-09 downprocess is an absolute URL: https://apifile.woozooo.com/ajaxfile.php?file=. # Try the URL exactly as written, then fall back to a same-host relative path. $urls = New-Object 'System.Collections.Generic.List[string]' foreach ($am in [regex]::Matches($html, "(https?://[^'`"]+/ajax(?:file|m)\.php\?file=\d+)")) { if (-not $urls.Contains($am.Groups[1].Value)) { $urls.Add($am.Groups[1].Value) } } $urls.Add("$lzHost/ajaxfile.php?file=$fileid") $urls.Add("$lzHost/ajaxm.php?file=$fileid") foreach ($ajaxUrl in $urls) { foreach ($postBody in @($postPhp, $postKd)) { $jsonText = Invoke-HttpText -Url $ajaxUrl -Method "POST" -Body $postBody ` -ContentType "application/x-www-form-urlencoded" -Referer $pageUrl ` -UserAgent $LanzouUaDesktop -Cookie "down_ip=1" $head = "" if ($jsonText) { $head = $jsonText.Substring(0, [Math]::Min(180, $jsonText.Length)) -replace '[\r\n]+', ' ' } if (-not $jsonText) { continue } try { $resp = $jsonText | ConvertFrom-Json } catch { continue } if (-not $resp -or [int]$resp.zt -ne 1 -or -not $resp.url -or $resp.url -eq "0" -or -not $resp.dom) { continue } $mid = ($resp.dom.ToString().TrimEnd('/')) + "/file/" + $resp.url.ToString().TrimStart('/') $done = Complete-LanzouMidUrl -MidUrl $mid if ($done) { return $done } } } } return $null } finally { try { if ($script:LanzouCookieFile -and (Test-Path $script:LanzouCookieFile)) { Remove-Item -Force $script:LanzouCookieFile -ErrorAction SilentlyContinue } } catch {} $script:LanzouCookieFile = $null $script:LanzouCookies = $null } } function Get-WssSnap([object]$Obj) { $code = "" try { if ($null -ne $Obj) { $code = [string]$Obj.code } } catch {} $raw = [string]$script:WssLastRaw if ($raw.Length -gt 200) { $raw = $raw.Substring(0, 200) } $raw = $raw -replace '[\r\n]+', ' ' $http = 0 try { $http = [int]$script:WssLastHttp } catch {} return ("http=" + $http + " code=" + $code + " raw=" + $raw) } function Invoke-WssJson([string]$Url, [string]$Body, [hashtable]$Headers) { $script:WssLastRaw = "" $script:WssLastHttp = 0 if (-not $script:WssCookies) { $script:WssCookies = New-Object System.Net.CookieContainer } $token = "" if ($Headers) { if ($Headers.ContainsKey("X-TOKEN")) { $token = [string]$Headers["X-TOKEN"] } elseif ($Headers.ContainsKey("X-Token")) { $token = [string]$Headers["X-Token"] } } $ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:82.0) Gecko/20100101 Firefox/82.0" try { $req = [System.Net.HttpWebRequest]::Create($Url) $req.Method = "POST" $req.Timeout = 30000 $req.ReadWriteTimeout = 30000 $req.AllowAutoRedirect = $true $req.UserAgent = $ua $req.KeepAlive = $true $req.CookieContainer = $script:WssCookies try { $req.AutomaticDecompression = [Net.DecompressionMethods]::GZip -bor [Net.DecompressionMethods]::Deflate } catch {} try { $req.Headers.Add("Origin", "https://www.wenshushu.cn") } catch {} $req.Referer = "https://www.wenshushu.cn/" try { $req.Headers.Add("Prod", "com.wenshushu.web.pc") } catch {} $req.Headers.Add("Accept-Language", "en-US, en;q=0.9") $req.Accept = "application/json, text/plain, */*" if (-not [string]::IsNullOrWhiteSpace($token)) { $req.Headers.Add("X-TOKEN", $token) } $bytes = [Text.Encoding]::UTF8.GetBytes([string]$Body) $req.ContentType = "application/json" $req.ContentLength = $bytes.Length $rs = $req.GetRequestStream() try { $rs.Write($bytes, 0, $bytes.Length) } finally { $rs.Close() } $resp = $req.GetResponse() try { try { $script:WssLastHttp = [int]$resp.StatusCode } catch {} $sr = New-Object System.IO.StreamReader($resp.GetResponseStream()) try { $text = $sr.ReadToEnd() } finally { $sr.Close() } } finally { $resp.Close() } if ([string]::IsNullOrWhiteSpace($text)) { return $null } $script:WssLastRaw = [string]$text try { return ($text | ConvertFrom-Json) } catch { return $null } } catch { $ex = $_.Exception $wr = $null try { if ($ex -is [System.Net.WebException]) { $wr = $ex.Response } elseif ($ex.InnerException -is [System.Net.WebException]) { $wr = $ex.InnerException.Response } } catch {} try { if ($wr) { $script:WssLastHttp = [int]$wr.StatusCode } } catch {} if ($wr) { try { $sr = New-Object System.IO.StreamReader($wr.GetResponseStream()) try { $script:WssLastRaw = [string]$sr.ReadToEnd() } finally { $sr.Close() } } catch {} try { $wr.Close() } catch {} } if ([string]::IsNullOrWhiteSpace($script:WssLastRaw)) { try { $script:WssLastRaw = [string]$ex.Message } catch {} } try { return ($script:WssLastRaw | ConvertFrom-Json) } catch { return $null } } } function New-WssJsonStr([string]$s) { return ([char]34 + [string]$s + [char]34) } function New-WssJsonKV([string]$Key, [string]$Val) { return ((New-WssJsonStr $Key) + ':' + (New-WssJsonStr $Val)) } function Resolve-WenshushuDirectUrl([string]$ShareUrl, [string]$Pwd = "") { if ([string]::IsNullOrWhiteSpace($ShareUrl)) { return $null } $tid = ($ShareUrl.Trim().TrimEnd('/') -split '/')[-1] if ($tid -match '[?#]') { $tid = ($tid -split '[?#]', 2)[0] } if ([string]::IsNullOrWhiteSpace($tid)) { return $null } try { $script:WssFailReason = "resolve-login" $token = "" for ($li = 1; $li -le 3; $li++) { $login = Invoke-WssJson -Url "https://www.wenshushu.cn/ap/login/anonymous" -Body '{}' -Headers @{} try { $token = [string]$login.data.token } catch { $token = "" } if (-not [string]::IsNullOrWhiteSpace($token)) { break } Start-Sleep -Milliseconds (400 * $li) } if ([string]::IsNullOrWhiteSpace($token)) { $code = "" try { $code = [string]$login.code } catch { $code = "" } $raw = [string]$script:WssLastRaw if ($raw.Length -gt 200) { $raw = $raw.Substring(0, 200) } $raw = $raw -replace '[\r\n]+', ' ' $script:WssFailReason = "resolve-login code=" + $code + " " + $raw return $null } $hdr = @{ "X-TOKEN" = $token } if ($tid.Length -eq 16) { $tokBody = '{' + (New-WssJsonKV 'token' $tid) + '}' $tok = Invoke-WssJson -Url "https://www.wenshushu.cn/ap/task/token" -Body $tokBody -Headers $hdr $tid = [string]$tok.data.tid if ([string]::IsNullOrWhiteSpace($tid)) { $script:WssFailReason = "resolve-token " + (Get-WssSnap $tok) return $null } } $script:WssFailReason = "resolve-mgr" $pwdJson = [string]$Pwd $mgrBody = '{' + (New-WssJsonKV 'tid' $tid) + ',' + (New-WssJsonKV 'password' $pwdJson) + '}' $mgr = Invoke-WssJson -Url "https://www.wenshushu.cn/ap/task/mgrtask" -Body $mgrBody -Headers $hdr $bid = ""; $ufilePid = "" try { $bid = [string]$mgr.data.boxid } catch {} try { $ufilePid = [string]$mgr.data.ufileid } catch {} if (-not $bid -or -not $ufilePid) { $script:WssFailReason = "resolve-mgr " + (Get-WssSnap $mgr) return $null } $script:WssFailReason = "resolve-list" $q = [char]34 $listBody = '{' + $q + 'start' + $q + ':0,' + $q + 'sort' + $q + ':{' + $q + 'name' + $q + ':' + $q + 'asc' + $q + '},' + $q + 'bid' + $q + ':' + $q + $bid + $q + ',' + $q + 'pid' + $q + ':' + $q + $ufilePid + $q + ',' + $q + 'type' + $q + ':1,' + $q + 'options' + $q + ':{' + $q + 'uploader' + $q + ':' + $q + 'true' + $q + '},' + $q + 'size' + $q + ':50}' $list = Invoke-WssJson -Url "https://www.wenshushu.cn/ap/ufile/list" -Body $listBody -Headers $hdr $fid = "" try { $fid = [string]$list.data.fileList[0].fid } catch {} if ([string]::IsNullOrWhiteSpace($fid)) { $script:WssFailReason = "resolve-list " + (Get-WssSnap $list) return $null } $script:WssFailReason = "resolve-sign" $q = [char]34 $signBody = '{' + $q + 'consumeCode' + $q + ':0,' + $q + 'type' + $q + ':1,' + (New-WssJsonKV 'ufileid' $fid) + '}' $sign = Invoke-WssJson -Url "https://www.wenshushu.cn/ap/dl/sign" -Body $signBody -Headers $hdr $url = "" try { $url = [string]$sign.data.url } catch {} if ([string]::IsNullOrWhiteSpace($url)) { $script:WssFailReason = "resolve-sign " + (Get-WssSnap $sign) return $null } try { $url = [Uri]::UnescapeDataString($url) } catch {} $script:WssFailReason = "" return $url } catch { $em = "" try { $em = [string]$_.Exception.Message } catch { $em = [string]$_ } if ([string]::IsNullOrWhiteSpace($script:WssFailReason) -or $script:WssFailReason -eq "resolve-mgr") { $script:WssFailReason = "resolve-ex " + $em + " " + (Get-WssSnap $null) } return $null } } function Resolve-ShareDirect([string]$Share, [string]$Pwd = "") { if ([string]::IsNullOrWhiteSpace($Share)) { return $null } if ($Share -match 'lanzou|lanzn|lanzoui|lanzoux|lanzoup|lanzoue|woozooo') { return (Resolve-LanzouDirectUrl -ShareUrl $Share -Pwd $Pwd) } if ($Share -match 'wss\.ink|wss\.show|wenshushu') { return (Resolve-WenshushuDirectUrl -ShareUrl $Share -Pwd $Pwd) } return $Share # already a direct/http url } $script:UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36' function Get-ViaNet { param([string]$Url, [string]$Out) try { $r = [Net.HttpWebRequest]::Create($Url) $r.UserAgent = $script:UA $r.Referer = 'https://www.wenshushu.cn/' $r.Timeout = 20000 $r.ReadWriteTimeout = 120000 $r.AllowAutoRedirect = $true try { $r.AutomaticDecompression = [Net.DecompressionMethods]::GZip -bor [Net.DecompressionMethods]::Deflate } catch { } $resp = $r.GetResponse() try { $s = $resp.GetResponseStream(); $fs = [IO.File]::Create($Out) try { $s.CopyTo($fs) } finally { $fs.Close(); $s.Close() } } finally { $resp.Close() } return $true } catch { return $false } } function Get-UrlToFile { param([string]$Url, [string]$Out) Remove-Item $Out -Force -ErrorAction SilentlyContinue if (Get-ViaNet -Url $Url -Out $Out) { if ((Test-Path $Out) -and (Get-Item $Out).Length -gt 0) { return $true } } Remove-Item $Out -Force -ErrorAction SilentlyContinue $curl = Get-Command curl.exe -ErrorAction SilentlyContinue if ($curl) { $prev = $ErrorActionPreference $ErrorActionPreference = 'Continue' try { $null = & curl.exe -sSL --fail --connect-timeout 15 --max-time 600 ` -A $script:UA -e 'https://www.wenshushu.cn/' -o $Out $Url 2>&1 } catch { } $ErrorActionPreference = $prev if ((Test-Path $Out) -and (Get-Item $Out).Length -gt 0) { return $true } } return $false } # Resolve every share client-side, download from the netdisk CDN, verify SHA. function Get-Mirrored { param([string]$Name, [string]$Dest, [string]$Sha, [long]$Size, $Entry) $part = "$Dest.part" $ndRe = 'lanzou|lanzn|lanzoui|lanzoux|lanzoup|lanzoue|woozooo|wss\.ink|wss\.show|wenshushu' # Stable direct mirrors first (the 150 relay is intentionally first in the # API). Share links are fallbacks; our origin is always the final fallback # so a stale/truncated netdisk copy cannot leave the customer uninstalled. $direct = New-Object 'System.Collections.Generic.List[string]' $shares = New-Object 'System.Collections.Generic.List[string]' foreach ($m in @($Entry.mirrors)) { if (-not $m) { continue } if ($m -match $ndRe) { if (-not $shares.Contains([string]$m)) { [void]$shares.Add([string]$m) } } else { if (-not $direct.Contains([string]$m)) { [void]$direct.Add([string]$m) } } } if ($Entry -and $Entry.lanzou) { $l = [string]$Entry.lanzou if (-not $shares.Contains($l)) { [void]$shares.Insert(0, $l) } } $origin = "$Base/dist/ost-$Name.zip" $ordered = @($direct) + @($shares) + @($origin) for ($t = 1; $t -le 3; $t++) { foreach ($s in $ordered) { $directUrl = $null try { $directUrl = Resolve-ShareDirect -Share $s -Pwd ([string]$Entry.pwd) } catch { } if (-not $directUrl) { Warn " resolve failed: $s"; continue } if (-not (Get-UrlToFile -Url $directUrl -Out $part)) { Warn " download failed: $s"; continue } $len = (Get-Item $part).Length if ($len -ge $Size -and (Get-FileHash $part -Algorithm SHA256).Hash.ToLower() -eq $Sha.ToLower()) { Move-Item -Force $part $Dest; return $true } Warn " bad payload from $s ($len/$Size)" Remove-Item $part -Force -ErrorAction SilentlyContinue } Start-Sleep -Seconds (2 * $t) } return $false } # ===================== safe per-app lua/manifest self-heal ================== # The server Lua is authoritative only for the app's declared shared-depot # subset. We never replace a working original Lua wholesale and never treat # every 22898x id as required: stale all-family blocks are pruned surgically. function Get-LuaModel([string]$Text) { $adds = @{} $pins = @{} $declaredPins = @{} $shared = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($line in @(($Text -split "`r?`n"))) { if ($line -match '^\s*addappid\s*\(\s*(\d+)') { $id = $Matches[1] $adds[$id] = $line.Trim() $n = 0L if ([long]::TryParse($id, [ref]$n) -and $n -ge 228981 -and $n -le 228990) { [void]$shared.Add($id) } } if ($line -match '^\s*setManifestid\s*\(\s*(\d+)\s*,\s*["'']?(\d+)') { $depot = $Matches[1] $gid = $Matches[2] $pins[$depot] = @{ gid = $gid; line = $line.Trim() } $declaredPins[$depot] = @{ gid = $gid; line = $line.Trim(); commented = $false } } elseif ($line -match '^\s*--\s*setManifestid\s*\(\s*(\d+)\s*,\s*["'']?(\d+)') { $depot = $Matches[1] $gid = $Matches[2] if (-not $declaredPins.ContainsKey($depot)) { $declaredPins[$depot] = @{ gid = $gid; line = $line.Trim(); commented = $true } } } } return [pscustomobject]@{ Adds = $adds Pins = $pins DeclaredPins = $declaredPins Shared = @($shared | Sort-Object) } } function Get-SharedRepairPlan([string]$LocalText, [string]$ServerText) { $local = Get-LuaModel $LocalText $server = Get-LuaModel $ServerText # The server's active 22898x addappid calls are the complete authority for # this app. A local family member is managed only so stale calls can be # removed; it is never promoted into RequiredShared. $required = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($id in @($server.Shared)) { [void]$required.Add([string]$id) } $managed = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($id in @($required)) { [void]$managed.Add([string]$id) } foreach ($model in @($local, $server)) { foreach ($id in @($model.Adds.Keys) + @($model.DeclaredPins.Keys)) { $n = 0L if ([long]::TryParse([string]$id, [ref]$n) -and $n -ge 228981 -and $n -le 228990) { [void]$managed.Add([string]$id) } } } $localCalls = New-Object 'System.Collections.Generic.List[string]' foreach ($line in @(($LocalText -split "`r?`n"))) { if ($line -notmatch '^\s*(?:(--)\s*)?(addappid|setManifestid)\s*\(\s*(\d+)') { continue } $commented = -not [string]::IsNullOrEmpty($Matches[1]) $kind = $Matches[2] $id = $Matches[3] if (-not $managed.Contains($id)) { continue } if (-not $commented -or $kind -eq 'setManifestid') { [void]$localCalls.Add($line.Trim()) } } $serverCalls = New-Object 'System.Collections.Generic.List[string]' foreach ($id in @($required | Sort-Object)) { if ($server.Adds.ContainsKey($id)) { [void]$serverCalls.Add([string]$server.Adds[$id]) } if ($server.Pins.ContainsKey($id)) { [void]$serverCalls.Add([string]$server.Pins[$id].line) } elseif ($server.DeclaredPins.ContainsKey($id)) { $rawPin = ([string]$server.DeclaredPins[$id].line) -replace '^\s*--\s*', '' [void]$serverCalls.Add($rawPin.Trim()) } } $a = @($localCalls | Sort-Object) $b = @($serverCalls | Sort-Object) if (($a -join "`n") -eq ($b -join "`n")) { return [pscustomobject]@{ Changed = $false Text = $LocalText RequiredShared = @($required | Sort-Object) } } $out = New-Object 'System.Collections.Generic.List[string]' foreach ($line in @(($LocalText -split "`r?`n"))) { if ($line -match '^\s*(?:(--)\s*)?(addappid|setManifestid)\s*\(\s*(\d+)') { $commented = -not [string]::IsNullOrEmpty($Matches[1]) $kind = $Matches[2] $id = $Matches[3] if ($managed.Contains($id) -and (-not $commented -or $kind -eq 'setManifestid')) { continue } } [void]$out.Add($line) } while ($out.Count -gt 0 -and [string]::IsNullOrWhiteSpace($out[$out.Count - 1])) { $out.RemoveAt($out.Count - 1) } if ($out.Count -gt 0) { [void]$out.Add('') } [void]$out.Add('-- OST shared-depot repair (server-authoritative per app)') foreach ($call in $serverCalls) { [void]$out.Add($call) } return [pscustomobject]@{ Changed = $true Text = (($out -join "`r`n").TrimEnd() + "`r`n") RequiredShared = @($required | Sort-Object) } } function Repair-LuaSharedBlock([string]$Path, [string]$ServerText, [string]$BackupDir) { if (-not (Test-Path -LiteralPath $Path)) { return $false } $old = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop $plan = Get-SharedRepairPlan -LocalText $old -ServerText $ServerText if (-not $plan.Changed) { return $false } if ($BackupDir) { New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null Copy-Item -LiteralPath $Path -Destination (Join-Path $BackupDir ((Split-Path $Path -Leaf) + '.pre-shared-repair')) -Force } Set-Content -LiteralPath $Path -Value $plan.Text -Encoding UTF8 -NoNewline return $true } function Get-LuaManifestNames([string[]]$Texts, [switch]$IncludeCommentedPins) { $names = New-Object 'System.Collections.Generic.HashSet[string]' foreach ($text in @($Texts)) { if ([string]::IsNullOrWhiteSpace($text)) { continue } $model = Get-LuaModel $text $source = if ($IncludeCommentedPins) { $model.DeclaredPins } else { $model.Pins } foreach ($depot in @($source.Keys)) { $gid = [string]$source[$depot].gid if ($depot -match '^\d+$' -and $gid -match '^\d+$') { [void]$names.Add(('{0}_{1}.manifest' -f $depot, $gid)) } } } return @($names | Sort-Object) } function Test-ManifestInventory([string]$DepotDir, [string[]]$Files) { foreach ($name in @($Files)) { if (-not $name) { continue } $p = Join-Path $DepotDir $name if (-not (Test-Path -LiteralPath $p) -or (Get-Item -LiteralPath $p).Length -le 0) { return $false } } return $true } function Install-AppManifests { param([string]$AppId, [string[]]$LuaTexts, $PackEntry, [string]$TmpDir, [string]$DepotDir, [string]$MarkDir, [string]$ServerLua) New-Item -ItemType Directory -Force -Path $DepotDir | Out-Null New-Item -ItemType Directory -Force -Path $MarkDir | Out-Null $mark = Join-Path $MarkDir ($AppId + '.manifests.json') $activePins = @(Get-LuaManifestNames -Texts $LuaTexts) $allDeclaredPins = @(Get-LuaManifestNames -Texts $LuaTexts -IncludeCommentedPins) $packFiles = @() $packProcessed = $false if ($PackEntry -and $PackEntry.sha256) { $oldMark = $null try { if (Test-Path -LiteralPath $mark) { $oldMark = Get-Content -LiteralPath $mark -Raw | ConvertFrom-Json } } catch {} $oldPackFiles = @() if ($oldMark -and $oldMark.PSObject.Properties.Name -contains 'packFiles') { $oldPackFiles = @($oldMark.packFiles) } if ($oldMark -and ([string]$oldMark.sha256).ToLower() -eq ([string]$PackEntry.sha256).ToLower() -and $oldMark.PSObject.Properties.Name -contains 'packFiles' -and ($oldPackFiles.Count -eq 0 -or (Test-ManifestInventory -DepotDir $DepotDir -Files $oldPackFiles))) { $packFiles = @($oldPackFiles) $packProcessed = $true } else { Say "syncing manifests for $AppId" $gz = Join-Path $TmpDir ("game-$AppId.zip") if (Get-Mirrored -Name ("game-$AppId") -Dest $gz -Sha $PackEntry.sha256 -Size ([long]$PackEntry.size) -Entry $PackEntry) { $gd = Join-Path $TmpDir ("game-$AppId-expanded") Remove-Item -LiteralPath $gd -Recurse -Force -ErrorAction SilentlyContinue # 解包失败(下载到的不是合法 zip,比如反爬 HTML 页)不能冒充"清单缺失"—— # 落到备用 pin 路径继续,把真实原因写清楚。 $expanded = $false try { Expand-Archive -LiteralPath $gz -DestinationPath $gd -Force $expanded = $true } catch { Warn " game $AppId pack download is not a valid zip (下载到的包损坏/不是 zip): $($_.Exception.Message)" } if ($expanded) { $gameDir = Join-Path $gd "games\$AppId" foreach ($f in @(Get-ChildItem -LiteralPath $gameDir -File -ErrorAction SilentlyContinue)) { if ($f.Extension -ne '.manifest') { continue } Copy-Item -LiteralPath $f.FullName -Destination (Join-Path $DepotDir $f.Name) -Force $packFiles += $f.Name Say " manifest $($f.Name)" } $packFiles = @($packFiles | Sort-Object -Unique) $packProcessed = $true } } else { Warn " pack $AppId download failed; trying effective and fallback manifest pins" } } } # 分级需求 v3:激活以服务器交付的 lua 为准。 # 硬需求 = windows/无平台标记 且非 DLC 的 pin; # 其他平台(linux/macos)pin = 跳过(本机永远用不到); # DLC pin = 尽力拉取,缺失只警告不失败(本体可正常游玩)。 $depotMeta = @{} foreach ($text in @($LuaTexts)) { if ([string]::IsNullOrWhiteSpace($text)) { continue } $inDlc = $false foreach ($line in ($text -split "`r?`n")) { if ($line -match '^\s*--\s*Main Depots') { $inDlc = $false; continue } if ($line -match '^\s*--\s*DLC') { $inDlc = $true; continue } if ($line -match '^\s*addappid\s*\(\s*(\d+)') { $dd = $Matches[1] if (-not $depotMeta.ContainsKey($dd)) { $plat = '' if ($line -match '--.*?\((linux|windows|macos)\)') { $plat = $Matches[1] } $depotMeta[$dd] = @{ plat = $plat; dlc = $inDlc } } } } } $softPlat = @{}; $softDlc = @{} $serverPins = @() if ($ServerLua) { $serverPins = @(Get-LuaManifestNames -Texts @($ServerLua) -IncludeCommentedPins) } $hard = @{} if ($ServerLua -and $serverPins.Count -gt 0) { foreach ($n in $serverPins) { $dd = ($n -split '_')[0] $meta = $depotMeta[$dd] if ($meta -and $meta.plat -and $meta.plat -ne 'windows') { $softPlat[$n] = $true; continue } if ($meta -and $meta.dlc) { $softDlc[$n] = $true; continue } $hard[$n] = $true } } else { foreach ($n in $activePins) { $hard[$n] = $true } if (-not $packProcessed) { foreach ($n in $allDeclaredPins) { $hard[$n] = $true } } } $legacyPins = @($allDeclaredPins + $activePins | Where-Object { -not $hard.ContainsKey($_) -and -not $softPlat.ContainsKey($_) } | Sort-Object -Unique) $pinNames = @($hard.Keys | Sort-Object) $downloaded = New-Object 'System.Collections.Generic.List[string]' $unavailable = New-Object 'System.Collections.Generic.List[string]' $netFails = New-Object 'System.Collections.Generic.List[string]' foreach ($name in @(@($pinNames + $legacyPins + @($softDlc.Keys)) | Sort-Object -Unique)) { if (Test-Path -LiteralPath (Join-Path $DepotDir $name)) { continue } $tmpName = Join-Path $TmpDir ($name + '.part') $got = $false foreach ($try in 1..3) { if (Get-UrlToFile -Url ("{0}/mf/{1}" -f $Base, $name) -Out $tmpName) { $got = $true; break } Start-Sleep -Seconds (2 * $try) } if ($got) { Move-Item -LiteralPath $tmpName -Destination (Join-Path $DepotDir $name) -Force [void]$downloaded.Add($name) Say " manifest $name (inline)" } else { Remove-Item -LiteralPath $tmpName -Force -ErrorAction SilentlyContinue if ($softDlc.ContainsKey($name)) { Warn " manifest $name skipped (DLC 清单服务器暂缺,不影响游戏本体;DLC/原声带内容可能不完整)" continue } if ($legacyPins -contains $name) { Warn " manifest $name skipped (本地旧版 lua 的旧世代清单,服务器已不提供;激活以服务器交付为准)" continue } # 区分根因:404 = 服务器真缺清单;其它 = 网络传输失败(重跑脚本即可续传) $status = 0 try { $wr = [System.Net.WebRequest]::Create(("{0}/mf/{1}" -f $Base, $name)) $wr.Method = 'HEAD'; $wr.Timeout = 15000 $resp = $wr.GetResponse(); $status = [int]$resp.StatusCode; $resp.Close() } catch [System.Net.WebException] { if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode } } if ($status -eq 404) { [void]$unavailable.Add($name) Warn " manifest $name MISSING on server (服务器清单缺失,不是网络问题)" } else { [void]$netFails.Add($name) Warn " manifest $name download failed (网络传输失败,重跑本脚本自动续传): HTTP=$status" } } } $requiredFiles = @($packFiles + $pinNames | Sort-Object -Unique) if ($requiredFiles.Count -gt 0) { $payload = [ordered]@{ appid = $AppId sha256 = if ($PackEntry) { [string]$PackEntry.sha256 } else { '' } packFiles = @($packFiles) files = @($requiredFiles) downloaded = @($downloaded) updated = (Get-Date).ToString('o') } Set-Content -LiteralPath $mark -Value ($payload | ConvertTo-Json -Depth 5) -Encoding UTF8 } $script:LastFailKind = '' if ($unavailable.Count -gt 0) { $script:LastFailKind = 'missing' } elseif ($netFails.Count -gt 0) { $script:LastFailKind = 'net' } if ($script:LastFailKind) { if (-not $script:FailKinds) { $script:FailKinds = @{} } $script:FailKinds[$script:LastFailKind] = $true } return ($requiredFiles.Count -gt 0 -and (Test-ManifestInventory -DepotDir $DepotDir -Files $requiredFiles)) } Say 'OpenSteamTool deployer (thin front + netdisk payload)' $steam = Resolve-SteamPath -Hint $SteamPath if (-not $steam) { Die 'Steam not found. Use -SteamPath "D:\Steam".' } Say "Steam: $steam" # Steam under "C:\Program Files (x86)" needs admin to drop the DLLs; a standard # shell would get "access denied" halfway through, leaving a half-deployed kit. # Detect it up-front and hand off to an elevated run instead of failing mid-way. if ((-not $NoStart) -and (-not (Test-Elevated)) -and (-not (Test-DirWritable $steam))) { Warn "Steam directory is not writable ($steam); requesting elevation..." Warn 'Steam dir is not writable -> requesting elevation...' if (Restart-Elevated) { Say 'Continuing in the administrator window; this window can be closed.'; exit 0 } Warn 'Elevation was cancelled or failed; continuing (likely to fail).' } $hwid = Get-Hwid # ---- activation is the engine's job (Steam native dialog) ------------------ # No key is required to deploy. Pass -Code only to pre-bind this machine, or # -RequireKey if you deliberately want the old install-time gate. $licPath = Join-Path $steam 'opensteamtool\license.json' if ($Code) { Say 'Pre-binding this machine...' $r = Post-Json "$Base/api/redeem" @{ code = $Code; hwid = $hwid } if ($r -and $r.ok) { New-Item -ItemType Directory -Force -Path (Split-Path $licPath) | Out-Null @{ code = $r.code; hwid = $hwid; token = $r.token; expires = $r.expires } | ConvertTo-Json | Set-Content -Path $licPath -Encoding UTF8 Say "bound: $($r.code)" } else { Warn "code not bound: $(if($r){$r.error}else{'network'}) (install continues; activate in Steam with a card key)" } } elseif ($RequireKey) { $ok = $false if (Test-Path $licPath) { try { $lic = Get-Content $licPath -Raw | ConvertFrom-Json if ($lic.code) { $v = Post-Json "$Base/api/verify" @{ code = $lic.code; hwid = $hwid } if ($v -and $v.ok) { Say "license OK ($($lic.code))"; $ok = $true } else { Warn "saved license rejected: $($v.error)" } } } catch { } } if (-not $ok) { if (-not $Code) { Write-Host ''; $Code = (Read-Host 'Enter activation code (KAMI)').Trim() } if (-not $Code) { Die 'no activation code (-RequireKey)' } $r = Post-Json "$Base/api/redeem" @{ code = $Code; hwid = $hwid } if (-not $r -or -not $r.ok) { Die "activation failed: $(if($r){$r.error}else{'network'})" } New-Item -ItemType Directory -Force -Path (Split-Path $licPath) | Out-Null @{ code = $r.code; hwid = $hwid; token = $r.token; expires = $r.expires } | ConvertTo-Json | Set-Content -Path $licPath -Encoding UTF8 Say "activated: $($r.code)" } } else { Say 'no key needed: activate in Steam with your card key' } $wasRunning = @(Get-Process steam -ErrorAction SilentlyContinue).Count -gt 0 if ($wasRunning) { Say 'Stopping Steam...' # $ErrorActionPreference='Stop' turns a refused Stop-Process ("access denied": # non-elevated shell, or Steam launched elevated) into a TERMINATING error that # aborts the whole deploy before any file is touched. Suppress it, then fall # back to taskkill (tears down our own process tree incl. steamwebhelper). try { Get-Process steam -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue } catch { } Start-Sleep 5 if (@(Get-Process steam -ErrorAction SilentlyContinue).Count -gt 0) { try { & taskkill.exe /F /T /IM steam.exe 2>$null | Out-Null } catch { } Start-Sleep 3 } if (@(Get-Process steam -ErrorAction SilentlyContinue).Count -gt 0) { if ((-not $NoStart) -and (-not (Test-Elevated))) { Warn 'Steam refused to close (needs admin) -> requesting elevation...' Warn 'Steam refused to close (needs admin) -> requesting elevation...' if (Restart-Elevated) { Say 'Continuing in the administrator window; this window can be closed.'; exit 0 } Warn 'Elevation was cancelled or failed.' } Warn 'Steam did not close automatically. Nothing was changed.' Warn 'Fully exit Steam (tray -> Exit) and re-run, or run this from an elevated PowerShell.' Warn 'Steam is still running and could not be stopped. Nothing was changed.' Warn 'Fully exit Steam (tray -> Exit) and re-run, or run this from an elevated PowerShell.' Die 'Steam is still running; deployment aborted.' } } New-Item -ItemType Directory -Force -Path $Tmp | Out-Null $distCb = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() # Path cache-buster: some CDN configurations ignore query strings when caching. $dist = Invoke-RestMethod "$Base/api/dist/cb-$distCb" -Headers @{ 'Cache-Control' = 'no-cache'; 'Pragma' = 'no-cache' } -TimeoutSec 60 $kitZip = Join-Path $Tmp 'kit.zip' Say "Downloading kit from mirror ($([math]::Round($dist.kit.size/1MB,2)) MB)" if (-not (Get-Mirrored -Name 'kit' -Dest $kitZip -Sha $dist.kit.sha256 -Size $dist.kit.size -Entry $dist.kit)) { Die 'kit download failed' } $kitDir = Join-Path $Tmp 'kit' Expand-Archive -LiteralPath $kitZip -DestinationPath $kitDir -Force $backup = Join-Path $steam "opensteamtool\_backup_$Stamp" New-Item -ItemType Directory -Force -Path $backup | Out-Null # Remove old engine names (opensteamtools.dll / early OpenSteamTool.dll). # The old 129 core ships hid.dll + version.dll + versions.dll (+ opensteamtools.dll). # If any stays in the Steam root it hijacks the loader and OUR engine never loads # -> activation "succeeds" but nothing is written / nothing downloads. # Archive everything conflicting (never delete) so it is fully reversible. function Remove-Conflicts { param([string]$Root, [string]$Backup) $rel = @( 'hid.dll', 'version.dll', 'versions.dll', 'opensteamtools.dll', 'OpenSteamTool.dll', 'steamclient_loader.dll', 'diversion.dll', 'bin\diversion.dll', 'winmm.dll', 'zlib1.dll', '7z.dll', 'dinput8.dll', 'msimg32.dll', 'dbghelp.dll', 'xinput9_1_0.dll', 'core.dll', 'Core', 'CoreBeta', 'diversion', 'bin\diversion', 'config\stUI', 'config\steamunlocked', 'config\greenluma', 'steam_emu.ini', 'steam.cfg', 'cdk.exe', 'f1.exe', 'emu.cfg', 'local.vdf.bak' ) $moved = New-Object 'System.Collections.Generic.List[string]' $locked = New-Object 'System.Collections.Generic.List[string]' foreach ($r in $rel) { $p = Join-Path $Root $r if (-not (Test-Path -LiteralPath $p)) { continue } try { $fi = Get-Item -LiteralPath $p -Force -ErrorAction SilentlyContinue; if ($fi -and -not $fi.PSIsContainer -and $fi.IsReadOnly) { $fi.IsReadOnly = $false } } catch {} try { New-Item -ItemType Directory -Force -Path $Backup | Out-Null $dest = Join-Path $Backup ($r -replace '[\\/]', '_') Move-Item -LiteralPath $p -Destination $dest -Force -ErrorAction Stop try { Add-Content -LiteralPath (Join-Path $Backup 'restore.map') -Value ($r + '|' + ($r -replace '[\\/]', '_')) -Encoding UTF8 } catch {} [void]$moved.Add($r) } catch { try { Remove-Item -LiteralPath $p -Recurse -Force -ErrorAction Stop; [void]$moved.Add($r + '(rm)') } catch { [void]$locked.Add($r) } } } # stale registry leftovers from the 129 / SteamTools kit foreach ($rk in @('HKCU:\Software\SteamTools','HKLM:\Software\SteamTools', 'HKCU:\Software\Valve\Steamtools','HKLM:\SOFTWARE\WOW6432Node\Valve\Steamtools')) { if (Test-Path $rk) { try { Remove-Item -Path $rk -Recurse -Force -ErrorAction Stop } catch { [void]$locked.Add($rk) } } } return @{ moved = $moved; locked = $locked } } $cf = Remove-Conflicts -Root $steam -Backup $backup foreach ($m in $cf.moved) { Say " cleaned conflict $m" } if ($cf.locked.Count -gt 0) { foreach ($l in $cf.locked) { Warn " LOCKED conflict: $l" } Warn "some conflicting files are locked - close any other unlock tool and re-run" } foreach ($f in Get-ChildItem (Join-Path $kitDir 'kit') -File) { if ($f.Extension -ne '.dll') { continue } $dest = Join-Path $steam $f.Name if (Test-Path $dest) { Copy-Item $dest (Join-Path $backup $f.Name) -Force } Copy-Item $f.FullName $dest -Force Say " placed $($f.Name)" } $kitMarkDir = Join-Path $steam 'opensteamtool\synced' New-Item -ItemType Directory -Force -Path $kitMarkDir | Out-Null Set-Content -LiteralPath (Join-Path $kitMarkDir 'kit') -Value $dist.kit.sha256 -Encoding ASCII Say 'recorded kit version' # --- watchdog: self-heal + auto-update the kit -------------------------- try { $watchDst = Join-Path $steam 'ostm_watch.ps1' try { Invoke-WebRequest -Uri "$Base/ost/ostm_watch.ps1" -OutFile $watchDst -UseBasicParsing -TimeoutSec 60 } catch { Warn (' watchdog download failed: ' + $_.Exception.Message) } if (Test-Path -LiteralPath $watchDst) { $vbsDst = Join-Path $steam 'ostm_launch.vbs' $vbsText = "' OST watchdog launcher" + "`r`n" + 'Option Explicit' + "`r`n" + 'Dim sh' + "`r`n" + 'Set sh = CreateObject("WScript.Shell")' + "`r`n" + 'sh.Run """C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"" -NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File ""' + $watchDst + '""", 0, False' + "`r`n" Set-Content -LiteralPath $vbsDst -Value $vbsText -Encoding ASCII $taskOk = $false # v2: always recreate so the cadence/limits stay sane. The old 1-minute # trigger + while($true) watchdog stacked instances -> popup storm + a # locked ostm_watch.ps1 that blocked Steam uninstall. try { schtasks /Delete /TN SteamManifestSync /F *> $null } catch {} try { schtasks /Create /TN SteamManifestSync /SC MINUTE /MO 5 /RL LIMITED /TR ('wscript.exe //nologo //B "' + $vbsDst + '"') /F *> $null; $taskOk = ($LASTEXITCODE -eq 0) } catch {} if (-not $taskOk) { try { New-Item -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Force | Out-Null Set-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\CurrentVersion\Run' -Name 'SteamManifestSync' -Value ('wscript.exe //nologo //B "' + $vbsDst + '"') $taskOk = $true } catch {} } Say (' watchdog installed (autostart ' + $(if ($taskOk) { 'ok' } else { 'FAILED' }) + ')') } } catch { Warn (' watchdog setup failed: ' + $_.Exception.Message) } $toml = @( 'PIPEsec_log' 'level = "debug"' '' 'PIPEsec_manifest' 'url = "wudrm"' 'timeout_resolve_ms = 5000' 'timeout_connect_ms = 15000' 'timeout_send_ms = 10000' 'timeout_recv_ms = 30000' '' 'PIPEsec_stats' 'enable_api = true' '' 'PIPEsec_lua' 'paths = [ "config/stplug-in"]' '' 'PIPEsec_inject' 'enabled = false' '' 'PIPEsec_remote' 'url_template = "https://steamidcs.top/ost/spec/{channel}/{component}/{sha256}.toml"' ) $txt = ($toml -join "`n").Replace('PIPEsec_log', '[log]').Replace('PIPEsec_manifest', '[manifest]').Replace('PIPEsec_stats', '[stats]').Replace('PIPEsec_lua', '[lua]').Replace('PIPEsec_inject', '[inject]').Replace('PIPEsec_remote', '[remote]') Set-Content -Path (Join-Path $steam 'opensteamtool.toml') -Value $txt -Encoding UTF8 Say 'wrote opensteamtool.toml' if ($Game) { $key = "game-$Game" New-Item -ItemType Directory -Force -Path (Join-Path $steam 'config\lua') | Out-Null $luaDst = Join-Path $steam "config\lua\$Game.lua" $packLua = $null if ($dist.$key) { Say "Downloading game pack $Game (netdisk)" $gz = Join-Path $Tmp "game-$Game.zip" if (Get-Mirrored -Name $key -Dest $gz -Sha $dist.$key.sha256 -Size $dist.$key.size -Entry $dist.$key) { $gdir = Join-Path $Tmp "game-$Game" Expand-Archive -LiteralPath $gz -DestinationPath $gdir -Force New-Item -ItemType Directory -Force -Path (Join-Path $steam 'depotcache') | Out-Null foreach ($f in Get-ChildItem (Join-Path $gdir "games\$Game") -File) { if ($f.Name -eq 'pack.json') { continue } if ($f.Extension -eq '.lua') { $packLua = $f.FullName Say " lua $($f.Name) (pack candidate)" } else { Copy-Item $f.FullName (Join-Path $steam "depotcache\$($f.Name)") -Force Say " manifest $($f.Name)" } } } else { Warn " pack download failed -> inline lua fallback" } } if (-not (Test-Path $luaDst)) { if ($packLua -and (Copy-Item -LiteralPath $packLua -Destination $luaDst -Force -PassThru -ErrorAction SilentlyContinue)) { Say " lua $Game.lua (pack)" } else { Say "Fetching lua for $Game (inline, no netdisk)" if (Get-UrlToFile -Url "$Base/api/game/$Game.lua" -Out $luaDst) { Say " lua $Game.lua (inline)" } else { Die "Game $Game unavailable (no published pack and no inline lua)" } } } else { Say " kept existing lua $Game.lua (no overwrite)" } } # ---- self-heal: place manifests for every game already in the library -------- # Activation only writes the lua; a title whose depot needs a pinned manifest # also needs the .manifest dropped into depotcache. Sync only the manifest set # declared by that app's server lua/published pack. Never refresh every Lua # merely because it mentions a 22898x id and never overwrite a working source. $luaDir = Join-Path $steam 'config\lua' $depotDir = Join-Path $steam 'depotcache' $manifestFailures = New-Object 'System.Collections.Generic.List[string]' $script:FailKinds = @{} if (Test-Path $luaDir) { New-Item -ItemType Directory -Force -Path $depotDir | Out-Null $markDir = Join-Path $steam 'opensteamtool\synced' New-Item -ItemType Directory -Force -Path $markDir | Out-Null $repairBackup = Join-Path $backup 'lua_shared_repair' foreach ($lf in (Get-ChildItem -LiteralPath $luaDir -Filter *.lua -File -ErrorAction SilentlyContinue)) { if ($lf.BaseName -notmatch '^\d+$') { continue } $id = $lf.BaseName $localLua = Get-Content -LiteralPath $lf.FullName -Raw -ErrorAction SilentlyContinue $serverLua = $null $serverTmp = Join-Path $Tmp ("server-$id.lua") try { if (Get-UrlToFile -Url ("{0}/api/game/{1}.lua" -f $Base, $id) -Out $serverTmp) { $candidate = Get-Content -LiteralPath $serverTmp -Raw -ErrorAction SilentlyContinue if ($candidate -match ("(?m)^\s*addappid\s*\(\s*{0}(?:\s*,|\s*\))" -f [regex]::Escape($id))) { $serverLua = $candidate } } } catch { Warn " lua metadata $id failed: $($_.Exception.Message)" } if ($serverLua) { try { if (Repair-LuaSharedBlock -Path $lf.FullName -ServerText $serverLua -BackupDir $repairBackup) { Say " repaired shared-depot mapping for $id (original backed up)" $localLua = Get-Content -LiteralPath $lf.FullName -Raw } } catch { Warn " shared repair $id failed: $($_.Exception.Message)" } } $texts = @() if ($localLua) { $texts += $localLua } if ($serverLua) { $texts += $serverLua } $entry = $null $pk = "game-$id" if ($dist.PSObject.Properties.Name -contains $pk) { $entry = $dist.$pk } try { $ok = Install-AppManifests -AppId $id -LuaTexts $texts -PackEntry $entry -TmpDir $Tmp -DepotDir $depotDir -MarkDir $markDir -ServerLua $serverLua if (-not $ok) { [void]$manifestFailures.Add($id) if ($script:LastFailKind -eq 'net') { Warn " 游戏 $id 清单拉取失败 —— 是网络传输问题(服务器上文件齐全),重跑本脚本自动续传即可" } else { Warn " 游戏 $id 清单不全 —— 激活会卡在 Steam「重试」页(原因:服务器清单缺失,不是网络问题)" } } } catch { Warn " manifest sync $id failed: $($_.Exception.Message)" } } } if ($manifestFailures.Count -gt 0) { Write-Host '' $aggNetOnly = ($script:FailKinds) -and ($script:FailKinds.ContainsKey('net')) -and (-not $script:FailKinds.ContainsKey('missing')) if ($aggNetOnly) { Write-Host '[ost] ========== 清单拉取失败(网络原因)=========' -ForegroundColor Yellow Write-Host '[ost] 服务器上这些文件是齐全的,只是下载中途断了:' -ForegroundColor Yellow foreach ($bad in $manifestFailures) { Write-Host ("[ost] - 游戏 {0}" -f $bad) -ForegroundColor Yellow } Write-Host ('[ost] DOWNLOAD FAILED (network) for game(s): ' + ($manifestFailures -join ', ') + ' -- just RE-RUN this script, it will resume') -ForegroundColor Yellow Write-Host '[ost] 不是服务器缺清单、不用联系客服 —— 重新运行本脚本即可自动续传。' -ForegroundColor Yellow Write-Host '[ost] ==============================================' -ForegroundColor Yellow exit 1 } Write-Host '[ost] ========== 激活会失败:游戏清单缺失 ==========' -ForegroundColor Red Write-Host '[ost] 以下游戏的清单文件在服务器上缺失(不是网络问题):' -ForegroundColor Red foreach ($bad in $manifestFailures) { Write-Host ("[ost] - 游戏 {0}" -f $bad) -ForegroundColor Red } Write-Host ("[ost] MISSING MANIFESTS for game(s): " + ($manifestFailures -join ", ") + " -- send this screenshot to support") -ForegroundColor Red Write-Host '[ost] 现在激活这些游戏会卡在 Steam 的「重试」页,点重试不会成功。' -ForegroundColor Red Write-Host '[ost] 请截图联系客服在服务器补齐清单,再重新运行本脚本。' -ForegroundColor Red Write-Host '[ost] ==============================================' -ForegroundColor Red exit 1 } Remove-Item $Tmp -Recurse -Force -ErrorAction SilentlyContinue Say "backup: $backup" if (-not $NoStart) { Say 'Starting Steam...'; Start-Process (Join-Path $steam 'steam.exe') -WorkingDirectory $steam } Say 'Done.'