diff --git a/GameUpdate.bat b/GameUpdate.bat index 64214fc..8b6180e 100644 --- a/GameUpdate.bat +++ b/GameUpdate.bat @@ -1,14 +1,69 @@ @echo off -setlocal +setlocal EnableExtensions EnableDelayedExpansion -REM Copy GAMEUPDATE.bat to a new file -copy "gameupdate\patch.bat" "gameupdate\patch2.bat" +REM When deployed on a game: this file sits in the game root; patch scripts live in .\gameupdate\ +REM In this repo the same layout is kept for neatness: this bat is under DazedMTLTool\gameupdate\ next to a nested gameupdate\ folder with patch.ps1. +set "GU_ROOT=%~dp0" +REM Game root is this batch file's folder (not %%CD%%, so full-path launches still work). +set "GAME_ROOT=!GU_ROOT!" +if "!GAME_ROOT:~-1!"=="\" set "GAME_ROOT=!GAME_ROOT:~0,-1!" +set "PATCH_SCRIPT_DIR=!GU_ROOT!gameupdate" -REM Run the new file -call "gameupdate\patch2.bat" +if not exist "!PATCH_SCRIPT_DIR!\patch.ps1" ( + echo ERROR: patch.ps1 not found at: + echo !PATCH_SCRIPT_DIR!\patch.ps1 + echo Expected layout: GameUpdate.bat and a gameupdate folder next to each other ^(same parent^). + pause + exit /b 1 +) -REM Delete the new file -del "gameupdate\patch2.bat" +REM Copy patch.ps1 so the live script can be overwritten during updates +copy /Y "!PATCH_SCRIPT_DIR!\patch.ps1" "!PATCH_SCRIPT_DIR!\patch2.ps1" >nul +if errorlevel 1 ( + echo ERROR: Could not copy patch.ps1 to patch2.ps1 in: + echo !PATCH_SCRIPT_DIR! + pause + exit /b 1 +) -endlocal -@echo on \ No newline at end of file +set "_my_shell=pwsh" +where /q !_my_shell! +if !errorlevel! neq 0 ( + echo PowerShell 7 ^(pwsh^) not found. + if /I "%GAMEUPDATE_PROMPT_PWSH%"=="1" ( + echo. + set /p "INSTALL_PWSH=PowerShell 7 is faster. Install now via winget? (Y/N): " + if /I "!INSTALL_PWSH!"=="Y" ( + echo Installing PowerShell 7 via winget... + winget install --id Microsoft.PowerShell --source winget --accept-package-agreements --accept-source-agreements + where /q pwsh + if !errorlevel! equ 0 ( + set "_my_shell=pwsh" + echo PowerShell 7 installed; using pwsh. + ) else ( + echo Install failed or unavailable; falling back to powershell. + set "_my_shell=powershell" + ) + ) else ( + echo Skipping install; falling back to powershell. + set "_my_shell=powershell" + ) + ) else ( + echo Tip: Set GAMEUPDATE_PROMPT_PWSH=1 to offer PowerShell 7 install. + echo Falling back to powershell... + set "_my_shell=powershell" + ) + where /q !_my_shell! + if !errorlevel! neq 0 ( + echo ERROR: Neither pwsh nor powershell was found. + del /Q "!PATCH_SCRIPT_DIR!\patch2.ps1" >nul 2>&1 + pause + exit /b 1 + ) +) + +!_my_shell! -NoProfile -ExecutionPolicy Bypass -File "!PATCH_SCRIPT_DIR!\patch2.ps1" -GameRoot "!GAME_ROOT!" +set "GU_PATCH_EXIT=!errorlevel!" +del /Q "!PATCH_SCRIPT_DIR!\patch2.ps1" >nul 2>&1 + +exit /b !GU_PATCH_EXIT! diff --git a/GameUpdate_linux.sh b/GameUpdate_linux.sh index 732096a..f3a5b47 100644 --- a/GameUpdate_linux.sh +++ b/GameUpdate_linux.sh @@ -3,11 +3,12 @@ # Enable error handling set -e -# Copy patch.sh to a new file in gameupdate -cp "gameupdate/patch.sh" "gameupdate/patch2.sh" +# Same layout as GameUpdate.bat: patch assets live under ./gameupdate/ next to this file. +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +GAME_ROOT="$SCRIPT_DIR" +PATCH_DIR="$SCRIPT_DIR/gameupdate" -# Run the new file -bash "gameupdate/patch2.sh" - -# Delete the new file -rm "gameupdate/patch2.sh" +cd "$GAME_ROOT" +cp "$PATCH_DIR/patch.sh" "$PATCH_DIR/patch2.sh" +bash "$PATCH_DIR/patch2.sh" +rm "$PATCH_DIR/patch2.sh" diff --git a/gameupdate/patch.ps1 b/gameupdate/patch.ps1 new file mode 100644 index 0000000..90a42a2 --- /dev/null +++ b/gameupdate/patch.ps1 @@ -0,0 +1,769 @@ +#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 = @{ 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) { + 'username' { $cfg.username = $v } + 'repo' { $cfg.repo = $v } + 'branch' { $cfg.branch = $v } + } + } + return $cfg +} + +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 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)][string]$Username, + [Parameter(Mandatory = $true)][string]$Repo, + [Parameter(Mandatory = $true)][string]$Branch + ) + + if ($PSVersionTable.PSEdition -ne 'Core') { + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + } + catch {} + } + + Set-Location -LiteralPath $Root + + $id = [uri]::EscapeDataString($Username + '/' + $Repo) + $branchEnc = [uri]::EscapeDataString($Branch) + $headers = @{ 'User-Agent' = 'DazedMTL-Patcher-1.0' } + $dlAttempts = Get-EnvInt -Name 'GAMEUPDATE_DL_ATTEMPTS' -Default 2 + + $latestSha = Invoke-WithRetry -Name 'Resolve latest patch SHA' -Attempts $dlAttempts -Action { + (Invoke-RestMethod -Uri ("https://gitgud.io/api/v4/projects/$id/repository/branches/$branchEnc") -Headers $headers).commit.id + } + 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')) + $archiveSha = [uri]::EscapeDataString($latestSha) + $archiveUrl = "https://gitgud.io/api/v4/projects/$id/repository/archive.zip?sha=$archiveSha" + + $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) + } + Copy-Item -Path (Join-Path $dirs[0].FullName '*') -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 + } + + Write-Host ('Pulling patch from https://gitgud.io/{0}/{1} (branch: {2})' -f $cfg.username, $cfg.repo, $cfg.branch) + + Invoke-SrpgPreSetup -Root $GameRoot + + $stateFile = Join-Path $PatchBundleRoot 'previous_patch_sha.txt' + + $patchDlExit = 1 + try { + $patchDlExit = Invoke-PatchDownloadExtract -Root $GameRoot -StateFilePath $stateFile ` + -Username $cfg.username -Repo $cfg.repo -Branch $cfg.branch + } + 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 + + 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 +} diff --git a/gameupdate/patch.sh b/gameupdate/patch.sh index 0902451..ad1b86d 100644 --- a/gameupdate/patch.sh +++ b/gameupdate/patch.sh @@ -1,10 +1,13 @@ #!/bin/bash -set -e +set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(pwd)" CONFIG_FILE="$SCRIPT_DIR/patch-config.txt" +STATE_FILE="$SCRIPT_DIR/previous_patch_sha.txt" +GITGUD_API="https://gitgud.io/api/v4" +API_HEADERS=( -H "User-Agent: GameUpdate/1.0" ) check_dependency() { if ! command -v "$1" > /dev/null 2>&1; then @@ -32,11 +35,61 @@ echo "Root directory: $ROOT_DIR" echo "Config file path: $CONFIG_FILE" # Read configuration from file +# shellcheck disable=SC1090 . "$CONFIG_FILE" +if [ -z "${username:-}" ]; then + echo "ERROR: 'username=' is missing in gameupdate/patch-config.txt" + exit 1 +fi +if [ -z "${repo:-}" ]; then + echo "ERROR: 'repo=' is missing in gameupdate/patch-config.txt" + exit 1 +fi +if [ -z "${branch:-}" ]; then + echo "ERROR: 'branch=' is missing in gameupdate/patch-config.txt" + exit 1 +fi + +RETRIES="${GAMEUPDATE_DL_ATTEMPTS:-2}" +if ! [[ "$RETRIES" =~ ^[1-9][0-9]*$ ]]; then + RETRIES=2 +fi + +retry_cmd() { + local name="$1" + shift + local attempt=1 + while true; do + if "$@"; then + return 0 + fi + if [ "$attempt" -ge "$RETRIES" ]; then + echo "$name failed after $attempt attempt(s)." + return 1 + fi + echo "$name failed ($attempt/$RETRIES), retrying..." + attempt=$((attempt + 1)) + sleep 2 + done +} + +project_enc=$(jq -nr --arg ns "$username" --arg rp "$repo" '$ns + "/" + $rp | @uri') +branch_enc=$(jq -nr --arg b "$branch" '$b | @uri') + # Get the latest hash echo "Getting latest commit SHA hash" -latest_patch_sha=$(curl -s "https://gitgud.io/api/v4/projects/$username%2F$repo/repository/branches/$branch" | jq -r '.commit.id') +latest_patch_sha="$( + retry_cmd "Resolve latest patch SHA" \ + curl -fsSL "${API_HEADERS[@]}" \ + "${GITGUD_API}/projects/${project_enc}/repository/branches/${branch_enc}" \ + | jq -r '.commit.id' +)" +latest_patch_sha="$(printf '%s' "$latest_patch_sha" | tr -d '[:space:]')" +if [ -z "$latest_patch_sha" ] || [ "$latest_patch_sha" = "null" ]; then + echo "PATCH_ERR:API:Latest commit SHA response was empty." + exit 1 +fi # -------------------------------------------------------- # PRE-SETUP: Ensure SRPG data and patch structure exists @@ -49,92 +102,91 @@ if [ -f "$ROOT_DIR/data.dts" ]; then if [ -f "$UNPACKER" ]; then echo "[Pre-Setup] Running SRPG_Unpacker preparation steps..." - # Step 1: Unpack (once) - if [ ! -d "$ROOT_DIR/data" ]; then - if [ -f "$ROOT_DIR/data.dts" ]; then - echo "[Pre-Setup] Step 1: Unpacking data.dts -> data" - ( cd "$ROOT_DIR" && "$UNPACKER" -o data data.dts ) || echo "[Pre-Setup] ERROR: Unpack failed." + # Step 1: Unpack (once) — mirror patch.ps1: unpack if no data/ or no data/project.dat + if [ ! -d "$ROOT_DIR/data" ] || [ ! -f "$ROOT_DIR/data/project.dat" ]; then + if [ -f "$ROOT_DIR/data.dts" ]; then + echo "[Pre-Setup] Step 1: Unpacking data.dts -> data" + ( cd "$ROOT_DIR" && "$UNPACKER" -o data data.dts ) || echo "[Pre-Setup] ERROR: Unpack failed." + else + echo "[Pre-Setup] Step 1: Skipping unpack (no data folder and no data.dts found)." + fi else - echo "[Pre-Setup] Step 1: Skipping unpack (no data folder and no data.dts found)." + echo "[Pre-Setup] Step 1: data folder exists; skipping unpack." fi - else - echo "[Pre-Setup] Step 1: data folder exists; skipping unpack." - fi - # Step 2: Create Patch (once) - if [ ! -d "$ROOT_DIR/patch" ]; then - if [ -f "$ROOT_DIR/data/project.dat" ]; then - echo "[Pre-Setup] Step 2: Creating patch from data/project.dat" - ( cd "$ROOT_DIR" && "$UNPACKER" ./data/project.dat -c ) || echo "[Pre-Setup] ERROR: Create Patch failed." - else - echo "[Pre-Setup] Step 2: Skipping create patch (data/project.dat not found)." - fi + # Step 2: Create Patch (once) + if [ ! -d "$ROOT_DIR/patch" ]; then + if [ -f "$ROOT_DIR/data/project.dat" ]; then + echo "[Pre-Setup] Step 2: Creating patch from data/project.dat" + ( cd "$ROOT_DIR" && "$UNPACKER" ./data/project.dat -c ) || echo "[Pre-Setup] ERROR: Create Patch failed." + else + echo "[Pre-Setup] Step 2: Skipping create patch (data/project.dat not found)." + fi else echo "[Pre-Setup] Step 2: patch folder exists; skipping create." fi - else - echo "[Pre-Setup] SRPG_Unpacker.exe not found in root; skipping pre-setup steps." - fi else - echo "[Pre-Setup] data.dts not found; skipping pre-setup SRPG steps." + echo "[Pre-Setup] SRPG_Unpacker.exe not found in root; skipping pre-setup steps." fi +fi download_extract() { - # Download zip file into root via GitLab API (bypasses Cloudflare DDoS protection) echo "Downloading latest patch..." - curl -sL -A "git/2.0" "https://gitgud.io/api/v4/projects/$username%2F$repo/repository/archive.zip?sha=$branch" -o "$ROOT_DIR/repo.zip" - if [ $? -ne 0 ]; then + archive_sha_enc=$(jq -nr --arg s "$latest_patch_sha" '$s | @uri') + if ! retry_cmd "Download archive with curl" \ + curl -fSL --progress-bar "${API_HEADERS[@]}" \ + "${GITGUD_API}/projects/${project_enc}/repository/archive.zip?sha=${archive_sha_enc}" \ + -o "$ROOT_DIR/repo.zip"; then echo "Download failed!" rm -f "$ROOT_DIR/repo.zip" - rm -rf "$ROOT_DIR/$repo-$branch" return 1 fi - # Extract contents, overwriting conflicts into root + TMP_EX=$(mktemp -d) + echo "Extracting..." - unzip -qo "$ROOT_DIR/repo.zip" -d "$ROOT_DIR" - if [ $? -ne 0 ]; then + if ! unzip -qo "$ROOT_DIR/repo.zip" -d "$TMP_EX"; then echo "Extraction failed!" + rm -rf "$TMP_EX" + rm -f "$ROOT_DIR/repo.zip" + return 1 + fi + + inner="" + for d in "$TMP_EX"/*; do + if [ -d "$d" ]; then + inner="$d" + break + fi + done + if [ -z "$inner" ]; then + echo "Archive had no root folder!" + rm -rf "$TMP_EX" rm -f "$ROOT_DIR/repo.zip" - rm -rf "$ROOT_DIR/$repo-$branch" return 1 fi echo "Applying patch..." - # API zip uses a different folder name (repo-branch-sha), find it dynamically - EXTRACTED_DIR=$(find "$ROOT_DIR" -maxdepth 1 -type d -name "${repo}-*" | head -1) - if [ -z "$EXTRACTED_DIR" ]; then - echo "Patch application failed - extracted folder not found!" - rm -f "$ROOT_DIR/repo.zip" - return 1 - fi - cp -r "$EXTRACTED_DIR/"* "$ROOT_DIR/" - if [ $? -ne 0 ]; then + if ! cp -r "$inner"/* "$ROOT_DIR/"; then echo "Patch application failed!" + rm -rf "$TMP_EX" rm -f "$ROOT_DIR/repo.zip" - rm -rf "$ROOT_DIR/$repo-$branch" return 1 fi + rm -rf "$TMP_EX" + echo "Cleaning up..." rm -f "$ROOT_DIR/repo.zip" - [ -n "$EXTRACTED_DIR" ] && rm -rf "$EXTRACTED_DIR" - rm -f "$ROOT_DIR/latest_patch_sha.txt" - # Store latest SHA for next check in gameupdate - echo "$latest_patch_sha" > "$SCRIPT_DIR/previous_patch_sha.txt" + # -------------------------------------------------------- + # POST-APPLY: Run Steps 3 and 4 after patch files are merged + # -------------------------------------------------------- + UNPACKER="$ROOT_DIR/SRPG_Unpacker.exe" + if [ -f "$ROOT_DIR/data.dts" ]; then + if [ -f "$UNPACKER" ]; then + echo "Running SRPG_Unpacker apply/pack steps..." - # -------------------------------------------------------- - # POST-APPLY: Run Steps 3 and 4 after patch files are merged - # 3) Apply Patch to data/project.dat - # 4) Pack data back into data.dts - # -------------------------------------------------------- - UNPACKER="$ROOT_DIR/SRPG_Unpacker.exe" - if [ -f "$ROOT_DIR/data.dts" ]; then - if [ -f "$UNPACKER" ]; then - echo "Running SRPG_Unpacker apply/pack steps..." - - # Step 3: Apply Patch if [ -f "$ROOT_DIR/data/project.dat" ]; then echo "Step 3: Applying patch to data/project.dat" ( cd "$ROOT_DIR" && "$UNPACKER" ./data/project.dat -a ) || echo "ERROR: Apply Patch failed." @@ -142,35 +194,31 @@ download_extract() { echo "ERROR: data/project.dat not found; cannot apply patch." fi - # Step 4: Pack if [ -d "$ROOT_DIR/data" ]; then echo "Step 4: Packing data -> data.dts" ( cd "$ROOT_DIR" && "$UNPACKER" -o data.dts data ) || echo "WARNING: Pack failed." - else - echo "Step 4: Skipping pack (data folder not found)." - fi - else - echo "SRPG_Unpacker.exe not found in root; skipping SRPG patch steps." - fi - else - echo "data.dts not found; skipping SRPG patch steps." - fi + else + echo "Step 4: Skipping pack (data folder not found)." + fi + else + echo "SRPG_Unpacker.exe not found in root; skipping SRPG patch steps." + fi + fi + + echo "$latest_patch_sha" > "$STATE_FILE" } # Check if previous_patch_sha.txt exists in gameupdate -if [ ! -f "$SCRIPT_DIR/previous_patch_sha.txt" ]; then - echo "Previous SHA hash not found!" - echo "Assuming first time patching..." +if [ ! -f "$STATE_FILE" ]; then + echo "No saved patch version yet (first run); comparing with remote..." download_extract else - # Read the stored SHA from previous check - previous_patch_sha=$(cat "$SCRIPT_DIR/previous_patch_sha.txt") + previous_patch_sha=$(tr -d '[:space:]' < "$STATE_FILE") - # Compare trimmed SHAs if [ "$latest_patch_sha" != "$previous_patch_sha" ]; then echo "Update found! Patching..." download_extract else - echo "Patch is up to date." + echo "Already up to date (matches latest patch commit)." fi fi