This commit is contained in:
2026-08-22 16:15:43 +04:00
parent ae9baeee02
commit d4e553d781
4 changed files with 412 additions and 3 deletions
+21
View File
@@ -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-пакеты
+388
View File
@@ -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
}
}
@@ -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