#Requires -Version 5.1 param( [Parameter(Mandatory = $false)] [string]$GameRoot ) $ErrorActionPreference = 'Stop' if (-not $PSBoundParameters.ContainsKey('GameRoot') -or [string]::IsNullOrWhiteSpace($GameRoot)) { $GameRoot = (Get-Location).ProviderPath } else { $GameRoot = (Resolve-Path -LiteralPath $GameRoot).ProviderPath } $PatchBundleRoot = $PSScriptRoot function Wait-ConsolePause { Write-Host '' Write-Host 'Press any key to close this window...' try { $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') } catch { Read-Host 'Press Enter to close' } } function Get-DazedPatcherBannerGlyphs { return @{ 'A' = @(' ', ' AAA ', ' A A ', ' AAAAA ', ' A A ', ' A A ', ' ') 'C' = @(' ', ' CCCC ', ' C ', ' C ', ' C ', ' CCCC ', ' ') 'D' = @(' ', ' DDDDD ', ' D D ', ' D D ', ' D D ', ' DDDDD ', ' ') 'E' = @(' ', ' EEEEE ', ' E ', ' EEE ', ' E ', ' EEEEE ', ' ') 'H' = @(' ', ' H H ', ' H H ', ' HHHHH ', ' H H ', ' H H ', ' ') 'P' = @(' ', ' PPPPP ', ' P P ', ' PPPP ', ' P ', ' P ', ' ') 'R' = @(' ', ' RRRRR ', ' R R ', ' RRRR ', ' R R ', ' R R ', ' ') 'T' = @(' ', ' TTTTT ', ' T ', ' T ', ' T ', ' T ', ' ') 'Z' = @(' ', ' ZZZZZ ', ' Z ', ' Z ', ' Z ', ' ZZZZZ ', ' ') } } function Build-AsciiWordLines { param( [Parameter(Mandatory = $true)][string]$Word, [hashtable]$Glyphs ) $letters = @( $Word.ToUpperInvariant().ToCharArray() | ForEach-Object { [string]$_ } ) $rows = New-Object string[] 7 for ($r = 0; $r -lt 7; $r++) { $parts = New-Object System.Collections.Generic.List[string] foreach ($letter in $letters) { if (-not $Glyphs.ContainsKey($letter)) { throw "Banner glyph missing for '$letter'." } $parts.Add($Glyphs[$letter][$r]) } $rows[$r] = ($parts -join ' ') } return [string[]]$rows } function ConvertTo-LineStringArray { param([AllowNull()][object]$Value) if ($null -eq $Value) { return @() } if ($Value -is [string[]]) { return $Value } if ($Value -is [System.Collections.Generic.List[string]]) { return $Value.ToArray() } if ($Value -is [string]) { if ([string]::IsNullOrWhiteSpace([string]$Value)) { return @() } return ,[string]$Value } return [string[]](@($Value) | ForEach-Object { if ($null -eq $_) { '' } else { "$_" } }) } function Pad-LineBlockToHeight { param( [Parameter(Mandatory = $true, Position = 0)][object]$Rows, [Parameter(Mandatory = $true, Position = 1)][int]$TargetHeight ) [string[]]$lines = ConvertTo-LineStringArray -Value $Rows if ($lines.Count -eq 0 -and $TargetHeight -gt 0) { $blank = New-Object string[] $TargetHeight for ($i = 0; $i -lt $TargetHeight; $i++) { $blank[$i] = '' } return $blank } if ($lines.Count -ge $TargetHeight) { return [string[]]$lines } $padTotal = $TargetHeight - $lines.Count $padTop = [int][Math]::Floor($padTotal / 2) $padBot = $padTotal - $padTop $top = @() for ($i = 0; $i -lt $padTop; $i++) { $top += '' } $bot = @() for ($i = 0; $i -lt $padBot; $i++) { $bot += '' } return [string[]]($top + $lines + $bot) } function Get-EmbeddedIconAsciiCatLines { # Refresh via generate_icon_ascii.ps1 when assets/icon.png changes. $raw = @' # %# %:.-@ #..= #:.-*:.:# *..=*..-% *=.:.+# %% @*-:::+# *-# +...=# =-% % #..==..:# %@ %..* -.:%%#=..=##*##%% %*=:...+ *:.=+.:= ..=%##%*:..::.....:=+:..-=*:.: *=:--.=* ..=%###%########*+=:.-+#%%%:.- -:% #..=%###%%%%%###%%%%%#%%%#%*..+ %..=%##-.=*=-*%#####%%%%%#%#:.: +.:###%+....:*%###:.=*=-*%%-..% -.:%#%+..:..=%####=....-#%%+..% *..*%##**%*-=###%=..:..=%#%*..# =..*%%%%#%%%#####*#%*-+%%%-.. +..-*#%%%%######%%#%%%%*:..# %+...-+*##%%%%%%%%#*+-..=% %..:.....::---::....:*% :.:%%#*+=--::::-=+:.:* %..+%#%%%%%%%%%%%%%%+..-@ #-:::+@ #..+%###############%#:.:% #..=*:..# ..=%################%#:..% %..+%%-..@ %..:%###%#%##%%#%#####%#:.- *..*%*..+ #....*%##-.+%%#:.+%##%#%%*..* :.-%%:.- %..=..-%#%:..#%+..+%#%+::#%:.- :.-%%:.- -.:%=..*%%*..+%:.:%#%#..:#%=.. #..+%*..+ ..=%#..:%%#..-#..=%#%-..*%%=..*:.-%%-..@ -.-%%+..+%%-.:+..+%%+..+%#%-....=%%-..# +..*%*..:%%=..=..*%%:..*%%#...=#%+:.:# ..-**..:+**=..:..+**+:..**+..=*=:..+ +..............................:=# %%%%%%%%%%%%%%%%%%%%%%%%%%%%@ '@ return [string[]]@($raw -split '\r?\n') } function Write-AsciiHeader { try { Write-Host '' $glyphs = Get-DazedPatcherBannerGlyphs $wordDazed = Build-AsciiWordLines -Word 'Dazed' -Glyphs $glyphs $wordPatcher = Build-AsciiWordLines -Word 'Patcher' -Glyphs $glyphs $titleCore = [System.Collections.Generic.List[string]]::new() foreach ($ln in $wordDazed) { $titleCore.Add($ln) } [void]$titleCore.Add('') foreach ($ln in $wordPatcher) { $titleCore.Add($ln) } $titleRows = $titleCore.ToArray() $catLines = @(Get-EmbeddedIconAsciiCatLines) $targetH = [Math]::Max($titleRows.Count, $catLines.Count) $titlePadded = Pad-LineBlockToHeight -Rows $titleRows -TargetHeight $targetH $catPadded = Pad-LineBlockToHeight -Rows $catLines -TargetHeight $targetH $titleWidth = 0 foreach ($ln in $titlePadded) { $safe = if ($null -eq $ln) { '' } else { $ln } if ($safe.Length -gt $titleWidth) { $titleWidth = $safe.Length } } $gap = ' ' for ($i = 0; $i -lt $targetH; $i++) { $ti = if ($null -eq $titlePadded[$i]) { '' } else { $titlePadded[$i] } $ci = if ($catPadded.Length -le $i -or $null -eq $catPadded[$i]) { '' } else { $catPadded[$i] } $left = if ($titleWidth -gt 0) { $ti.PadRight($titleWidth) } else { '' } Write-Host ($left + $gap + $ci) } Write-Host '' } catch { Write-Host ('[Banner skipped] ' + $_.Exception.Message) } } function Write-BannerWrongFolder { Write-Host '' Write-Host '========================================' Write-Host 'ERROR: Wrong game root folder!' Write-Host '========================================' Write-Host '' Write-Host 'Game root cannot be the gameupdate folder itself.' Write-Host 'Run GameUpdate.bat from the game root (same folder as GameUpdate.bat).' Write-Host '========================================' Write-Host '' } function Test-WrongWorkingDirectory { param([string]$Root) return ($Root -and ((Split-Path -Leaf $Root) -ieq 'gameupdate')) } function Read-PatchConfig { param([string]$ConfigPath) $cfg = @{ forge = 'gitlab' host = '' username = '' repo = '' branch = '' } if (-not (Test-Path -LiteralPath $ConfigPath)) { return $cfg } foreach ($rawLine in Get-Content -LiteralPath $ConfigPath) { $line = $rawLine.Trim() if (-not $line -or $line.StartsWith('#')) { continue } $eq = $line.IndexOf('=') if ($eq -lt 1) { continue } $k = $line.Substring(0, $eq).Trim().ToLowerInvariant() $v = if ($eq -lt $line.Length - 1) { $line.Substring($eq + 1).Trim() } else { '' } switch ($k) { 'forge' { $cfg.forge = $v } 'provider' { $cfg.forge = $v } 'host' { $cfg.host = $v } 'username' { $cfg.username = $v } 'owner' { $cfg.username = $v } 'org' { $cfg.username = $v } 'repo' { $cfg.repo = $v } 'branch' { $cfg.branch = $v } } } return $cfg } function Resolve-PatchForge { param([Parameter(Mandatory = $true)][hashtable]$Cfg) $raw = ([string]$Cfg.forge).Trim().ToLowerInvariant() if ([string]::IsNullOrWhiteSpace($raw)) { $raw = 'gitlab' } $kind = switch -Regex ($raw) { '^(gitlab|gl|gitgud)$' { 'gitlab' } '^(github|gh)$' { 'github' } '^(forgejo|gitea|fj|codeberg)$' { 'forgejo' } default { $null } } if (-not $kind) { throw ("PATCH_ERR:CONFIG:Unknown forge='{0}'. Use gitlab, github, or forgejo." -f $Cfg.forge) } $hostName = ([string]$Cfg.host).Trim() $hostName = $hostName -replace '^https?://', '' $hostName = $hostName.TrimEnd('/') if ($hostName.Contains('/')) { $hostName = $hostName.Split('/')[0] } if ([string]::IsNullOrWhiteSpace($hostName)) { $hostName = switch ($kind) { 'gitlab' { 'gitgud.io' } 'github' { 'github.com' } 'forgejo' { 'codeberg.org' } } } $webBase = "https://$hostName" $apiBase = switch ($kind) { 'gitlab' { "$webBase/api/v4" } 'github' { if ($hostName -eq 'github.com') { 'https://api.github.com' } else { "$webBase/api/v3" } } 'forgejo' { "$webBase/api/v1" } } return @{ kind = $kind host = $hostName webBase = $webBase apiBase = $apiBase username = ([string]$Cfg.username).Trim() repo = ([string]$Cfg.repo).Trim() branch = ([string]$Cfg.branch).Trim() } } function New-PatchApiHeaders { param([Parameter(Mandatory = $true)][hashtable]$Resolved) $headers = @{ 'User-Agent' = 'DazedMTL-Patcher-1.0' } if ($Resolved.kind -eq 'github') { $headers['Accept'] = 'application/vnd.github+json' } return $headers } function Get-PatchLatestSha { param( [Parameter(Mandatory = $true)][hashtable]$Resolved, [Parameter(Mandatory = $true)][hashtable]$Headers ) $ownerEnc = [uri]::EscapeDataString($Resolved.username) $repoEnc = [uri]::EscapeDataString($Resolved.repo) $branchEnc = [uri]::EscapeDataString($Resolved.branch) switch ($Resolved.kind) { 'gitlab' { $id = [uri]::EscapeDataString($Resolved.username + '/' + $Resolved.repo) $uri = "$($Resolved.apiBase)/projects/$id/repository/branches/$branchEnc" return [string](Invoke-RestMethod -Uri $uri -Headers $Headers).commit.id } 'github' { $uri = "$($Resolved.apiBase)/repos/$ownerEnc/$repoEnc/commits/$branchEnc" return [string](Invoke-RestMethod -Uri $uri -Headers $Headers).sha } 'forgejo' { $uri = "$($Resolved.apiBase)/repos/$ownerEnc/$repoEnc/branches/$branchEnc" return [string](Invoke-RestMethod -Uri $uri -Headers $Headers).commit.id } } } function Get-PatchArchiveUrl { param( [Parameter(Mandatory = $true)][hashtable]$Resolved, [Parameter(Mandatory = $true)][string]$Sha ) $ownerEnc = [uri]::EscapeDataString($Resolved.username) $repoEnc = [uri]::EscapeDataString($Resolved.repo) $shaEnc = [uri]::EscapeDataString($Sha) switch ($Resolved.kind) { 'gitlab' { $id = [uri]::EscapeDataString($Resolved.username + '/' + $Resolved.repo) return "$($Resolved.apiBase)/projects/$id/repository/archive.zip?sha=$shaEnc" } 'github' { return "$($Resolved.apiBase)/repos/$ownerEnc/$repoEnc/zipball/$shaEnc" } 'forgejo' { return "$($Resolved.apiBase)/repos/$ownerEnc/$repoEnc/archive/$shaEnc.zip" } } } function Invoke-SrpgUnpacker { param( [Parameter(Mandatory = $true)][string]$ExePath, [Parameter(Mandatory = $true)][string]$WorkingDirectory, [Parameter(Mandatory = $true)][string[]]$Arguments ) Push-Location -LiteralPath $WorkingDirectory try { & $ExePath @Arguments return $LASTEXITCODE } finally { Pop-Location } } function Test-WolfLooseDataReady { param([Parameter(Mandatory = $true)][string]$DataDir) if (-not (Test-Path -LiteralPath $DataDir -PathType Container)) { return $false } $markers = @( (Join-Path $DataDir 'BasicData\CommonEvent.dat'), (Join-Path $DataDir 'CommonEvent.dat') ) foreach ($marker in $markers) { if (Test-Path -LiteralPath $marker -PathType Leaf) { return $true } } if (@(Get-ChildItem -LiteralPath $DataDir -Filter '*.mps' -File -ErrorAction SilentlyContinue).Count -gt 0) { return $true } $basic = Join-Path $DataDir 'BasicData' if (Test-Path -LiteralPath $basic -PathType Container) { if (@(Get-ChildItem -LiteralPath $basic -Filter '*.dat' -File -ErrorAction SilentlyContinue).Count -gt 0) { return $true } } return $false } function Find-UberWolfCli { param([Parameter(Mandatory = $true)][string]$Root) $candidates = @( (Join-Path $Root 'UberWolfCli.exe'), (Join-Path $Root 'gameupdate\UberWolfCli.exe') ) foreach ($path in $candidates) { if (Test-Path -LiteralPath $path -PathType Leaf) { return $path } } return $null } function Invoke-WolfPreSetup { <# WOLF installs often ship Data.wolf, which overrides loose patched Data/ files. Unpack once with UberWolfCli (bundled in the patch repo), then retire Data.wolf so GameUpdate can copy injected .mps/.dat on top. #> param([Parameter(Mandatory = $true)][string]$Root) $dataWolf = $null foreach ($name in @('Data.wolf', 'data.wolf')) { $candidate = Join-Path $Root $name if (Test-Path -LiteralPath $candidate -PathType Leaf) { $dataWolf = $candidate break } } if (-not $dataWolf) { return } $dataDir = Join-Path $Root 'Data' if (-not (Test-Path -LiteralPath $dataDir -PathType Container)) { $dataDirAlt = Join-Path $Root 'data' if (Test-Path -LiteralPath $dataDirAlt -PathType Container) { $dataDir = $dataDirAlt } } if (Test-WolfLooseDataReady -DataDir $dataDir) { $bak = "$dataWolf.bak" if (-not (Test-Path -LiteralPath $bak)) { Write-Host '[Pre-Setup] Loose Data/ already present; retiring Data.wolf -> Data.wolf.bak' Rename-Item -LiteralPath $dataWolf -NewName ([IO.Path]::GetFileName($bak)) -Force } else { Write-Host '[Pre-Setup] Loose Data/ already present; removing leftover Data.wolf' Remove-Item -LiteralPath $dataWolf -Force } return } $cli = Find-UberWolfCli -Root $Root if (-not $cli) { Write-Host '[Pre-Setup] Data.wolf found but UberWolfCli.exe is missing.' Write-Host ' Copy UberWolfCli.exe into the game root (Step 1 gameupdate/) and re-run.' return } Write-Host '[Pre-Setup] Unpacking Data.wolf with UberWolfCli (one-time)...' $code = Invoke-SrpgUnpacker -ExePath $cli -WorkingDirectory $Root -Arguments @('-o', $dataWolf) if ($code -ne 0) { Write-Host ('[Pre-Setup] ERROR: UberWolfCli exited with code {0}.' -f $code) return } # UberWolf writes next to the archive as / (Data/). Also accept data.wolf~ leftovers. $unpacked = Join-Path $Root 'Data' if (-not (Test-Path -LiteralPath $unpacked -PathType Container)) { $unpacked = Join-Path $Root 'data' } if (-not (Test-WolfLooseDataReady -DataDir $unpacked)) { foreach ($alt in @('data.wolf~', 'Data.wolf~', 'Data~', 'data~')) { $altPath = Join-Path $Root $alt if (Test-Path -LiteralPath $altPath -PathType Container) { Write-Host ("[Pre-Setup] Promoting {0} -> Data\" -f $alt) if (Test-Path -LiteralPath (Join-Path $Root 'Data')) { Remove-FileOrDirectoryViaCmd -LiteralPath (Join-Path $Root 'Data') } Rename-Item -LiteralPath $altPath -NewName 'Data' -Force $unpacked = Join-Path $Root 'Data' break } } } if (-not (Test-WolfLooseDataReady -DataDir $unpacked)) { Write-Host '[Pre-Setup] ERROR: Unpack finished but loose Data/ still looks empty.' return } $bak = "$dataWolf.bak" if (Test-Path -LiteralPath $dataWolf -PathType Leaf) { if (-not (Test-Path -LiteralPath $bak)) { Write-Host '[Pre-Setup] Retiring Data.wolf -> Data.wolf.bak' Rename-Item -LiteralPath $dataWolf -NewName ([IO.Path]::GetFileName($bak)) -Force } else { Write-Host '[Pre-Setup] Removing Data.wolf so loose Data/ takes priority' Remove-Item -LiteralPath $dataWolf -Force } } Write-Host '[Pre-Setup] Wolf Data/ is ready for patching.' } function Invoke-SrpgPreSetup { param([string]$Root) $unpacker = Join-Path $Root 'SRPG_Unpacker.exe' $dts = Join-Path $Root 'data.dts' $dataDir = Join-Path $Root 'data' $projectDat = Join-Path $dataDir 'project.dat' $patchDir = Join-Path $Root 'patch' if (-not (Test-Path -LiteralPath $dts)) { return } if (-not (Test-Path -LiteralPath $unpacker)) { Write-Host '[Pre-Setup] SRPG_Unpacker.exe not found; skipping data setup.' return } $shouldUnpack = -not (Test-Path -LiteralPath $dataDir) -or -not (Test-Path -LiteralPath $projectDat) $runUnpackBlock = { if (Test-Path -LiteralPath $dts) { Write-Host '[Pre-Setup] Unpacking data.dts to data\' $code = Invoke-SrpgUnpacker -ExePath $unpacker -WorkingDirectory $Root -Arguments @('-o', 'data', 'data.dts') if ($code -ne 0) { Write-Host '[Pre-Setup] ERROR: Unpack failed. Continuing.' } } else { Write-Host '[Pre-Setup] Skipping unpack: data.dts missing.' } if (-not (Test-Path -LiteralPath $patchDir)) { if (Test-Path -LiteralPath $projectDat) { Write-Host '[Pre-Setup] Creating patch folder from data\project.dat' $code = Invoke-SrpgUnpacker -ExePath $unpacker -WorkingDirectory $Root -Arguments @('.\data\project.dat', '-c') if ($code -ne 0) { Write-Host '[Pre-Setup] ERROR: Create Patch failed. Continuing.' } } else { Write-Host '[Pre-Setup] Skipping create patch: data\project.dat not found.' } } } if ($shouldUnpack) { & $runUnpackBlock } else { if (-not (Test-Path -LiteralPath $patchDir)) { if (Test-Path -LiteralPath $projectDat) { Write-Host '[Pre-Setup] Creating patch folder from data\project.dat' $code = Invoke-SrpgUnpacker -ExePath $unpacker -WorkingDirectory $Root -Arguments @('.\data\project.dat', '-c') if ($code -ne 0) { Write-Host '[Pre-Setup] ERROR: Create Patch failed. Continuing.' } } else { Write-Host '[Pre-Setup] Skipping create patch: data\project.dat not found.' } } } } function Get-EnvInt { param( [Parameter(Mandatory = $true)][string]$Name, [Parameter(Mandatory = $true)][int]$Default ) $raw = [Environment]::GetEnvironmentVariable($Name) if ([string]::IsNullOrWhiteSpace($raw)) { return $Default } $parsed = 0 if ([int]::TryParse([string]$raw, [ref]$parsed) -and $parsed -gt 0) { return $parsed } return $Default } function Invoke-WithRetry { param( [Parameter(Mandatory = $true)][scriptblock]$Action, [Parameter(Mandatory = $true)][string]$Name, [int]$Attempts = 2, [int]$DelaySeconds = 2 ) $lastErr = $null for ($i = 1; $i -le $Attempts; $i++) { try { return & $Action } catch { $lastErr = $_ if ($i -lt $Attempts) { Write-Host ("{0} failed ({1}/{2}), retrying..." -f $Name, $i, $Attempts) Start-Sleep -Seconds $DelaySeconds } } } throw $lastErr } function Get-InvalidZipDownloadHint { param([Parameter(Mandatory = $true)][string]$LiteralPath) $item = Get-Item -LiteralPath $LiteralPath -ErrorAction SilentlyContinue if (-not $item) { return '(downloaded file missing)' } $len = $item.Length $parts = [System.Collections.Generic.List[string]]::new() $parts.Add("File size: $len bytes.") if ($len -eq 0) { $parts.Add('Empty file often means the window was closed during download, the connection dropped, or security software blocked the file. Run again and let it finish.') return ($parts -join ' ') } $peekSize = [Math]::Min([int64]$len, 6144) $buf = New-Object byte[] $peekSize $fs = [System.IO.File]::OpenRead($LiteralPath) try { [void]$fs.Read($buf, 0, $peekSize) } finally { $fs.Dispose() } $text = [System.Text.Encoding]::UTF8.GetString($buf) if ($text -match '(?i)]') { $parts.Add('Body looks like HTML (often an error page or block). Check username/repo/branch in patch-config.txt and network.') } elseif ($text.TrimStart().StartsWith('{')) { try { $j = ($text.TrimStart() | ConvertFrom-Json) if ($j.message) { $parts.Add("GitLab JSON: $($j.message)") } else { $parts.Add('Body starts with JSON (likely an API error, not a ZIP).') } } catch { $parts.Add('Body starts with `{` but was not parseable JSON (truncated or wrong encoding).') } } else { $flat = ($text -replace '[\r\n]+', ' ') $take = [Math]::Min(200, $flat.Length) if ($take -gt 0) { $parts.Add(('UTF-8 preview: ' + $flat.Substring(0, $take))) } } return ($parts -join ' ') } $script:GameUpdateConsoleCloseHelperLoaded = $false function Initialize-GameUpdateConsoleCloseHelper { if ($script:GameUpdateConsoleCloseHelperLoaded) { return } $script:GameUpdateConsoleCloseHelperLoaded = $true $script:GameUpdateConsoleCloseHelperWorks = $false if ($env:OS -ne 'Windows_NT') { return } try { Add-Type -TypeDefinition @' using System; using System.IO; using System.Runtime.InteropServices; namespace GameUpdate { public static class ConsoleCloseHelper { private static HandlerRoutine _handler; public static void Register() { _handler = Handle; SetConsoleCtrlHandler(_handler, true); } public static void Unregister() { if (_handler != null) { SetConsoleCtrlHandler(_handler, false); _handler = null; } } private static bool Handle(uint ctrlType) { const uint CTRL_CLOSE_EVENT = 2; const uint CTRL_LOGOFF_EVENT = 5; const uint CTRL_SHUTDOWN_EVENT = 6; if (ctrlType != CTRL_CLOSE_EVENT && ctrlType != CTRL_LOGOFF_EVENT && ctrlType != CTRL_SHUTDOWN_EVENT) { return false; } try { string path = Environment.GetEnvironmentVariable("GAMEUPDATE_REPO_ZIP_PATH"); if (!string.IsNullOrEmpty(path) && File.Exists(path)) { File.Delete(path); } } catch { } return false; } private delegate bool HandlerRoutine(uint ctrlType); [DllImport("Kernel32.dll", SetLastError = true)] private static extern bool SetConsoleCtrlHandler(HandlerRoutine handler, bool add); } } '@ -ErrorAction Stop $script:GameUpdateConsoleCloseHelperWorks = $true } catch { $script:GameUpdateConsoleCloseHelperWorks = $false } } function Invoke-PatchArchiveDownload { param( [Parameter(Mandatory = $true)][string]$Url, [Parameter(Mandatory = $true)][string]$OutFile, [Parameter(Mandatory = $true)][hashtable]$Headers ) if (Test-Path -LiteralPath $OutFile) { Remove-Item -LiteralPath $OutFile -Force } $outFull = [System.IO.Path]::GetFullPath($OutFile) Initialize-GameUpdateConsoleCloseHelper $prevPref = $ProgressPreference $completed = $false $registeredCloseHandler = $false try { if ($script:GameUpdateConsoleCloseHelperWorks) { $env:GAMEUPDATE_REPO_ZIP_PATH = $outFull [GameUpdate.ConsoleCloseHelper]::Register() $registeredCloseHandler = $true } $ProgressPreference = 'Continue' Invoke-WebRequest -Uri $Url -OutFile $OutFile -Headers $Headers -UseBasicParsing $completed = $true } finally { $ProgressPreference = $prevPref if ($registeredCloseHandler) { try { [GameUpdate.ConsoleCloseHelper]::Unregister() } catch { } Remove-Item env:GAMEUPDATE_REPO_ZIP_PATH -ErrorAction SilentlyContinue } if (-not $completed -and (Test-Path -LiteralPath $OutFile)) { Remove-FileOrDirectoryViaCmd -LiteralPath $OutFile } } } function Remove-FileOrDirectoryViaCmd { param([Parameter(Mandatory = $true)][string]$LiteralPath) if (-not (Test-Path -LiteralPath $LiteralPath)) { return } $full = (Resolve-Path -LiteralPath $LiteralPath).ProviderPath $item = Get-Item -LiteralPath $LiteralPath if ($item.PSIsContainer) { cmd.exe /c "rd /s /q `"$full`"" | Out-Null } else { cmd.exe /c "del /f /q `"$full`"" | Out-Null } } function Remove-DownloadedRepoZip { param([Parameter(Mandatory = $true)][string]$LiteralPath) Remove-FileOrDirectoryViaCmd -LiteralPath $LiteralPath } function Invoke-PatchDownloadExtract { param( [Parameter(Mandatory = $true)][string]$Root, [Parameter(Mandatory = $true)][string]$StateFilePath, [Parameter(Mandatory = $true)][hashtable]$Resolved ) if ($PSVersionTable.PSEdition -ne 'Core') { try { [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 } catch {} } Set-Location -LiteralPath $Root $headers = New-PatchApiHeaders -Resolved $Resolved $dlAttempts = Get-EnvInt -Name 'GAMEUPDATE_DL_ATTEMPTS' -Default 2 $latestSha = Invoke-WithRetry -Name 'Resolve latest patch SHA' -Attempts $dlAttempts -Action { Get-PatchLatestSha -Resolved $Resolved -Headers $headers } if (-not $latestSha) { throw 'PATCH_ERR:API:Latest commit SHA response was empty.' } $latestSha = ([string]$latestSha).Trim() $previousSha = '' if (Test-Path -LiteralPath $StateFilePath) { $previousSha = (Get-Content -LiteralPath $StateFilePath -Raw).Trim() } else { Write-Host 'First run: comparing with remote...' } if ($previousSha -eq $latestSha) { return 10 } $zipPath = Join-Path $Root ('dazedmtl_patch_' + [Guid]::NewGuid().ToString('N') + '.zip') Write-Host ('Downloading patch... ' + $zipPath) $stage = Join-Path ([IO.Path]::GetTempPath()) ('gu_' + [Guid]::NewGuid().ToString('N')) $archiveUrl = Get-PatchArchiveUrl -Resolved $Resolved -Sha $latestSha $dlSw = [System.Diagnostics.Stopwatch]::StartNew() Invoke-WithRetry -Name 'Download archive with Invoke-WebRequest' -Attempts $dlAttempts -Action { Invoke-PatchArchiveDownload -Url $archiveUrl -OutFile $zipPath -Headers $headers } | Out-Null $dlSw.Stop() $bytes = (Get-Item -LiteralPath $zipPath).Length if ($bytes -eq 0) { $hint = Get-InvalidZipDownloadHint -LiteralPath $zipPath Remove-DownloadedRepoZip -LiteralPath $zipPath throw "PATCH_ERR:ZIP:Download is empty (0 bytes). $hint" } $secs = [Math]::Max($dlSw.Elapsed.TotalSeconds, 0.001) $mbps = ($bytes / 1MB) / $secs Write-Host ("Download complete: {0:N1} MB in {1:N1}s ({2:N1} MB/s)" -f ($bytes / 1MB), $secs, $mbps) $zipHeaderBad = $false $zipHeaderHint = '' $fs = [System.IO.File]::OpenRead($zipPath) try { $hdr = New-Object byte[] 4 if ($fs.Read($hdr, 0, 4) -lt 4 -or $hdr[0] -ne 0x50 -or $hdr[1] -ne 0x4B) { $zipHeaderBad = $true $zipHeaderHint = Get-InvalidZipDownloadHint -LiteralPath $zipPath } } finally { $fs.Dispose() } if ($zipHeaderBad) { Remove-DownloadedRepoZip -LiteralPath $zipPath throw "PATCH_ERR:ZIP:Download is not a valid ZIP (missing PK header). $zipHeaderHint" } if (Test-Path -LiteralPath $stage) { Remove-FileOrDirectoryViaCmd -LiteralPath $stage } New-Item -ItemType Directory -Path $stage | Out-Null try { try { Expand-Archive -LiteralPath $zipPath -DestinationPath $stage -Force $dirs = @(Get-ChildItem -LiteralPath $stage -Directory) if ($dirs.Count -ne 1) { throw ('PATCH_ERR:ZIP:Expected one root folder in archive, found {0}.' -f $dirs.Count) } $stagedRoot = $dirs[0].FullName # Stage UberWolfCli into the game root before unpack so first-time # players can convert Data.wolf -> Data/ before English files land. foreach ($cliName in @('UberWolfCli.exe', 'UberWolfCli.LICENSE.txt')) { $stagedCli = Join-Path $stagedRoot $cliName $destCli = Join-Path $Root $cliName if ((Test-Path -LiteralPath $stagedCli -PathType Leaf) -and -not (Test-Path -LiteralPath $destCli -PathType Leaf)) { Copy-Item -LiteralPath $stagedCli -Destination $destCli -Force } } Invoke-WolfPreSetup -Root $Root Copy-Item -Path (Join-Path $stagedRoot '*') -Destination $PWD.Path -Recurse -Force } finally { if (Test-Path -LiteralPath $zipPath) { Remove-DownloadedRepoZip -LiteralPath $zipPath } } } finally { if (Test-Path -LiteralPath $stage) { Remove-FileOrDirectoryViaCmd -LiteralPath $stage } } Set-Content -LiteralPath $StateFilePath -Value $latestSha -Encoding Ascii return 0 } function Invoke-SrpgPostApply { param([string]$Root) $unpacker = Join-Path $Root 'SRPG_Unpacker.exe' $dts = Join-Path $Root 'data.dts' $projectDat = Join-Path $Root 'data\project.dat' $dataDir = Join-Path $Root 'data' if (-not (Test-Path -LiteralPath $dts)) { return } if (-not (Test-Path -LiteralPath $unpacker)) { Write-Host 'SRPG_Unpacker.exe not found in root; skipping SRPG patch steps.' return } if (Test-Path -LiteralPath $projectDat) { Write-Host 'Applying patch to data\project.dat...' $code = Invoke-SrpgUnpacker -ExePath $unpacker -WorkingDirectory $Root -Arguments @('.\data\project.dat', '-a') if ($code -ne 0) { Write-Host 'ERROR: Apply Patch failed.' } } else { Write-Host 'ERROR: data\project.dat not found; cannot apply patch.' } if (Test-Path -LiteralPath $dataDir) { Write-Host 'Packing data folder to data.dts...' $code = Invoke-SrpgUnpacker -ExePath $unpacker -WorkingDirectory $Root -Arguments @('-o', 'data.dts', 'data') if ($code -ne 0) { Write-Host 'WARNING: Pack failed.' } } else { Write-Host 'Step 4: Skipping pack - data folder not found.' } } function Remove-StalePatchZipArtifactsInGameRoot { param([Parameter(Mandatory = $true)][string]$Root) if (-not (Test-Path -LiteralPath $Root)) { return } Get-ChildItem -LiteralPath $Root -Filter 'dazedmtl_patch_*.zip' -File -ErrorAction SilentlyContinue | ForEach-Object { Remove-FileOrDirectoryViaCmd -LiteralPath $_.FullName } } function Remove-PatchTempArtifacts { param([string]$Root) $zipPath = Join-Path $Root 'repo.zip' $legacyTmp = Join-Path $Root '_patch_extract_tmp' Remove-StalePatchZipArtifactsInGameRoot -Root $Root if (Test-Path -LiteralPath $zipPath) { Write-Host ' Removing repo.zip...' Remove-FileOrDirectoryViaCmd -LiteralPath $zipPath } if (Test-Path -LiteralPath $legacyTmp) { Write-Host ' Removing _patch_extract_tmp - using rd /s /q (avoids PowerShell hangs on huge folders).' Remove-FileOrDirectoryViaCmd -LiteralPath $legacyTmp } } try { if (Test-WrongWorkingDirectory -Root $GameRoot) { Write-BannerWrongFolder Wait-ConsolePause exit 1 } Write-AsciiHeader Remove-StalePatchZipArtifactsInGameRoot -Root $GameRoot $configPath = Join-Path $PatchBundleRoot 'patch-config.txt' if (-not (Test-Path -LiteralPath $configPath)) { Write-Host 'patch-config.txt not found next to patch.ps1 - skipping patch.' Wait-ConsolePause exit 0 } $cfg = Read-PatchConfig -ConfigPath $configPath if ([string]::IsNullOrWhiteSpace($cfg.username)) { Write-Host "ERROR: 'username=' is missing in patch-config.txt" Wait-ConsolePause exit 1 } if ([string]::IsNullOrWhiteSpace($cfg.repo)) { Write-Host "ERROR: 'repo=' is missing in patch-config.txt" Wait-ConsolePause exit 1 } if ([string]::IsNullOrWhiteSpace($cfg.branch)) { Write-Host "ERROR: 'branch=' is missing in patch-config.txt" Wait-ConsolePause exit 1 } try { $resolved = Resolve-PatchForge -Cfg $cfg } catch { Write-Host $_.Exception.Message Wait-ConsolePause exit 1 } Write-Host ('Pulling patch from {0}/{1}/{2} ({3}, branch: {4})' -f ` $resolved.webBase, $resolved.username, $resolved.repo, $resolved.kind, $resolved.branch) Invoke-WolfPreSetup -Root $GameRoot Invoke-SrpgPreSetup -Root $GameRoot $stateFile = Join-Path $PatchBundleRoot 'previous_patch_sha.txt' $patchDlExit = 1 try { $patchDlExit = Invoke-PatchDownloadExtract -Root $GameRoot -StateFilePath $stateFile ` -Resolved $resolved } catch { Write-Host '' Write-Host $_.Exception.Message if ($_.InvocationInfo.PositionMessage) { Write-Host $_.InvocationInfo.PositionMessage } Remove-PatchTempArtifacts -Root $GameRoot Wait-ConsolePause exit 1 } if ($patchDlExit -eq 10) { Write-Host 'Already up to date.' Wait-ConsolePause exit 0 } if ($patchDlExit -ne 0) { Write-Host 'Download or extraction failed!' Remove-PatchTempArtifacts -Root $GameRoot Wait-ConsolePause exit 1 } Write-Host 'Applying patch...' Invoke-SrpgPostApply -Root $GameRoot # Safety: if Data/ is ready (from unpack or patch files) but Data.wolf # remains, retire it so the archive does not override loose English files. Invoke-WolfPreSetup -Root $GameRoot Write-Host 'Cleaning up...' Remove-PatchTempArtifacts -Root $GameRoot Write-Host 'Done.' Wait-ConsolePause exit 0 } catch { Write-Host '' Write-Host $_.Exception.Message Wait-ConsolePause exit 1 }