diff --git a/gameupdate/README.md b/gameupdate/README.md index 1ea782e..b638701 100644 --- a/gameupdate/README.md +++ b/gameupdate/README.md @@ -5,6 +5,9 @@ ## Future Patching 1. Run GAMEUPDATE.bat to auto patch. +2. Keep the whole `gameupdate` folder together when you ship the game—`patch.bat` and `patch-download.ps1` must sit side by side. +3. Optional: set `GAMEUPDATE_PROMPT_PWSH=1` before running `GameUpdate.bat` if you want users to be prompted to install PowerShell 7 via winget. +4. Optional: set `GAMEUPDATE_DL_ATTEMPTS` (default `2`) to control retries for API checks/downloads. Lower values fail faster; higher values tolerate flaky networks. # Troubleshooting **GAMEUPDATE.bat doesn't update and closes immediately** diff --git a/gameupdate/gameupdate/patch-download.ps1 b/gameupdate/gameupdate/patch-download.ps1 new file mode 100644 index 0000000..564c683 --- /dev/null +++ b/gameupdate/gameupdate/patch-download.ps1 @@ -0,0 +1,169 @@ +#Requires -Version 5.1 +param( + [Parameter(Mandatory = $true)] + [string]$GameRoot, + [Parameter(Mandatory = $true)] + [string]$StateFile +) + +$ErrorActionPreference = 'Stop' +$exitCode = 1 +$lockPath = Join-Path $GameRoot '.patch_update.lock' + +if ($PSVersionTable.PSEdition -ne 'Core') { + try { + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + } + catch {} +} + +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 Download-WithIwrSpeedProgress { + 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 + } + + # Fast path: let Invoke-WebRequest handle transfer and progress internally. + # Custom stream/polling progress gives richer stats but adds measurable overhead. + $prevPref = $ProgressPreference + try { + $ProgressPreference = 'Continue' + Invoke-WebRequest -Uri $Url -OutFile $OutFile -Headers $Headers -UseBasicParsing + } + finally { + $ProgressPreference = $prevPref + } +} + +try { + Set-Location -LiteralPath $GameRoot + if (Test-Path -LiteralPath $lockPath) { + throw 'PATCH_ERR:LOCK:Another patch process appears to be running.' + } + New-Item -Path $lockPath -ItemType File -Force | Out-Null + + if (-not $env:GU_USERNAME -or -not $env:GU_REPO -or -not $env:GU_BRANCH) { + throw 'PATCH_ERR:CONFIG:patch.bat must set GU_USERNAME, GU_REPO, and GU_BRANCH.' + } + + $id = [uri]::EscapeDataString($env:GU_USERNAME + '/' + $env:GU_REPO) + $branch = [uri]::EscapeDataString($env:GU_BRANCH) + $ua = 'DazedMTL-Patcher-1.0' + $headers = @{ 'User-Agent' = $ua } + $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/$branch") -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 $StateFile) { + $previousSha = (Get-Content -LiteralPath $StateFile -Raw).Trim() + } else { + Write-Host 'Previous SHA hash not found. Assuming first time patching...' + } + if ($previousSha -eq $latestSha) { + $exitCode = 10 + return + } + + Write-Host 'Update found! Patching...' + Write-Host 'Downloading latest patch... (backend: iwr, GitLab API)' + $zipPath = Join-Path $PWD.Path 'repo.zip' + $stage = Join-Path $PWD.Path '_patch_extract_tmp' + $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 { + Download-WithIwrSpeedProgress -Url $archiveUrl -OutFile $zipPath -Headers $headers + } | Out-Null + $dlSw.Stop() + $bytes = (Get-Item -LiteralPath $zipPath).Length + $secs = [Math]::Max($dlSw.Elapsed.TotalSeconds, 0.001) + $mbps = ($bytes / 1MB) / $secs + Write-Host ("Download complete via iwr: {0:N1} MB in {1:N1}s ({2:N1} MB/s)" -f ($bytes / 1MB), $secs, $mbps) + + $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) { + throw 'PATCH_ERR:ZIP:Download is not a valid ZIP (error page or empty file).' + } + } + finally { + $fs.Dispose() + } + + if (Test-Path -LiteralPath $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } + New-Item -ItemType Directory -Path $stage | Out-Null + 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 $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } + } + + Set-Content -LiteralPath $StateFile -Value $latestSha -Encoding Ascii + $exitCode = 0 +} +catch { + Write-Host '' + Write-Host ($_.Exception.Message) + if ($_.InvocationInfo.PositionMessage) { Write-Host $_.InvocationInfo.PositionMessage } + $exitCode = 1 +} +finally { + if (Test-Path -LiteralPath $lockPath) { + Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue + } +} + +exit $exitCode diff --git a/gameupdate/gameupdate/patch.bat b/gameupdate/gameupdate/patch.bat index f6f7a5b..85137ed 100644 --- a/gameupdate/gameupdate/patch.bat +++ b/gameupdate/gameupdate/patch.bat @@ -29,41 +29,38 @@ echo Using root directory: "%ROOT_DIR%" echo Using config: "%CONFIG_FILE%" echo Checking for pwsh... -set _my_shell=pwsh - -REM Check if pwsh.exe exists +set "_my_shell=pwsh" where /q !_my_shell! if !errorlevel! neq 0 ( echo pwsh not found. - echo. - echo PowerShell 7 ^(pwsh^) is faster and recommended. - set /p "INSTALL_PWSH=Would you like to install it 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 - if !errorlevel! neq 0 ( - echo Install failed or winget not available. Falling back to powershell... - set _my_shell=powershell + 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 PowerShell 7 installed successfully. - echo Please re-run GameUpdate.bat to use pwsh. - pause - exit /b 0 + echo Skipping install; falling back to powershell. + set "_my_shell=powershell" ) ) else ( - echo Skipping install. Falling back to powershell... - set _my_shell=powershell + echo Tip: Set GAMEUPDATE_PROMPT_PWSH=1 to offer PowerShell 7 install. + echo Falling back to powershell... + set "_my_shell=powershell" ) - - REM Check if powershell.exe exists - echo Checking for powershell... where /q !_my_shell! if !errorlevel! neq 0 ( - echo.Error: Powershell not found! + echo ERROR: Neither pwsh nor powershell was found. pause - exit /B 1 - ) else ( - echo powershell found. + exit /b 1 ) ) else ( echo pwsh found. @@ -85,11 +82,27 @@ for /f "usebackq tokens=1,2 delims==" %%a in ("%CONFIG_FILE%") do ( if "%%a"=="branch" set "branch=%%b" ) +REM Validate required config keys early for clearer errors +if "%username%"=="" ( + echo ERROR: 'username=' is missing in gameupdate\patch-config.txt + pause + exit /b 1 +) +if "%repo%"=="" ( + echo ERROR: 'repo=' is missing in gameupdate\patch-config.txt + pause + exit /b 1 +) +if "%branch%"=="" ( + echo ERROR: 'branch=' is missing in gameupdate\patch-config.txt + pause + exit /b 1 +) + REM For PowerShell (proper URL encoding; avoids cmd %% pitfalls) set "GU_USERNAME=%username%" set "GU_REPO=%repo%" set "GU_BRANCH=%branch%" -if /I "!_my_shell!"=="pwsh" (set "GU_DL_PROGRESS=Continue") else (set "GU_DL_PROGRESS=SilentlyContinue") REM -------------------------------------------------------- REM PRE-SETUP: Ensure SRPG data and patch structure exists @@ -150,56 +163,28 @@ if exist "%ROOT_DIR%\data.dts" ( echo [Pre-Setup] data.dts not found; skipping pre-setup SRPG steps. ) -REM Get the latest hash (GitLab API — browser /-/archive/ URLs are often blocked by CDN) -echo "Getting latest commit SHA hash" -!_my_shell! -NoProfile -Command "$ErrorActionPreference='Stop'; $id=[uri]::EscapeDataString($env:GU_USERNAME+'/'+ $env:GU_REPO); $br=[uri]::EscapeDataString($env:GU_BRANCH); (Invoke-RestMethod -Uri \"https://gitgud.io/api/v4/projects/$id/repository/branches/$br\" -Headers @{'User-Agent'='GameUpdate/1.0'}).commit.id" > "%ROOT_DIR%\latest_patch_sha.txt" - -REM Read the latest SHA from the file -set /p latest_patch_sha=<"%ROOT_DIR%\latest_patch_sha.txt" - -REM Check if previous_patch_sha.txt exists in gameupdate -if not exist "%SCRIPT_DIR%previous_patch_sha.txt" ( - echo "Previous SHA hash not found!" - echo "Assuming first time patching..." - goto download_extract -) - -REM Read the stored SHA from previous check -set /p previous_patch_sha=<"%SCRIPT_DIR%previous_patch_sha.txt" - -REM Trim whitespace from SHA strings -set "previous_patch_sha=%previous_patch_sha: =%" -set "latest_patch_sha=%latest_patch_sha: =%" - -REM Compare trimmed SHAs -if "%latest_patch_sha%" neq "%previous_patch_sha%" ( - echo "Update found! Patching..." - goto download_extract -) else ( - echo "Patch is up to date." -) - -REM Delete latest_patch_sha.txt -del "%ROOT_DIR%\latest_patch_sha.txt" - -endlocal -pause -exit /b - :download_extract -REM Escape single quotes in paths -set "escaped_root=%ROOT_DIR:'=''%" - -REM Download zip via GitLab API; extract single top-level folder (API uses commit-based root name, not repo-branch) -echo "Downloading latest patch..." -!_my_shell! -NoProfile -Command "$ErrorActionPreference='Stop'; Set-Location -LiteralPath '%escaped_root%'; $id=[uri]::EscapeDataString($env:GU_USERNAME+'/'+ $env:GU_REPO); $shaQ=[uri]::EscapeDataString($env:GU_BRANCH); $url=\"https://gitgud.io/api/v4/projects/$id/repository/archive.zip?sha=$shaQ\"; $ProgressPreference=$env:GU_DL_PROGRESS; Invoke-WebRequest -Uri $url -OutFile 'repo.zip' -UseBasicParsing -Headers @{'User-Agent'='GameUpdate/1.0'}; $ProgressPreference='SilentlyContinue'; $stage = Join-Path (Get-Location) '_patch_extract_tmp'; if (Test-Path $stage) { Remove-Item -LiteralPath $stage -Recurse -Force }; New-Item -ItemType Directory -Path $stage | Out-Null; try { Expand-Archive -LiteralPath '.\repo.zip' -DestinationPath $stage -Force; $dirs = @(Get-ChildItem -LiteralPath $stage -Directory); if ($dirs.Count -ne 1) { throw \"Expected one root folder in archive, found $($dirs.Count)\" }; Copy-Item -Path (Join-Path $dirs[0].FullName '*') -Destination (Get-Location) -Recurse -Force } finally { if (Test-Path $stage) { Remove-Item -LiteralPath $stage -Recurse -Force } }" -if !errorlevel! neq 0 ( +REM Download/extract via patch-download.ps1 (IWR-based); Bypass avoids ExecutionPolicy prompts +if not exist "%SCRIPT_DIR%patch-download.ps1" ( + echo ERROR: patch-download.ps1 not found next to patch.bat in the gameupdate folder. + pause + exit /b 1 +) +!_my_shell! -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT_DIR%patch-download.ps1" -GameRoot "%ROOT_DIR%" -StateFile "%SCRIPT_DIR%previous_patch_sha.txt" +set "PATCH_DL_EXIT=!errorlevel!" +if "!PATCH_DL_EXIT!"=="10" ( + echo Patch is up to date. + endlocal + pause + exit /b 0 +) +if not "!PATCH_DL_EXIT!"=="0" ( echo Download or extraction failed! if exist "%ROOT_DIR%\repo.zip" del "%ROOT_DIR%\repo.zip" if exist "%ROOT_DIR%\_patch_extract_tmp" rmdir /s /q "%ROOT_DIR%\_patch_extract_tmp" pause - exit /b + exit /b 1 ) echo "Applying patch..." REM Files merged by Copy-Item above @@ -248,9 +233,6 @@ REM Clean up echo "Cleaning up..." del "%ROOT_DIR%\repo.zip" if exist "%ROOT_DIR%\_patch_extract_tmp" rmdir /s /q "%ROOT_DIR%\_patch_extract_tmp" -del "%ROOT_DIR%\latest_patch_sha.txt" -REM Store latest SHA for next check in gameupdate -echo %latest_patch_sha% > "%SCRIPT_DIR%previous_patch_sha.txt" endlocal pause exit /b