diff --git a/README.md b/README.md index 5bcfd88..1f504b5 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,27 @@ $env:WOW_HOME = 'C:\Program Files (x86)\World of Warcraft' .\run.ps1 ``` +Для быстрой разработки интерфейса без пересборки MPQ: + +```powershell +# Наложить src\Data\...\Interface на клиент и следить за изменениями +.\hotreload.ps1 + +# То же самое, но запустить клиент и автоматически завершить сессию после его выхода +.\hotreload.ps1 -Launch + +# Явно указать клиент, если WOW_HOME не задан +.\hotreload.ps1 -ClientPath 'D:\Games\World of Warcraft' + +# Подключиться к другому серверу вместо локального +.\hotreload.ps1 -Realmlist 'logon.example.org' +``` + +Во время сессии изменённые файлы сразу копируются в `WOW_HOME\Interface`; увидеть изменения в игре +можно после `/reload`. По `Ctrl+C` или после выхода запущенного через `-Launch` клиента временные +файлы удаляются, а существовавшие до запуска файлы восстанавливаются. По умолчанию на время сессии +в `Data\ruRU\realmlist.wtf` записывается `127.0.0.1`; исходный realmlist также восстанавливается. + Диагностика запуска находится в `Logs\wxl-core.log` и `Logs\d3d9proxy.log` внутри клиента. ## MPQ-пакеты diff --git a/hotreload.ps1 b/hotreload.ps1 new file mode 100644 index 0000000..7a1dfb1 --- /dev/null +++ b/hotreload.ps1 @@ -0,0 +1,388 @@ +[CmdletBinding()] +param( + [string]$ClientPath = '', + [ValidatePattern('^[^\r\n]+$')] + [string]$Realmlist = '127.0.0.1', + [ValidateRange(100, 10000)] + [int]$PollIntervalMilliseconds = 500, + [switch]$NoInitialSync, + [switch]$Launch +) + +$ErrorActionPreference = 'Stop' +$repoRoot = [System.IO.Path]::GetFullPath($PSScriptRoot).TrimEnd('\') + +function Import-ProjectEnvironment { + $envFile = Join-Path $repoRoot '.env' + if (-not (Test-Path -LiteralPath $envFile -PathType Leaf)) { + return + } + + foreach ($line in Get-Content -LiteralPath $envFile -Encoding UTF8) { + if ($line -notmatch '^\s*([^#][^=]+)=(.*)$') { + continue + } + + $name = $matches[1].Trim() + $value = $matches[2].Trim().Trim('"').Trim("'") + if ($name -and -not (Test-Path "Env:$name")) { + Set-Item -Path "Env:$name" -Value $value + } + } +} + +function Get-InterfaceSourceRoots { + param([Parameter(Mandatory)][string]$RepositoryPath) + + $dataRoot = Join-Path $RepositoryPath 'src\Data' + if (-not (Test-Path -LiteralPath $dataRoot -PathType Container)) { + throw "Patch source directory was not found: $dataRoot" + } + + $roots = @() + foreach ($dataEntry in Get-ChildItem -LiteralPath $dataRoot -Directory | Sort-Object Name) { + $directInterface = Join-Path $dataEntry.FullName 'Interface' + if (Test-Path -LiteralPath $directInterface -PathType Container) { + $roots += [System.IO.Path]::GetFullPath($directInterface).TrimEnd('\') + } + + if ($dataEntry.Name -match '^[a-z]{2}[A-Z]{2}$') { + foreach ($localePatch in Get-ChildItem -LiteralPath $dataEntry.FullName -Directory | Sort-Object Name) { + $localeInterface = Join-Path $localePatch.FullName 'Interface' + if (Test-Path -LiteralPath $localeInterface -PathType Container) { + $roots += [System.IO.Path]::GetFullPath($localeInterface).TrimEnd('\') + } + } + } + } + + return @($roots) +} + +function Get-EffectiveInterfaceFiles { + param([Parameter(Mandatory)][string[]]$SourceRoots) + + # Later patch roots win, matching the order in which locale and higher patch + # layers override earlier archives in the client. + $files = @{} + foreach ($sourceRoot in $SourceRoots) { + $sourcePrefix = $sourceRoot + '\' + foreach ($file in Get-ChildItem -LiteralPath $sourceRoot -Recurse -Force -File) { + $relativePath = $file.FullName.Substring($sourcePrefix.Length) + if ($relativePath.EndsWith('.manifest.json', [System.StringComparison]::OrdinalIgnoreCase)) { + continue + } + + $files[$relativePath] = [pscustomobject]@{ + RelativePath = $relativePath + SourcePath = $file.FullName + Signature = '{0}:{1}' -f $file.Length, $file.LastWriteTimeUtc.Ticks + } + } + } + + return $files +} + +function Get-SafeTargetPath { + param([Parameter(Mandatory)][string]$RelativePath) + + $targetPath = [System.IO.Path]::GetFullPath((Join-Path $interfaceRoot $RelativePath)) + if (-not $targetPath.StartsWith($interfacePrefix, [System.StringComparison]::OrdinalIgnoreCase)) { + throw "Interface path escapes the client Interface directory: $RelativePath" + } + return $targetPath +} + +function Add-CreatedDirectory { + param([Parameter(Mandatory)][string]$DirectoryPath) + + if (Test-Path -LiteralPath $DirectoryPath -PathType Container) { + return + } + + $missing = @() + $candidate = $DirectoryPath + while ($candidate.StartsWith($interfacePrefix, [System.StringComparison]::OrdinalIgnoreCase) -and + -not (Test-Path -LiteralPath $candidate -PathType Container)) { + $missing += $candidate + $candidate = Split-Path -Parent $candidate + } + + New-Item -ItemType Directory -Path $DirectoryPath -Force | Out-Null + foreach ($created in $missing) { + $createdDirectories[$created] = $true + } +} + +function Install-HotReloadFile { + param( + [Parameter(Mandatory)][string]$RelativePath, + [Parameter(Mandatory)][string]$SourcePath, + [switch]$Quiet + ) + + $targetPath = Get-SafeTargetPath -RelativePath $RelativePath + if (-not $ownedTargets.ContainsKey($RelativePath)) { + $hadOriginal = Test-Path -LiteralPath $targetPath -PathType Leaf + $backupPath = $null + if ($hadOriginal) { + $backupPath = Join-Path $backupRoot $RelativePath + Add-CreatedDirectory -DirectoryPath (Split-Path -Parent $backupPath) + Copy-Item -LiteralPath $targetPath -Destination $backupPath -Force + } + + $ownedTargets[$RelativePath] = [pscustomobject]@{ + RelativePath = $RelativePath + TargetPath = $targetPath + HadOriginal = $hadOriginal + BackupPath = $backupPath + } + } + + Add-CreatedDirectory -DirectoryPath (Split-Path -Parent $targetPath) + Copy-Item -LiteralPath $SourcePath -Destination $targetPath -Force + if (-not $Quiet) { + Write-Host "Updated: Interface\$RelativePath" -ForegroundColor Green + } +} + +function Restore-HotReloadTarget { + param( + [Parameter(Mandatory)]$State, + [switch]$Quiet + ) + + if ($State.HadOriginal) { + Add-CreatedDirectory -DirectoryPath (Split-Path -Parent $State.TargetPath) + Copy-Item -LiteralPath $State.BackupPath -Destination $State.TargetPath -Force + if (-not $Quiet) { + Write-Host "Restored: Interface\$($State.RelativePath)" + } + } elseif (Test-Path -LiteralPath $State.TargetPath -PathType Leaf) { + Remove-Item -LiteralPath $State.TargetPath -Force + if (-not $Quiet) { + Write-Host "Removed: Interface\$($State.RelativePath)" + } + } +} + +Import-ProjectEnvironment + +if ([string]::IsNullOrWhiteSpace($ClientPath)) { + $ClientPath = $env:WOW_HOME +} +if ([string]::IsNullOrWhiteSpace($ClientPath)) { + throw 'ClientPath was not supplied and WOW_HOME is not set.' +} + +$clientRoot = [System.IO.Path]::GetFullPath($ClientPath).TrimEnd('\') +$clientPrefix = $clientRoot + '\' +$wowExecutable = Join-Path $clientRoot 'Wow.exe' +if (-not (Test-Path -LiteralPath $wowExecutable -PathType Leaf)) { + throw "Wow.exe was not found in the client directory: $clientRoot" +} +if ($clientRoot.Equals($repoRoot, [System.StringComparison]::OrdinalIgnoreCase) -or + $repoRoot.StartsWith($clientRoot + '\', [System.StringComparison]::OrdinalIgnoreCase)) { + throw 'The client directory must be separate from the repository.' +} + +$sourceRoots = @(Get-InterfaceSourceRoots -RepositoryPath $repoRoot) +if (-not $sourceRoots.Count) { + throw 'No src\Data\...\Interface patch directories were found.' +} + +$interfaceRoot = Join-Path $clientRoot 'Interface' +$interfaceRoot = [System.IO.Path]::GetFullPath($interfaceRoot).TrimEnd('\') +$interfacePrefix = $interfaceRoot + '\' +$systemTempRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath()).TrimEnd('\') +$systemTempPrefix = $systemTempRoot + '\' +$backupRoot = [System.IO.Path]::GetFullPath(( + Join-Path $systemTempRoot ('MoonWell-hotreload-' + [Guid]::NewGuid().ToString('N')) +)) +if (-not $backupRoot.StartsWith($systemTempPrefix, [System.StringComparison]::OrdinalIgnoreCase) -or + -not ([System.IO.Path]::GetFileName($backupRoot)).StartsWith( + 'MoonWell-hotreload-', + [System.StringComparison]::OrdinalIgnoreCase + )) { + throw "Unsafe hot reload backup path: $backupRoot" +} +$ownedTargets = @{} +$createdDirectories = @{} +$createdRealmlistDirectories = @() +$realmlistState = $null +$cleanupFailed = $false +$launchedProcess = $null + +New-Item -ItemType Directory -Path $interfaceRoot, $backupRoot -Force | Out-Null + +Write-Host 'MoonWell Interface hot reload' +Write-Host " Client: $clientRoot" +Write-Host " Sources: $($sourceRoots.Count) patch directories" + +try { + $realmlistPath = [System.IO.Path]::GetFullPath(( + Join-Path $clientRoot 'Data\ruRU\realmlist.wtf' + )) + if (-not $realmlistPath.StartsWith( + $clientPrefix, + [System.StringComparison]::OrdinalIgnoreCase + )) { + throw "Realmlist path escapes the client directory: $realmlistPath" + } + + $realmlistDirectory = Split-Path -Parent $realmlistPath + $candidateDirectory = $realmlistDirectory + while ($candidateDirectory.StartsWith( + $clientPrefix, + [System.StringComparison]::OrdinalIgnoreCase + ) -and + -not (Test-Path -LiteralPath $candidateDirectory -PathType Container)) { + $createdRealmlistDirectories += $candidateDirectory + $candidateDirectory = Split-Path -Parent $candidateDirectory + } + New-Item -ItemType Directory -Path $realmlistDirectory -Force | Out-Null + + $hadOriginalRealmlist = Test-Path -LiteralPath $realmlistPath -PathType Leaf + $realmlistBackupPath = $null + if ($hadOriginalRealmlist) { + $realmlistBackupPath = Join-Path $backupRoot 'realmlist.wtf.original' + Copy-Item -LiteralPath $realmlistPath -Destination $realmlistBackupPath -Force + } + $realmlistState = [pscustomobject]@{ + Path = $realmlistPath + HadOriginal = $hadOriginalRealmlist + BackupPath = $realmlistBackupPath + } + Set-Content -LiteralPath $realmlistPath -Value "set realmlist $Realmlist" -Encoding ASCII + Write-Host " Realmlist: $Realmlist" + + $previousFiles = Get-EffectiveInterfaceFiles -SourceRoots $sourceRoots + + if (-not $NoInitialSync) { + Write-Host "Installing $($previousFiles.Count) loose Interface files..." + foreach ($entry in $previousFiles.GetEnumerator()) { + Install-HotReloadFile ` + -RelativePath $entry.Value.RelativePath ` + -SourcePath $entry.Value.SourcePath ` + -Quiet + } + Write-Host 'Initial Interface overlay is ready.' -ForegroundColor Green + } else { + Write-Host 'Initial sync skipped; only subsequent changes will be overlaid.' + } + + if ($Launch) { + Write-Host 'Launching Wow.exe...' + $launchedProcess = Start-Process ` + -FilePath $wowExecutable ` + -WorkingDirectory $clientRoot ` + -PassThru + Write-Host 'Watching until the launched client exits. Press Ctrl+C to stop earlier.' + } else { + Write-Host 'Watching for changes. Press Ctrl+C to stop and clean the client.' + } + + while ($true) { + Start-Sleep -Milliseconds $PollIntervalMilliseconds + + if ($launchedProcess) { + $launchedProcess.Refresh() + if ($launchedProcess.HasExited) { + Write-Host 'The launched client has exited.' + break + } + } + + $currentFiles = Get-EffectiveInterfaceFiles -SourceRoots $sourceRoots + + foreach ($entry in $currentFiles.GetEnumerator()) { + $relativePath = $entry.Key + $previous = $previousFiles[$relativePath] + if ($null -eq $previous -or $previous.Signature -ne $entry.Value.Signature -or + $previous.SourcePath -ne $entry.Value.SourcePath) { + Install-HotReloadFile ` + -RelativePath $entry.Value.RelativePath ` + -SourcePath $entry.Value.SourcePath + } + } + + foreach ($entry in $previousFiles.GetEnumerator()) { + if (-not $currentFiles.ContainsKey($entry.Key) -and $ownedTargets.ContainsKey($entry.Key)) { + Restore-HotReloadTarget -State $ownedTargets[$entry.Key] + } + } + + $previousFiles = $currentFiles + } +} finally { + Write-Host "Cleaning $($ownedTargets.Count) hot reload files from the client..." + foreach ($state in $ownedTargets.Values) { + try { + Restore-HotReloadTarget -State $state -Quiet + } catch { + $cleanupFailed = $true + Write-Warning "Could not clean Interface\$($state.RelativePath): $($_.Exception.Message)" + } + } + + if ($null -ne $realmlistState) { + try { + if ($realmlistState.HadOriginal) { + Copy-Item ` + -LiteralPath $realmlistState.BackupPath ` + -Destination $realmlistState.Path ` + -Force + } elseif (Test-Path -LiteralPath $realmlistState.Path -PathType Leaf) { + Remove-Item -LiteralPath $realmlistState.Path -Force + } + } catch { + $cleanupFailed = $true + Write-Warning "Could not restore realmlist.wtf: $($_.Exception.Message)" + } + } + + foreach ($directoryPath in @($createdRealmlistDirectories | Sort-Object Length -Descending)) { + try { + if ((Test-Path -LiteralPath $directoryPath -PathType Container) -and + -not @(Get-ChildItem -LiteralPath $directoryPath -Force).Count) { + Remove-Item -LiteralPath $directoryPath -Force + } + } catch { + $cleanupFailed = $true + Write-Warning "Could not remove empty directory $directoryPath" + } + } + + foreach ($directoryPath in @($createdDirectories.Keys | Sort-Object Length -Descending)) { + try { + if ((Test-Path -LiteralPath $directoryPath -PathType Container) -and + -not @(Get-ChildItem -LiteralPath $directoryPath -Force).Count) { + Remove-Item -LiteralPath $directoryPath -Force + } + } catch { + $cleanupFailed = $true + Write-Warning "Could not remove empty directory $directoryPath" + } + } + + if ($cleanupFailed) { + Write-Warning "Cleanup was incomplete. Original-file backups were kept in: $backupRoot" + } else { + if (Test-Path -LiteralPath $backupRoot) { + $resolvedBackupRoot = (Resolve-Path -LiteralPath $backupRoot).Path + if (-not $resolvedBackupRoot.Equals( + $backupRoot, + [System.StringComparison]::OrdinalIgnoreCase + ) -or + -not $resolvedBackupRoot.StartsWith( + $systemTempPrefix, + [System.StringComparison]::OrdinalIgnoreCase + )) { + throw "Refusing to remove unexpected backup path: $resolvedBackupRoot" + } + Remove-Item -LiteralPath $resolvedBackupRoot -Recurse -Force + } + Write-Host 'Hot reload overlay removed; original client files restored.' -ForegroundColor Green + } +} diff --git a/src/Data/ruRU/patch-ruRU-5/Interface/AddOns/DragonUI_NewEra/modules/character/Honor.lua b/src/Data/ruRU/patch-ruRU-5/Interface/AddOns/DragonUI_NewEra/modules/character/Honor.lua index a7adff4..683e8fe 100644 --- a/src/Data/ruRU/patch-ruRU-5/Interface/AddOns/DragonUI_NewEra/modules/character/Honor.lua +++ b/src/Data/ruRU/patch-ruRU-5/Interface/AddOns/DragonUI_NewEra/modules/character/Honor.lua @@ -336,13 +336,13 @@ local function applyRank() elseif progress and progress > 0 then rankBlock.icon:Hide() anchorTitle(false) - rankBlock.title:SetText(L("PVP_UNRANKED", "Unranked")) + rankBlock.title:SetText(L("PVP_UNRANKED", "Без ранга")) rankBlock.bar:Show() rankBlock.hint:Hide() else rankBlock.icon:Hide() anchorTitle(false) - rankBlock.title:SetText(L("PVP_UNRANKED", "Unranked")) + rankBlock.title:SetText(L("PVP_UNRANKED", "Без ранга")) rankBlock.bar:Hide() rankBlock.hint:Show() end diff --git a/vendor/warcraftxl b/vendor/warcraftxl index 1508beb..083a625 160000 --- a/vendor/warcraftxl +++ b/vendor/warcraftxl @@ -1 +1 @@ -Subproject commit 1508beb6ec1eeb337a243f22fe6fe27deb9a8cf0 +Subproject commit 083a62568c8c0b9d88baef3fce38576d2be0e820