Merge branch 'master' into dragonflight-ui-as-base-blizzard-ui

This commit is contained in:
2026-08-17 20:36:23 +04:00
23 changed files with 1510 additions and 54 deletions
+1
View File
@@ -5,6 +5,7 @@ manifest.json
.vscode
dist
build/
/new customization mpqs/*.mpq
Wow*.exe
*.backup.exe
Logs/
+2 -1
View File
@@ -15,7 +15,8 @@
url = https://github.com/WarcraftXL/wxl-db2.git
[submodule "vendor/modules/wxl-modern-m2"]
path = vendor/modules/wxl-modern-m2
url = https://github.com/WarcraftXL/wxl-modern-m2.git
url = https://github.com/sindoring/wxl-modern-m2.git
branch = agent/guard-oversized-m2-batches
[submodule "vendor/modules/wxl-modern-wmo"]
path = vendor/modules/wxl-modern-wmo
url = https://github.com/WarcraftXL/wxl-modern-wmo.git
+355
View File
@@ -0,0 +1,355 @@
# Совместимость CVPatch и расширенной кастомизации
Документ описывает фактический набор графических MPQ MoonWell для клиента
**WoW 3.3.5a, build 12340**, их порядок загрузки, конфликты DBC, сборку слоя
совместимости и серверные требования парикмахерской.
## Исходные данные и происхождение
Старые архивы `patch-ruRU-A..W` относятся к **CVPatch (Collection Visual
Patch)** от Vary/vvladoon. Найденная тема оригинального проекта:
<https://forum.uwow.biz/threads/cvpatch-collection-visual-patch-lich-king-3-3-5.296264/>
Источник новых архивов утрачен. Единственная сохранённая подпись находится в
`new customization mpqs/readme.txt`:
- `patch-a-001.mpq``Model and Textures NPC`;
- `patch-b-002.mpq``Characters HD The War Within`;
- `patch-k.mpq``Interface`.
Новые архивы нельзя считать официальным или полностью автономным комплектом
для 3.3.5a: в них отсутствовал совместимый `BarberShopStyle.dbc`, а часть DBC
содержит данные более новых версий игры.
## Установленная часть CVPatch
| Патч | Назначение | Зависимости и пересечения |
|---|---|---|
| A | Основные модели существ, гуманоидов и транспорта | Основа для старого B |
| B | Альтернативные модели существ, гуманоидов и транспорта | Требует A |
| E | Текстуры экипировки в повышенном разрешении | Независимый |
| H | HD-модели игровых персонажей Leeviathan | Перекрывается новыми X/Y на одинаковых путях |
| I | Новые модели и текстуры брони и оружия | Изначально требовал H; его item-поля сохраняются при слиянии |
| M | Карты классических подземелий и пещер | Независимый |
| S | Эффекты заклинаний и способностей | Содержит собственные spell DBC |
| T | Текстуры зданий и зон | Независимый |
| U | Окружение Низины Арати и Ущелья Песни Войны | Независимый |
| W | Вода | Независимый |
Старые буквенные имена не меняются. X, Y и Z специально выбраны после W,
чтобы новый комплект и исправления имели приоритет без переименования CVPatch.
## Новые исходные архивы
### `patch-a-001.mpq` → `patch-ruRU-X.MPQ`
Размер исходника — около 2,49 GiB, `(listfile)` содержит 29 495 файлов:
- 16 023 запечённых NPC-текстуры в `Textures\bakednpctextures`;
- 13 465 файлов моделей, skin, анимаций и текстур в `Character`;
- модели стандартных рас и ряда NPC-рас: Vrykul, Naga, Taunka, Forest Troll и
других;
- 28 120 BLP, 1 245 ANIM, 94 SKIN и 29 M2;
- 7 DBC.
DBC внутри X:
| DBC | Строк | Роль |
|---|---:|---|
| `CharacterFacialHairStyles.dbc` | 272 | Штатный объём лицевой кастомизации |
| `CharHairGeosets.dbc` | 370 | Штатный объём причёсок |
| `CharSections.dbc` | 10 060 | Секции текстур персонажей |
| `CreatureDisplayInfoExtra.dbc` | 15 477 | Дополнительные параметры отображения существ |
| `EmotesTextSound.dbc` | 782 | Звуки текстовых эмоций |
| `HelmetGeosetVisData.dbc` | 21 | Скрытие geoset под шлемами |
| `SpellVisualKitModelAttach.dbc` | 594 | Крепление моделей визуальных эффектов |
X нужен текущему комбинированному комплекту для NPC-моделей и запечённых
текстур. Для новых причёсок главным является Y, который загружается позже и
перекрывает одноимённые таблицы X.
### `patch-b-002.mpq` → `patch-ruRU-Y.MPQ`
Размер исходника — около 2,80 GiB, `(listfile)` содержит 36 191 файл:
- 36 180 файлов в `Character`;
- HD-модели и текстуры десяти стандартных игровых рас WotLK;
- 34 934 BLP, 1 021 ANIM, 125 BONE, 80 SKIN и 20 M2;
- 11 DBC с существенно расширенной кастомизацией.
DBC внутри Y:
| DBC | Строк | Роль |
|---|---:|---|
| `CharacterFacialHairStyles.dbc` | 3 616 | Бороды, серьги, рога и другие лицевые варианты |
| `CharHairGeosets.dbc` | 854 | Geoset и номера причёсок |
| `CharHairTextures.dbc` | 177 | Текстуры волос |
| `CharSections.dbc` | 614 212 | Расширенные секции кожи, лица, волос и их сочетаний |
| `CharVariations.dbc` | 62 | Наборы вариаций персонажей |
| `ChrRaces.dbc` | 41 | Описания рас, включая данные за пределами штатного WotLK |
| `CreatureDisplayInfo.dbc` | 24 282 | Display ID моделей |
| `CreatureDisplayInfoExtra.dbc` | 15 477 | Раса, пол, внешность и предметные поля display ID |
| `CreatureFamily.dbc` | 40 | Семейства существ |
| `CreatureModelData.dbc` | 1 353 | Пути и параметры моделей |
| `HelmetGeosetVisData.dbc` | 65 | Расширенные правила отображения под шлемами |
Y обязателен для расширенной кастомизации. Z не содержит копии всех этих
таблиц и не заменяет Y.
Наличие современных race ID в DBC не добавляет новые игровые расы в ядро или
интерфейс 3.3.5a. Текущая интеграция поддерживает расширение внешности десяти
штатных рас. Новые игровые расы потребуют отдельной доработки клиента, UI,
сетевого протокола и сервера.
### `patch-k.mpq` — не устанавливается
Размер — около 53 MiB, `(listfile)` содержит 673 файла только в `Interface`:
- 547 BLP;
- 68 SKIN и 24 M2 для экранов входа и выбора персонажа;
- 15 Lua, 12 XML, TOC и вспомогательные файлы.
Архив меняет `Interface\Glues`, `Interface\GlueXML`, модели экранов входа и
создания персонажа. Он намеренно исключён из сборки, потому что перекрывает
кастомный интерфейс MoonWell. Устанавливать его вручную поверх текущего клиента
нельзя без отдельного разбора и слияния UI.
## Итоговый порядок загрузки
В локализованной цепочке `Data\ruRU` используется следующий порядок:
1. штатные архивы клиента, включая `patch-ruRU-3.MPQ`;
2. старые CVPatch `patch-ruRU-A..W`;
3. `patch-ruRU-X.MPQ` — неизменённая копия `patch-a-001.mpq`;
4. `patch-ruRU-Y.MPQ` — неизменённая копия `patch-b-002.mpq`;
5. `patch-ruRU-Z.MPQ` — сгенерированный слой совместимости MoonWell.
Z является последней инстанцией для конфликтующих DBC. Нельзя переносить его
раньше X/Y или устанавливать другой locale MPQ после Z, который содержит те же
таблицы.
## Содержимое `patch-ruRU-Z.MPQ`
В текущей сборке Z содержит:
- `BarberShopStyle.dbc` — расширенная таблица парикмахерской;
- `CreatureDisplayInfoExtra.dbc` — результат слияния новых display ID и
предметных полей CVPatch I;
- `SpellVisualKitModelAttach.dbc` — более полная версия из CVPatch S;
- `CreatureModelData.dbc` — сгенерированное слияние штатных, CVPatch и новых
моделей;
- `CreatureDisplayInfo.dbc` — сгенерированное слияние display ID;
- `WXLFileData.csv` и `WXLSpellOverrides.tsv` для WarcraftXL.
Последние четыре файла добавляются упаковщиком автоматически; поэтому состав
папки `src\Data\ruRU\patch-ruRU-Z` и готового MPQ не обязан совпадать буквально.
### Правила слияния DBC
`tool/scripts/build-customization-compat.ps1` выполняет следующие операции:
- берёт штатную базу из `patch-ruRU-3.MPQ`;
- сохраняет уникальные изменения `CreatureModelData` из CVPatch A;
- сохраняет уникальные изменения `CreatureDisplayInfo` из CVPatch B;
- отдаёт приоритет Y при реальном конфликте одной и той же строки;
- переносит изменённые item-поля `CreatureDisplayInfoExtra` из CVPatch I в
новые строки Y;
- восстанавливает `SpellVisualKitModelAttach.dbc` из CVPatch S;
- генерирует расширенный `BarberShopStyle.dbc`;
- обновляет входные `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc` для
генератора MoonWell creatures.
Так Z не позволяет Y случайно уничтожить старые изменения существ, экипировки
и эффектов, а генерация MoonWell creatures не уничтожает новые display ID.
## Расширенная парикмахерская
### Причина исходного падения
Y расширил:
- `CharHairGeosets.dbc` с 370 до 854 строк;
- `CharacterFacialHairStyles.dbc` с 272 до 3 616 строк.
При этом исходные новые MPQ не содержали `BarberShopStyle.dbc`. Клиент продолжал
использовать штатную таблицу на 491 строку. При выборе нового варианта функция
`GetBarberShopTotalCost` не находила запись, после чего WoW падал с
`ERROR #132 / ACCESS_VIOLATION`. В зафиксированном крэше вызов шёл от
`BarberShopFrameSelector1Next`.
### Как сформирована новая таблица
Генератор:
- сохраняет все 491 штатную строку и исходную строковую таблицу байт-в-байт;
- добавляет отсутствующие причёски из `CharHairGeosets.dbc` Y;
- добавляет отсутствующую растительность и украшения из
`CharacterFacialHairStyles.dbc` Y;
- работает только с сочетаниями расы, пола и типа, уже существующими в штатном
`BarberShopStyle.dbc`, то есть с поддерживаемыми расами WotLK;
- сохраняет штатные строки типа 3 для особой кастомизации рыцарей смерти;
- назначает новые уникальные ID после максимального штатного ID;
- проверяет отсутствие дубликатов и полноту покрытия.
Результат:
- 3 765 строк всего;
- 152 новые причёски;
- 3 122 новых варианта растительности, серёг и других особенностей;
- максимальный номер лицевого варианта — 255, что помещается в поле внешности
персонажа 3.3.5a.
В исходном комплекте нет названий новых стилей. Поэтому сейчас они отображаются
как `New hairstyle N` и `New appearance N`. Это только подписи: на выбор модели
и сохранение внешности они не влияют.
## Что требуется клиенту и серверу
| Компонент | Клиент | Сервер |
|---|---|---|
| `patch-ruRU-X.MPQ` | Да, для текущего комплекта NPC/персонажей | Нет |
| `patch-ruRU-Y.MPQ` | Да, для моделей, текстур и основных customization DBC | Нет |
| `patch-ruRU-Z.MPQ` | Да, для совместимости и исправления парикмахерской | Нет как MPQ |
| Сгенерированный `BarberShopStyle.dbc` | Уже находится внутри Z | Да, отдельным DBC |
| Остальные клиентские DBC из X/Y | Загружаются из MPQ | Для текущих десяти рас не копируются |
Серверную копию нужно положить в каталог DBC worldserver, обычно:
```text
<server>/data/dbc/BarberShopStyle.dbc
```
После замены требуется полный перезапуск worldserver: DBC загружаются при
старте. Клиентская и серверная копии `BarberShopStyle.dbc` должны быть одним и
тем же сгенерированным файлом. Без серверной копии клиент больше не должен
падать при переключении, но сервер может отклонить применение нового стиля или
неверно рассчитать его стоимость.
Готовая серверная копия после запуска генератора находится в:
```text
build/customization-compat/server-dbc/BarberShopStyle.dbc
```
Тот же отслеживаемый исходник для Z находится в:
```text
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc
```
Не следует копировать на сервер `ChrRaces.dbc`, `CharSections.dbc` или весь
набор DBC из Y без отдельной задачи и проверки ядра.
## Сборка
### Предварительные условия
В `.env` должен быть задан корректный `WOW_HOME`. В клиенте должны находиться:
- штатный `Data\ruRU\patch-ruRU-3.MPQ`;
- CVPatch A, B, I и S, используемые генератором как источники слияния;
- остальные CVPatch, если они должны остаться в итоговой установке.
В репозитории должны оставаться:
- `new customization mpqs\patch-a-001.mpq`;
- `new customization mpqs\patch-b-002.mpq`;
- `tool\target\release\mpqread.exe`.
### Пересоздание DBC совместимости
```powershell
.\tool\scripts\build-customization-compat.ps1
```
Скрипт обновляет:
```text
assets/dbc/3.3.5a/CreatureModelData.dbc
assets/dbc/3.3.5a/CreatureDisplayInfo.dbc
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/CreatureDisplayInfoExtra.dbc
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/SpellVisualKitModelAttach.dbc
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc
build/customization-compat/server-dbc/BarberShopStyle.dbc
```
### Упаковка MPQ
```powershell
.\tool\target\release\tool.exe .\src .\dist
```
Упаковщик:
- собирает Z из исходников и сгенерированных MoonWell DBC;
- не копирует внешние графические X/Y в `dist` и не включает их в managed manifest;
- не добавляет `patch-k.mpq`.
Для полной локальной сборки и синхронизации клиента можно использовать:
```powershell
.\run.ps1 -Env local
```
Этот сценарий также собирает WarcraftXL, останавливает блокирующие процессы и
синхронизирует весь `dist` в `WOW_HOME`. Для проверки только изменения Z можно
собрать архив упаковщиком и скопировать лишь
`dist\Data\ruRU\patch-ruRU-Z.MPQ`.
Сборка этих архивов не должна автоматически изменять `manifest.json`. Манифест
обновляется владельцем проекта отдельно после локальной проверки.
## Исходники MPQ и установленные копии
Файлы в `new customization mpqs` используются только как локальные источники
генератора таблиц совместимости. В готовом клиенте X и Y управляются отдельно от
репозиторных патчей.
Списки репозиторных и внешних графических патчей находятся в
`patch-layout.json`. `deploy.ps1`, `run.ps1`, генератор манифеста и S3 uploader
синхронизируют только список `repositoryPatches`. Пути из `graphicsPatches`
сохраняются без копирования и удаления. Если один путь указан в обоих списках,
`repositoryPatches` имеет приоритет.
Z не консолидирует многогигабайтные модели и текстуры X/Y. Удаление X или Y из
клиента не освобождается наличием текущих DBC в Z.
## Проверка после сборки
Минимальный сценарий локальной проверки:
1. Полностью закрыть WoW перед заменой MPQ.
2. Проверить наличие X, Y и Z в `Data\ruRU`.
3. Запустить клиент и открыть создание персонажа.
4. Проверить оба пола и основные расы, пролистать причёски и лицевые варианты.
5. Создать тестового персонажа, войти в мир и проверить модель после повторного
входа.
6. Открыть парикмахерскую и несколько раз нажать вперёд/назад у каждого
селектора.
7. Проверить отображение стоимости и отсутствие `ERROR #132`.
8. Применить новый стиль, выйти из мира и войти снова.
9. Проверить старую броню, NPC-модели и эффекты заклинаний, которые могли быть
затронуты конфликтующими DBC.
Для шага 8 сервер уже должен использовать сгенерированный
`BarberShopStyle.dbc`.
## Диагностика
| Симптом | Вероятная причина |
|---|---|
| Новых причёсок нет | Отсутствует Y или более поздний MPQ перекрыл его `CharHairGeosets`/`CharSections` |
| Модель отсутствует или отображается неверно | X/Y не скопированы полностью либо повреждены многогигабайтные архивы |
| Падение при переключении в парикмахерской | Z отсутствует, загружен раньше Y или его `BarberShopStyle.dbc` перекрыт |
| Вариант выбирается, но не применяется | На сервере остался штатный `BarberShopStyle.dbc` или worldserver не перезапущен |
| Исчезли старые NPC/display ID | Z не пересобран после изменения источников CVPatch или Y |
| Проблемы со старой экипировкой | Потеряны item-поля CVPatch I в `CreatureDisplayInfoExtra.dbc` |
| Изменился экран входа/создания персонажа | `patch-k.mpq` был установлен вручную |
Перед заменой рабочего Z рекомендуется сохранять предыдущий архив. Последняя
локальная резервная копия, созданная во время исправления парикмахерской:
```text
build/customization-compat/client-backup/patch-ruRU-Z.before-barbershop.MPQ
```
+5
View File
@@ -135,6 +135,11 @@ DragonUI_NewEra и его `!!!ClassicAPI` входят в системный и
Зафиксированные upstream-версии, состав поставки и решение конфликта двух
Encounter Journal описаны в [`DRAGONUI_NEWERA.md`](DRAGONUI_NEWERA.md).
Управляемые репозиторием MPQ и внешние графические патчи перечислены отдельно в
`patch-layout.json`. Сборка и deploy синхронизируют только `repositoryPatches` и
не изменяют `graphicsPatches`. При пересечении списков репозиторный патч имеет
приоритет.
## Лицензирование
WarcraftXL распространяется по GPL-3.0 и подключён как отдельный неизменённый upstream submodule.
Binary file not shown.
Binary file not shown.
+53 -20
View File
@@ -75,8 +75,53 @@ function Enable-LargeAddressAware {
}
}
function Initialize-MissingSubmodules {
param(
[Parameter(Mandatory)]
[string]$RepositoryPath
)
$modulesFile = Join-Path $RepositoryPath '.gitmodules'
if (-not (Test-Path -LiteralPath $modulesFile -PathType Leaf)) {
return
}
$configuredModules = @(
& git -C $RepositoryPath config --file .gitmodules --get-regexp '^submodule\..*\.path$'
)
if ($LASTEXITCODE -gt 1) {
throw "Failed to read submodules from $modulesFile"
}
foreach ($configuredModule in $configuredModules) {
$fields = $configuredModule -split '\s+', 2
if ($fields.Count -ne 2) {
continue
}
$relativePath = $fields[1].Trim()
$modulePath = Join-Path $RepositoryPath $relativePath
$gitMarker = Join-Path $modulePath '.git'
# Updating an initialized submodule checks out the SHA recorded by its parent and detaches
# any active development branch. Only initialize genuinely missing worktrees.
if (-not (Test-Path -LiteralPath $gitMarker)) {
Write-Host "Initializing missing submodule: $modulePath"
& git -C $RepositoryPath submodule update --init -- $relativePath
if ($LASTEXITCODE -ne 0) {
throw "Failed to initialize submodule: $modulePath"
}
}
if (Test-Path -LiteralPath $modulePath -PathType Container) {
Initialize-MissingSubmodules -RepositoryPath $modulePath
}
}
}
$ErrorActionPreference = 'Stop'
$repoRoot = $PSScriptRoot
. (Join-Path $repoRoot 'pipeline-layout.ps1')
$cmake = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe'
if (-not (Test-Path -LiteralPath $cmake)) {
$cmakeCommand = Get-Command cmake -ErrorAction SilentlyContinue
@@ -84,8 +129,7 @@ if (-not (Test-Path -LiteralPath $cmake)) {
$cmake = $cmakeCommand.Source
}
& git -C $repoRoot submodule update --init --recursive
if ($LASTEXITCODE -ne 0) { throw 'Failed to initialize WarcraftXL submodules.' }
Initialize-MissingSubmodules -RepositoryPath $repoRoot
$win32BuildDir = Join-Path $repoRoot 'build\warcraftxl-win32'
$stockExe = Join-Path $repoRoot 'Wow_Original.exe'
@@ -110,18 +154,7 @@ $win32ArtifactDir = Join-Path $win32BuildDir "vendor\warcraftxl\$Configuration"
$warcraftXL = Join-Path $win32ArtifactDir 'WarcraftXL.dll'
$selectedProxy = Join-Path $win32ArtifactDir 'd3d9.dll'
$recoveryProxy = Join-Path $win32BuildDir "artifacts\$Configuration\d3d9-native.dll"
$extensionNames = @(
'MoonWell',
'wxl-db2',
'wxl-fdid-moonwell',
'wxl-grasswind',
'wxl-modern-adt',
'wxl-modern-blp',
'wxl-modern-m2',
'wxl-modern-wmo',
'wxl-moonwell-storage-fallback',
'wxl-unit-outline'
)
$extensionNames = Get-MoonWellExtensionNames
if ($NativeRenderer) {
Write-Warning '-NativeRenderer is no longer needed: WarcraftXL 1.1 uses native D3D9 by default.'
@@ -133,6 +166,8 @@ function Install-WarcraftXLArtifacts {
[string]$Destination
)
Remove-MoonWellRuntimeGarbage -Destination $Destination
$utils = Join-Path $Destination 'Utils'
New-Item `
-ItemType Directory `
@@ -140,12 +175,6 @@ function Install-WarcraftXLArtifacts {
-Force |
Out-Null
$legacyHost = Join-Path $utils 'WarcraftXLHost.exe'
if (Test-Path -LiteralPath $legacyHost -PathType Leaf) {
Remove-Item -LiteralPath $legacyHost -Force
Write-Host "Removed obsolete WarcraftXL 1.0 host: $legacyHost"
}
$destinationExe = Join-Path $Destination 'Wow.exe'
Copy-Item `
@@ -208,6 +237,10 @@ if ($Deploy) {
if (-not [string]::IsNullOrWhiteSpace($PackagePath)) {
$PackagePath = [System.IO.Path]::GetFullPath($PackagePath)
Assert-SafePackagePath `
-PackagePath $PackagePath `
-RepositoryPath $repoRoot `
-ClientPath $ClientPath
Install-WarcraftXLArtifacts -Destination $PackagePath
Write-Host "Packaged stock Wow.exe and WarcraftXL artifacts in $PackagePath"
}
+4
View File
@@ -9,6 +9,8 @@ import sys
from pathlib import Path
from typing import Optional
from patch_layout import PATCH_LAYOUT
IGNORED_TOP_LEVEL_DIRS = {
"Cache",
@@ -49,6 +51,8 @@ def is_ignored(rel_path: Path) -> bool:
return True
if len(rel_path.parts) == 1 and rel_path.name in IGNORED_TOP_LEVEL_FILES:
return True
if PATCH_LAYOUT.is_external_graphics_patch(rel_path.as_posix()):
return True
return any(part in IGNORED_DIRS_ANYWHERE for part in rel_path.parts[:-1])
+31 -6
View File
@@ -13,6 +13,7 @@ param(
$ErrorActionPreference = 'Stop'
$repoRoot = $PSScriptRoot
. (Join-Path $repoRoot 'pipeline-layout.ps1')
function Import-ProjectEnvironment {
$envFile = Join-Path $repoRoot '.env'
@@ -46,6 +47,10 @@ $PackagePath = [System.IO.Path]::GetFullPath($PackagePath)
if (-not (Test-Path -LiteralPath $ClientPath -PathType Container)) {
throw "Client directory was not found: $ClientPath"
}
Assert-SafePackagePath `
-PackagePath $PackagePath `
-RepositoryPath $repoRoot `
-ClientPath $ClientPath
$runningClients = @(Get-Process Wow -ErrorAction SilentlyContinue | Where-Object {
$_.Path -and $_.Path.StartsWith($ClientPath, [System.StringComparison]::OrdinalIgnoreCase)
@@ -64,11 +69,21 @@ $env:WOW_HOME = $ClientPath
Write-Host "MoonWell client: $ClientPath"
Write-Host "Package staging: $PackagePath"
Write-Host '[1/5] Initializing WarcraftXL dependencies...'
& git -C $repoRoot submodule update --init --recursive
if ($LASTEXITCODE -ne 0) { throw 'Git submodule initialization failed.' }
Write-Host '[1/5] Preserving initialized WarcraftXL worktrees...'
New-Item -ItemType Directory -Path $PackagePath -Force | Out-Null
$previousPackageFiles = @(
Get-RelativeFilePaths -Root $PackagePath
)
if (-not $SkipDataBuild) {
Write-Host 'Resetting package staging to prevent stale build artifacts...'
Reset-MoonWellPackageStaging `
-PackagePath $PackagePath `
-RepositoryPath $repoRoot `
-ClientPath $ClientPath
} else {
New-Item -ItemType Directory -Path $PackagePath -Force | Out-Null
Remove-MoonWellRuntimeGarbage -Destination $PackagePath
}
if (-not $SkipDataBuild) {
Write-Host '[2/5] Building MPQ patches...'
@@ -99,6 +114,18 @@ if ($NativeRenderer) { $buildArguments.NativeRenderer = $true }
if ($LASTEXITCODE -ne 0) { throw 'WarcraftXL deployment failed.' }
Write-Host '[4/5] Synchronizing package files...'
$python = Get-Command python -ErrorAction SilentlyContinue
if (-not $python) { throw 'Python was not found; package validation cannot run.' }
& $python.Source (Join-Path $repoRoot 'validate_package.py') --dir $PackagePath
if ($LASTEXITCODE -ne 0) { throw 'Package validation failed.' }
$currentPackageFiles = @(Get-RelativeFilePaths -Root $PackagePath)
Remove-StalePackageFilesFromClient `
-ClientPath $ClientPath `
-PreviousFiles $previousPackageFiles `
-CurrentFiles $currentPackageFiles
Remove-MoonWellRuntimeGarbage -Destination $ClientPath
& robocopy $PackagePath $ClientPath /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP
$robocopyExitCode = $LASTEXITCODE
if ($robocopyExitCode -ge 8) {
@@ -107,8 +134,6 @@ if ($robocopyExitCode -ge 8) {
if (-not $SkipManifest) {
Write-Host '[5/5] Building launcher manifest...'
$python = Get-Command python -ErrorAction SilentlyContinue
if (-not $python) { throw 'Python was not found; launcher manifest was not generated.' }
& $python.Source (Join-Path $repoRoot 'build_manifest.py') `
--dir $PackagePath --output (Join-Path $repoRoot 'manifest.json')
if ($LASTEXITCODE -ne 0) { throw 'Launcher manifest generation failed.' }
+26
View File
@@ -0,0 +1,26 @@
{
"repositoryPatches": [
"Data/patch-4.MPQ",
"Data/patch-Z.MPQ",
"Data/ruRU/patch-ruRU-4.MPQ",
"Data/ruRU/patch-ruRU-5.MPQ",
"Data/ruRU/patch-ruRU-Z.MPQ"
],
"graphicsPatches": [
"Data/patch-X.MPQ",
"Data/ruRU/backup-ruRU.MPQ",
"Data/ruRU/patch-ruRU-6.MPQ",
"Data/ruRU/patch-ruRU-A.mpq",
"Data/ruRU/patch-ruRU-B.mpq",
"Data/ruRU/patch-ruRU-E.mpq",
"Data/ruRU/patch-ruRU-H.MPQ",
"Data/ruRU/patch-ruRU-I.mpq",
"Data/ruRU/patch-ruRU-M.MPQ",
"Data/ruRU/patch-ruRU-S.mpq",
"Data/ruRU/patch-ruRU-T.mpq",
"Data/ruRU/patch-ruRU-U.mpq",
"Data/ruRU/patch-ruRU-W.mpq",
"Data/ruRU/patch-ruRU-X.MPQ",
"Data/ruRU/patch-ruRU-Y.MPQ"
]
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import json
from dataclasses import dataclass
from pathlib import Path
def normalized_path(path: str) -> str:
return path.replace("\\", "/").casefold()
@dataclass(frozen=True)
class PatchLayout:
repository_patches: frozenset[str]
graphics_patches: frozenset[str]
def is_repository_patch(self, path: str) -> bool:
return normalized_path(path) in self.repository_patches
def is_graphics_patch(self, path: str) -> bool:
return normalized_path(path) in self.graphics_patches
def is_external_graphics_patch(self, path: str) -> bool:
key = normalized_path(path)
return key in self.graphics_patches and key not in self.repository_patches
def load_patch_layout(path: Path | None = None) -> PatchLayout:
config_path = path or Path(__file__).resolve().with_name("patch-layout.json")
data = json.loads(config_path.read_text(encoding="utf-8"))
repository = frozenset(normalized_path(item) for item in data["repositoryPatches"])
graphics = frozenset(normalized_path(item) for item in data["graphicsPatches"])
return PatchLayout(repository_patches=repository, graphics_patches=graphics)
PATCH_LAYOUT = load_patch_layout()
+241
View File
@@ -0,0 +1,241 @@
$script:MoonWellExtensionNames = @(
'MoonWell',
'wxl-db2',
'wxl-fdid-moonwell',
'wxl-grasswind',
'wxl-modern-adt',
'wxl-modern-blp',
'wxl-modern-m2',
'wxl-modern-wmo',
'wxl-moonwell-storage-fallback',
'wxl-unit-outline'
)
$patchLayoutPath = Join-Path $PSScriptRoot 'patch-layout.json'
if (-not (Test-Path -LiteralPath $patchLayoutPath -PathType Leaf)) {
throw "Patch layout was not found: $patchLayoutPath"
}
$patchLayout = Get-Content -LiteralPath $patchLayoutPath -Raw -Encoding UTF8 | ConvertFrom-Json
$script:MoonWellRepositoryPatches = @($patchLayout.repositoryPatches)
$script:MoonWellGraphicsPatches = @($patchLayout.graphicsPatches)
function Get-MoonWellExtensionNames {
return @($script:MoonWellExtensionNames)
}
function Get-MoonWellRepositoryPatchPaths {
return @($script:MoonWellRepositoryPatches)
}
function Get-MoonWellGraphicsPatchPaths {
return @($script:MoonWellGraphicsPatches)
}
function Test-MoonWellRepositoryPatch {
param([Parameter(Mandatory)][string]$RelativePath)
foreach ($repositoryPatch in $script:MoonWellRepositoryPatches) {
if ($RelativePath.Replace('/', '\').Equals(
$repositoryPatch.Replace('/', '\'),
[System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
function Test-MoonWellGraphicsPatch {
param([Parameter(Mandatory)][string]$RelativePath)
foreach ($graphicsPatch in $script:MoonWellGraphicsPatches) {
if ($RelativePath.Replace('/', '\').Equals(
$graphicsPatch.Replace('/', '\'),
[System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
function Test-MoonWellPreservedDataPatch {
param([Parameter(Mandatory)][string]$RelativePath)
if (Test-MoonWellRepositoryPatch -RelativePath $RelativePath) {
return $false
}
return Test-MoonWellGraphicsPatch -RelativePath $RelativePath
}
function Assert-SafePackagePath {
param(
[Parameter(Mandatory)]
[string]$PackagePath,
[Parameter(Mandatory)]
[string]$RepositoryPath,
[Parameter()]
[string]$ClientPath
)
$packageRoot = [System.IO.Path]::GetFullPath($PackagePath).TrimEnd('\')
$repositoryRoot = [System.IO.Path]::GetFullPath($RepositoryPath).TrimEnd('\')
$pathRoot = [System.IO.Path]::GetPathRoot($packageRoot).TrimEnd('\')
if (-not $packageRoot -or $packageRoot -eq $pathRoot) {
throw "Refusing to use a filesystem root as package staging: $packageRoot"
}
if ($packageRoot.Equals($repositoryRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
$repositoryRoot.StartsWith($packageRoot + '\', [System.StringComparison]::OrdinalIgnoreCase)) {
throw 'Package staging must not be the repository root or one of its ancestors.'
}
if (-not [string]::IsNullOrWhiteSpace($ClientPath)) {
$clientRoot = [System.IO.Path]::GetFullPath($ClientPath).TrimEnd('\')
$packageIsClientOrChild =
$packageRoot.Equals($clientRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
$packageRoot.StartsWith($clientRoot + '\', [System.StringComparison]::OrdinalIgnoreCase)
$packageContainsClient =
$clientRoot.StartsWith($packageRoot + '\', [System.StringComparison]::OrdinalIgnoreCase)
if ($packageIsClientOrChild -or $packageContainsClient) {
throw 'Package staging must be separate from the installed client tree.'
}
}
}
function Get-RelativeFilePaths {
param([Parameter(Mandatory)][string]$Root)
if (-not (Test-Path -LiteralPath $Root -PathType Container)) {
return @()
}
$absoluteRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\')
return @(
Get-ChildItem -LiteralPath $absoluteRoot -Recurse -Force -File |
ForEach-Object { $_.FullName.Substring($absoluteRoot.Length + 1) }
)
}
function Remove-EmptyMoonWellDirectories {
param([Parameter(Mandatory)][string]$Destination)
foreach ($relativeRoot in @('Data\Patch-WXL.MPQ', 'Extensions', 'Utils')) {
$managedRoot = Join-Path $Destination $relativeRoot
if (-not (Test-Path -LiteralPath $managedRoot -PathType Container)) {
continue
}
$directories = @(
Get-ChildItem -LiteralPath $managedRoot -Recurse -Force -Directory |
Sort-Object { $_.FullName.Length } -Descending
)
$directories += Get-Item -LiteralPath $managedRoot
foreach ($directory in $directories) {
if (-not @(Get-ChildItem -LiteralPath $directory.FullName -Force).Count) {
Remove-Item -LiteralPath $directory.FullName -Force
Write-Host "Removed empty managed directory: $($directory.FullName)"
}
}
}
}
function Reset-MoonWellPackageStaging {
param(
[Parameter(Mandatory)][string]$PackagePath,
[Parameter(Mandatory)][string]$RepositoryPath,
[Parameter(Mandatory)][string]$ClientPath
)
Assert-SafePackagePath `
-PackagePath $PackagePath `
-RepositoryPath $RepositoryPath `
-ClientPath $ClientPath
if (Test-Path -LiteralPath $PackagePath) {
Remove-Item -LiteralPath $PackagePath -Recurse -Force
}
New-Item -ItemType Directory -Path $PackagePath -Force | Out-Null
}
function Remove-MoonWellRuntimeGarbage {
param([Parameter(Mandatory)][string]$Destination)
if (-not (Test-Path -LiteralPath $Destination -PathType Container)) {
return
}
$allowedExtensions = @{}
foreach ($name in $script:MoonWellExtensionNames) {
$allowedExtensions[$name.ToLowerInvariant()] = $true
}
$extensionsRoot = Join-Path $Destination 'Extensions'
if (Test-Path -LiteralPath $extensionsRoot -PathType Container) {
foreach ($entry in Get-ChildItem -LiteralPath $extensionsRoot -Force) {
$isAllowedDirectory = $entry.PSIsContainer -and
$allowedExtensions.ContainsKey($entry.Name.ToLowerInvariant())
if (-not $isAllowedDirectory) {
Remove-Item -LiteralPath $entry.FullName -Recurse -Force
Write-Host "Removed unmanaged WarcraftXL extension artifact: $($entry.FullName)"
continue
}
foreach ($dll in Get-ChildItem -LiteralPath $entry.FullName -Force -File -Filter '*.dll') {
$expectedName = "$($entry.Name).dll"
if (-not $dll.Name.Equals($expectedName, [System.StringComparison]::OrdinalIgnoreCase)) {
Remove-Item -LiteralPath $dll.FullName -Force
Write-Host "Removed stale extension DLL: $($dll.FullName)"
}
}
}
}
foreach ($dll in Get-ChildItem -LiteralPath $Destination -Force -File -Filter 'WarcraftXL*.dll') {
if (-not $dll.Name.Equals('WarcraftXL.dll', [System.StringComparison]::OrdinalIgnoreCase)) {
Remove-Item -LiteralPath $dll.FullName -Force
Write-Host "Removed stale WarcraftXL DLL: $($dll.FullName)"
}
}
$utilsRoot = Join-Path $Destination 'Utils'
if (Test-Path -LiteralPath $utilsRoot -PathType Container) {
foreach ($legacyFile in Get-ChildItem -LiteralPath $utilsRoot -Force -File -Filter 'WarcraftXLHost*') {
Remove-Item -LiteralPath $legacyFile.FullName -Force
Write-Host "Removed obsolete WarcraftXL 1.0 host artifact: $($legacyFile.FullName)"
}
}
Remove-EmptyMoonWellDirectories -Destination $Destination
}
function Remove-StalePackageFilesFromClient {
param(
[Parameter(Mandatory)][string]$ClientPath,
[Parameter(Mandatory)][AllowEmptyCollection()][string[]]$PreviousFiles,
[Parameter(Mandatory)][AllowEmptyCollection()][string[]]$CurrentFiles
)
$current = @{}
foreach ($relativePath in $CurrentFiles) {
$current[$relativePath.ToLowerInvariant()] = $true
}
$clientRoot = [System.IO.Path]::GetFullPath($ClientPath).TrimEnd('\')
foreach ($relativePath in $PreviousFiles) {
if ($current.ContainsKey($relativePath.ToLowerInvariant()) -or
(Test-MoonWellPreservedDataPatch -RelativePath $relativePath)) {
continue
}
$target = [System.IO.Path]::GetFullPath((Join-Path $clientRoot $relativePath))
if (-not $target.StartsWith($clientRoot + '\', [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Package inventory contains a path outside the client: $relativePath"
}
if (Test-Path -LiteralPath $target -PathType Leaf) {
Remove-Item -LiteralPath $target -Force
Write-Host "Removed stale packaged file from client: $target"
}
}
Remove-EmptyMoonWellDirectories -Destination $ClientPath
}
+30 -1
View File
@@ -9,6 +9,7 @@ $ErrorActionPreference = "Stop"
# --- Resolve project root (folder where script is located)
$ROOT = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $ROOT 'pipeline-layout.ps1')
# --- Load .env if WOW_HOME not already set
if (-not $env:WOW_HOME) {
@@ -80,6 +81,11 @@ $TOOL = Join-Path $ROOT "tool\target\release\tool.exe"
$RELOAD_SCRIPT = Join-Path $ROOT "reload_wow.bat"
$WXL_BUILD_SCRIPT = Join-Path $ROOT "build-warcraftxl.ps1"
Assert-SafePackagePath `
-PackagePath $DIST_DIR `
-RepositoryPath $ROOT `
-ClientPath $WOW_HOME
function Stop-WowRuntime {
param([Parameter(Mandatory=$true)][string]$ClientPath)
@@ -118,7 +124,14 @@ if (!(Test-Path $TOOL)) {
Pop-Location
}
# --- Build MPQ archives into dist/
# --- Build MPQ archives into a clean dist/
$previousPackageFiles = @(
Get-RelativeFilePaths -Root $DIST_DIR
)
Reset-MoonWellPackageStaging `
-PackagePath $DIST_DIR `
-RepositoryPath $ROOT `
-ClientPath $WOW_HOME
Write-Host "Building MPQ archives from src/ -> dist/..."
& $TOOL $SRC_DIR $DIST_DIR
if ($LASTEXITCODE -ne 0) {
@@ -145,6 +158,22 @@ if ($LASTEXITCODE -ne 0) {
# --- Sync dist/ -> WOW_HOME
Write-Host "Syncing dist/ -> WOW_HOME..."
$python = Get-Command python -ErrorAction SilentlyContinue
if (-not $python) {
Write-Error "Python is required for package validation."
exit 1
}
& $python.Source (Join-Path $ROOT 'validate_package.py') --dir $DIST_DIR
if ($LASTEXITCODE -ne 0) {
Write-Error "Package validation failed!"
exit 1
}
$currentPackageFiles = @(Get-RelativeFilePaths -Root $DIST_DIR)
Remove-StalePackageFilesFromClient `
-ClientPath $WOW_HOME `
-PreviousFiles $previousPackageFiles `
-CurrentFiles $currentPackageFiles
Remove-MoonWellRuntimeGarbage -Destination $WOW_HOME
robocopy $DIST_DIR $WOW_HOME /E /NFL /NDL
# robocopy exit codes 0-7 are success/warnings; 8+ are errors
if ($LASTEXITCODE -ge 8) {
+410
View File
@@ -0,0 +1,410 @@
param(
[string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path,
[string]$ClientPath
)
$ErrorActionPreference = 'Stop'
function Get-EnvValue([string]$Path, [string]$Name) {
$line = Get-Content -LiteralPath $Path | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -First 1
if ($line) { return $line.Substring($Name.Length + 1).Trim() }
return $null
}
if (-not $ClientPath) {
$ClientPath = Get-EnvValue (Join-Path $ProjectRoot '.env') 'WOW_HOME'
}
if (-not $ClientPath -or -not (Test-Path -LiteralPath (Join-Path $ClientPath 'Data\ruRU'))) {
throw 'A valid WoW 3.3.5 client path is required.'
}
$reader = Join-Path $ProjectRoot 'tool\target\release\mpqread.exe'
if (-not (Test-Path -LiteralPath $reader)) {
throw "MPQ reader not found: $reader"
}
$work = Join-Path $ProjectRoot 'build\customization-compat'
New-Item -ItemType Directory -Path $work -Force | Out-Null
function Export-MpqFile([string]$Archive, [string]$VirtualPath, [string]$OutputName) {
$output = Join-Path $work $OutputName
& $reader $Archive $VirtualPath $output 2>$null
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output)) {
throw "Cannot extract $VirtualPath from $Archive"
}
return $output
}
function Read-U32([byte[]]$Bytes, [int]$Offset) {
return [BitConverter]::ToUInt32($Bytes, $Offset)
}
function Write-U32([byte[]]$Bytes, [int]$Field, [uint32]$Value) {
[BitConverter]::GetBytes($Value).CopyTo($Bytes, $Field * 4)
}
function Read-Wdbc([string]$Path) {
[byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
if ($bytes.Length -lt 20 -or [Text.Encoding]::ASCII.GetString($bytes, 0, 4) -ne 'WDBC') {
throw "Not a WDBC file: $Path"
}
$count = Read-U32 $bytes 4
$fields = Read-U32 $bytes 8
$recordSize = Read-U32 $bytes 12
$stringSize = Read-U32 $bytes 16
$recordsEnd = 20 + ($count * $recordSize)
if ($recordsEnd + $stringSize -ne $bytes.Length) {
throw "Invalid WDBC size: $Path"
}
$records = [Collections.Generic.Dictionary[uint32,byte[]]]::new()
for ($index = 0; $index -lt $count; $index++) {
[byte[]]$row = New-Object byte[] $recordSize
[Array]::Copy($bytes, 20 + ($index * $recordSize), $row, 0, $recordSize)
$records.Add((Read-U32 $row 0), $row)
}
[byte[]]$strings = New-Object byte[] $stringSize
[Array]::Copy($bytes, $recordsEnd, $strings, 0, $stringSize)
return [pscustomobject]@{
Path = $Path
Fields = [uint32]$fields
RecordSize = [uint32]$recordSize
Records = $records
Strings = $strings
}
}
function Read-WdbcRows([string]$Path) {
[byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
if ($bytes.Length -lt 20 -or [Text.Encoding]::ASCII.GetString($bytes, 0, 4) -ne 'WDBC') {
throw "Not a WDBC file: $Path"
}
$count = Read-U32 $bytes 4
$fields = Read-U32 $bytes 8
$recordSize = Read-U32 $bytes 12
$stringSize = Read-U32 $bytes 16
$recordsEnd = 20 + ($count * $recordSize)
if ($recordsEnd + $stringSize -ne $bytes.Length) {
throw "Invalid WDBC size: $Path"
}
$rows = New-Object Collections.Generic.List[byte[]]
for ($index = 0; $index -lt $count; $index++) {
[byte[]]$row = New-Object byte[] $recordSize
[Array]::Copy($bytes, 20 + ($index * $recordSize), $row, 0, $recordSize)
$rows.Add($row)
}
[byte[]]$strings = New-Object byte[] $stringSize
[Array]::Copy($bytes, $recordsEnd, $strings, 0, $stringSize)
return [pscustomobject]@{
Path = $Path
Fields = [uint32]$fields
RecordSize = [uint32]$recordSize
Rows = $rows
Strings = $strings
}
}
function Get-StringBytes($Table, [uint32]$Offset) {
if ($Offset -eq 0) { return ,([byte[]]@()) }
if ($Offset -ge $Table.Strings.Length) { throw "Bad string offset $Offset in $($Table.Path)" }
$end = [int]$Offset
while ($end -lt $Table.Strings.Length -and $Table.Strings[$end] -ne 0) { $end++ }
[byte[]]$value = New-Object byte[] ($end - [int]$Offset)
if ($value.Length) { [Array]::Copy($Table.Strings, $Offset, $value, 0, $value.Length) }
return ,$value
}
function Get-RowSignature($Table, [byte[]]$Row, [int[]]$StringFields) {
$parts = New-Object Collections.Generic.List[string]
for ($field = 0; $field -lt $Table.Fields; $field++) {
$value = Read-U32 $Row ($field * 4)
if ($StringFields -contains $field) {
$parts.Add('s:' + [Convert]::ToBase64String((Get-StringBytes $Table $value)))
} else {
$parts.Add('u:' + $value)
}
}
return $parts -join '|'
}
function Select-DeltaMerge($Base, $Old, $New, [int[]]$StringFields, [string]$Label) {
$ids = [Collections.Generic.HashSet[uint32]]::new()
foreach ($id in $Base.Records.Keys) { [void]$ids.Add($id) }
foreach ($id in $Old.Records.Keys) { [void]$ids.Add($id) }
foreach ($id in $New.Records.Keys) { [void]$ids.Add($id) }
$selected = [Collections.Generic.Dictionary[uint32,object]]::new()
$oldOnly = 0
$newChanges = 0
$conflicts = 0
foreach ($id in $ids) {
$baseRow = $null; $oldRow = $null; $newRow = $null
[void]$Base.Records.TryGetValue($id, [ref]$baseRow)
[void]$Old.Records.TryGetValue($id, [ref]$oldRow)
[void]$New.Records.TryGetValue($id, [ref]$newRow)
$baseSig = if ($baseRow) { Get-RowSignature $Base $baseRow $StringFields } else { $null }
$oldSig = if ($oldRow) { Get-RowSignature $Old $oldRow $StringFields } else { $null }
$newSig = if ($newRow) { Get-RowSignature $New $newRow $StringFields } else { $null }
$oldChanged = $oldRow -and ($null -eq $baseRow -or $oldSig -ne $baseSig)
$newChanged = $newRow -and ($null -eq $baseRow -or $newSig -ne $baseSig)
if ($oldChanged -and $newChanged -and $oldSig -ne $newSig) { $conflicts++ }
if ($newChanged) {
$selected.Add($id, [pscustomobject]@{ Table = $New; Row = $newRow })
$newChanges++
} elseif ($oldChanged) {
$selected.Add($id, [pscustomobject]@{ Table = $Old; Row = $oldRow })
$oldOnly++
} elseif ($newRow) {
$selected.Add($id, [pscustomobject]@{ Table = $New; Row = $newRow })
} elseif ($oldRow) {
$selected.Add($id, [pscustomobject]@{ Table = $Old; Row = $oldRow })
} else {
$selected.Add($id, [pscustomobject]@{ Table = $Base; Row = $baseRow })
}
}
Write-Host "${Label}: rows=$($selected.Count), retained-old=$oldOnly, applied-new=$newChanges, same-id-conflicts=$conflicts"
return $selected
}
function Select-DisplayExtraMerge($Base, $OldItem, $New) {
$ids = [Collections.Generic.HashSet[uint32]]::new()
foreach ($id in $OldItem.Records.Keys) { [void]$ids.Add($id) }
foreach ($id in $New.Records.Keys) { [void]$ids.Add($id) }
$selected = [Collections.Generic.Dictionary[uint32,object]]::new()
$itemFieldsApplied = 0
foreach ($id in $ids) {
$baseRow = $null; $oldRow = $null; $newRow = $null
[void]$Base.Records.TryGetValue($id, [ref]$baseRow)
[void]$OldItem.Records.TryGetValue($id, [ref]$oldRow)
[void]$New.Records.TryGetValue($id, [ref]$newRow)
if ($newRow) {
[byte[]]$merged = $newRow.Clone()
if ($oldRow) {
for ($field = 8; $field -le 18; $field++) {
$oldValue = Read-U32 $oldRow ($field * 4)
$baseValue = if ($baseRow) { Read-U32 $baseRow ($field * 4) } else { 0 }
if (-not $baseRow -or $oldValue -ne $baseValue) {
Write-U32 $merged $field $oldValue
$itemFieldsApplied++
}
}
}
$selected.Add($id, [pscustomobject]@{ Table = $New; Row = $merged })
} else {
$selected.Add($id, [pscustomobject]@{ Table = $OldItem; Row = $oldRow })
}
}
Write-Host "CreatureDisplayInfoExtra: rows=$($selected.Count), retained item fields from patch I=$itemFieldsApplied"
return $selected
}
function Write-Wdbc($Template, $Selected, [int[]]$StringFields, [string]$Path) {
$stringOffsets = [Collections.Generic.Dictionary[string,uint32]]::new([StringComparer]::Ordinal)
$strings = New-Object Collections.Generic.List[byte]
$strings.Add(0)
$stringOffsets.Add('', 0)
$rows = New-Object Collections.Generic.List[byte[]]
foreach ($id in ($Selected.Keys | Sort-Object)) {
$choice = $Selected[$id]
[byte[]]$row = $choice.Row.Clone()
foreach ($field in $StringFields) {
$sourceOffset = Read-U32 $row ($field * 4)
[byte[]]$value = Get-StringBytes $choice.Table $sourceOffset
$key = [Convert]::ToBase64String($value)
[uint32]$targetOffset = 0
if (-not $stringOffsets.TryGetValue($key, [ref]$targetOffset)) {
$targetOffset = [uint32]$strings.Count
if ($value.Length) { $strings.AddRange($value) }
$strings.Add(0)
$stringOffsets.Add($key, $targetOffset)
}
Write-U32 $row $field $targetOffset
}
$rows.Add($row)
}
$parent = Split-Path -Parent $Path
New-Item -ItemType Directory -Path $parent -Force | Out-Null
$stream = [IO.File]::Open($Path, [IO.FileMode]::Create, [IO.FileAccess]::Write)
try {
$writer = New-Object IO.BinaryWriter($stream)
$writer.Write([Text.Encoding]::ASCII.GetBytes('WDBC'))
$writer.Write([uint32]$rows.Count)
$writer.Write([uint32]$Template.Fields)
$writer.Write([uint32]$Template.RecordSize)
$writer.Write([uint32]$strings.Count)
foreach ($row in $rows) { $writer.Write($row) }
$writer.Write($strings.ToArray())
$writer.Flush()
} finally {
$stream.Dispose()
}
}
function Write-ExtendedBarberShopStyle($Base, $HairGeosets, $FacialHairStyles, [string]$Path) {
if ($Base.Fields -ne 40 -or $Base.RecordSize -ne 160) {
throw "Unexpected BarberShopStyle schema in $($Base.Path)"
}
if ($HairGeosets.Fields -ne 6 -or $HairGeosets.RecordSize -ne 24) {
throw "Unexpected CharHairGeosets schema in $($HairGeosets.Path)"
}
if ($FacialHairStyles.Fields -ne 8 -or $FacialHairStyles.RecordSize -ne 32) {
throw "Unexpected CharacterFacialHairStyles schema in $($FacialHairStyles.Path)"
}
$templates = [Collections.Generic.Dictionary[string,byte[]]]::new([StringComparer]::Ordinal)
$existing = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
[uint32]$nextId = 1
foreach ($row in $Base.Rows) {
$id = Read-U32 $row 0
if ($id -ge $nextId) { $nextId = $id + 1 }
$type = Read-U32 $row 4
if ($type -ne 0 -and $type -ne 2) { continue }
$race = Read-U32 $row (37 * 4)
$gender = Read-U32 $row (38 * 4)
$style = Read-U32 $row (39 * 4)
$groupKey = "${race}:${gender}:${type}"
if (-not $templates.ContainsKey($groupKey)) { $templates.Add($groupKey, $row) }
[void]$existing.Add("${groupKey}:${style}")
}
$targets = New-Object Collections.Generic.List[object]
$targetKeys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
foreach ($row in $HairGeosets.Rows) {
$race = Read-U32 $row 4
$gender = Read-U32 $row 8
$style = Read-U32 $row 12
$groupKey = "${race}:${gender}:0"
$targetKey = "${groupKey}:${style}"
if ($templates.ContainsKey($groupKey) -and $targetKeys.Add($targetKey)) {
$targets.Add([pscustomobject]@{ Race = $race; Gender = $gender; Type = 0; Style = $style; GroupKey = $groupKey; TargetKey = $targetKey })
}
}
foreach ($row in $FacialHairStyles.Rows) {
$race = Read-U32 $row 0
$gender = Read-U32 $row 4
$style = Read-U32 $row 8
$groupKey = "${race}:${gender}:2"
$targetKey = "${groupKey}:${style}"
if ($templates.ContainsKey($groupKey) -and $targetKeys.Add($targetKey)) {
$targets.Add([pscustomobject]@{ Race = $race; Gender = $gender; Type = 2; Style = $style; GroupKey = $groupKey; TargetKey = $targetKey })
}
}
foreach ($groupKey in $templates.Keys) {
if (-not ($targets | Where-Object { $_.GroupKey -eq $groupKey } | Select-Object -First 1)) {
throw "No customization rows found for BarberShopStyle group $groupKey"
}
}
$rows = New-Object Collections.Generic.List[byte[]]
foreach ($row in $Base.Rows) { $rows.Add($row.Clone()) }
$strings = New-Object Collections.Generic.List[byte]
$strings.AddRange($Base.Strings)
$newStringOffsets = [Collections.Generic.Dictionary[string,uint32]]::new([StringComparer]::Ordinal)
$addedHair = 0
$addedFacial = 0
foreach ($target in ($targets | Sort-Object Race, Gender, Type, Style)) {
if ($existing.Contains($target.TargetKey)) { continue }
[byte[]]$row = $templates[$target.GroupKey].Clone()
Write-U32 $row 0 $nextId
Write-U32 $row 39 ([uint32]$target.Style)
# patch-ruRU-3 stores its localized display name in locale slot 8
# (field 10). New names are intentionally generic because the source
# customization archive contains no BarberShopStyle names.
for ($field = 2; $field -le 17; $field++) { Write-U32 $row $field 0 }
$label = if ($target.Type -eq 0) {
"New hairstyle $([uint32]$target.Style + 1)"
} else {
"New appearance $([uint32]$target.Style + 1)"
}
[uint32]$nameOffset = 0
if (-not $newStringOffsets.TryGetValue($label, [ref]$nameOffset)) {
$nameOffset = [uint32]$strings.Count
$strings.AddRange([Text.Encoding]::UTF8.GetBytes($label))
$strings.Add(0)
$newStringOffsets.Add($label, $nameOffset)
}
Write-U32 $row 10 $nameOffset
$rows.Add($row)
[void]$existing.Add($target.TargetKey)
$nextId++
if ($target.Type -eq 0) { $addedHair++ } else { $addedFacial++ }
}
foreach ($target in $targets) {
if (-not $existing.Contains($target.TargetKey)) {
throw "Missing generated BarberShopStyle row $($target.TargetKey)"
}
}
$parent = Split-Path -Parent $Path
New-Item -ItemType Directory -Path $parent -Force | Out-Null
$stream = [IO.File]::Open($Path, [IO.FileMode]::Create, [IO.FileAccess]::Write)
try {
$writer = New-Object IO.BinaryWriter($stream)
$writer.Write([Text.Encoding]::ASCII.GetBytes('WDBC'))
$writer.Write([uint32]$rows.Count)
$writer.Write([uint32]$Base.Fields)
$writer.Write([uint32]$Base.RecordSize)
$writer.Write([uint32]$strings.Count)
foreach ($row in $rows) { $writer.Write($row) }
$writer.Write($strings.ToArray())
$writer.Flush()
} finally {
$stream.Dispose()
}
Write-Host "BarberShopStyle: rows=$($rows.Count), added-hair=$addedHair, added-appearance=$addedFacial"
}
$locale = Join-Path $ClientPath 'Data\ruRU'
$baseArchive = Join-Path $locale 'patch-ruRU-3.MPQ'
$oldA = Join-Path $locale 'patch-ruRU-A.mpq'
$oldB = Join-Path $locale 'patch-ruRU-B.mpq'
$oldI = Join-Path $locale 'patch-ruRU-I.mpq'
$oldS = Join-Path $locale 'patch-ruRU-S.mpq'
$newB = Join-Path $ProjectRoot 'new customization mpqs\patch-b-002.mpq'
$baseModel = Read-Wdbc (Export-MpqFile $baseArchive 'DBFilesClient\CreatureModelData.dbc' 'base-CreatureModelData.dbc')
$baseDisplay = Read-Wdbc (Export-MpqFile $baseArchive 'DBFilesClient\CreatureDisplayInfo.dbc' 'base-CreatureDisplayInfo.dbc')
$baseExtra = Read-Wdbc (Export-MpqFile $baseArchive 'DBFilesClient\CreatureDisplayInfoExtra.dbc' 'base-CreatureDisplayInfoExtra.dbc')
$oldModel = Read-Wdbc (Export-MpqFile $oldA 'DBFilesClient\CreatureModelData.dbc' 'old-CreatureModelData.dbc')
$oldDisplay = Read-Wdbc (Export-MpqFile $oldB 'DBFilesClient\CreatureDisplayInfo.dbc' 'old-CreatureDisplayInfo.dbc')
$oldExtra = Read-Wdbc (Export-MpqFile $oldI 'DBFilesClient\CreatureDisplayInfoExtra.dbc' 'old-CreatureDisplayInfoExtra.dbc')
$newModel = Read-Wdbc (Export-MpqFile $newB 'DBFilesClient\CreatureModelData.dbc' 'new-CreatureModelData.dbc')
$newDisplay = Read-Wdbc (Export-MpqFile $newB 'DBFilesClient\CreatureDisplayInfo.dbc' 'new-CreatureDisplayInfo.dbc')
$newExtra = Read-Wdbc (Export-MpqFile $newB 'DBFilesClient\CreatureDisplayInfoExtra.dbc' 'new-CreatureDisplayInfoExtra.dbc')
$spellAttach = Export-MpqFile $oldS 'DBFilesClient\SpellVisualKitModelAttach.dbc' 'SpellVisualKitModelAttach.dbc'
$baseBarber = Read-WdbcRows (Export-MpqFile $baseArchive 'DBFilesClient\BarberShopStyle.dbc' 'base-BarberShopStyle.dbc')
$newHairGeosets = Read-WdbcRows (Export-MpqFile $newB 'DBFilesClient\CharHairGeosets.dbc' 'new-CharHairGeosets.dbc')
$newFacialHair = Read-WdbcRows (Export-MpqFile $newB 'DBFilesClient\CharacterFacialHairStyles.dbc' 'new-CharacterFacialHairStyles.dbc')
$modelSelected = Select-DeltaMerge $baseModel $oldModel $newModel @(2) 'CreatureModelData'
$displaySelected = Select-DeltaMerge $baseDisplay $oldDisplay $newDisplay @(6,7,8,9) 'CreatureDisplayInfo'
$extraSelected = Select-DisplayExtraMerge $baseExtra $oldExtra $newExtra
$mergedModel = Join-Path $work 'merged-CreatureModelData.dbc'
$mergedDisplay = Join-Path $work 'merged-CreatureDisplayInfo.dbc'
$mergedExtra = Join-Path $work 'merged-CreatureDisplayInfoExtra.dbc'
$extendedBarber = Join-Path $work 'extended-BarberShopStyle.dbc'
Write-Wdbc $newModel $modelSelected @(2) $mergedModel
Write-Wdbc $newDisplay $displaySelected @(6,7,8,9) $mergedDisplay
Write-Wdbc $newExtra $extraSelected @(20) $mergedExtra
Write-ExtendedBarberShopStyle $baseBarber $newHairGeosets $newFacialHair $extendedBarber
$baseTarget = Join-Path $ProjectRoot 'assets\dbc\3.3.5a'
$compatTarget = Join-Path $ProjectRoot 'src\Data\ruRU\patch-ruRU-Z\DBFilesClient'
New-Item -ItemType Directory -Path $compatTarget -Force | Out-Null
Copy-Item -LiteralPath $mergedModel -Destination (Join-Path $baseTarget 'CreatureModelData.dbc') -Force
Copy-Item -LiteralPath $mergedDisplay -Destination (Join-Path $baseTarget 'CreatureDisplayInfo.dbc') -Force
Copy-Item -LiteralPath $mergedExtra -Destination (Join-Path $compatTarget 'CreatureDisplayInfoExtra.dbc') -Force
Copy-Item -LiteralPath $spellAttach -Destination (Join-Path $compatTarget 'SpellVisualKitModelAttach.dbc') -Force
Copy-Item -LiteralPath $extendedBarber -Destination (Join-Path $compatTarget 'BarberShopStyle.dbc') -Force
$serverTarget = Join-Path $work 'server-dbc'
New-Item -ItemType Directory -Path $serverTarget -Force | Out-Null
Copy-Item -LiteralPath $extendedBarber -Destination (Join-Path $serverTarget 'BarberShopStyle.dbc') -Force
Write-Host "Compatibility sources updated in $baseTarget and $compatTarget"
Write-Host "Matching server DBC staged in $serverTarget"
+11 -7
View File
@@ -167,17 +167,21 @@ fn main() -> Result<(), Box<dyn Error>> {
}
let sub_name = sub.file_name().to_string_lossy().to_string();
let output_mpq = output_dir.join("Data").join(&name).join(&sub_name);
build_mpq(&sub_path, &output_mpq, None)?;
// The final ruRU Z layer is the compatibility authority for customization,
// legacy visual-patch DBCs and MoonWell custom creature rows.
let creature_files = if name.eq_ignore_ascii_case("ruRU")
&& sub_name.eq_ignore_ascii_case("patch-ruRU-Z")
{
generated_creatures.as_ref()
} else {
None
};
build_mpq(&sub_path, &output_mpq, creature_files)?;
}
} else {
// Regular patch dir, e.g. patch-4/ or patch-Z/
let output_mpq = output_dir.join("Data").join(&name);
let creature_files = if name.eq_ignore_ascii_case("patch-Z") {
generated_creatures.as_ref()
} else {
None
};
build_mpq(&path, &output_mpq, creature_files)?;
build_mpq(&path, &output_mpq, None)?;
}
}
+167 -16
View File
@@ -11,9 +11,17 @@ import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from patch_layout import PATCH_LAYOUT, normalized_path
S3_PREFIX = "World of Warcraft"
MANIFEST_S3_KEY = "manifest.json"
MANAGED_MANIFEST_S3_KEY = "moonwell-managed-manifest.json"
LEGACY_MANAGED_FILES = {
"utils/warcraftxlhost.exe",
"utils/warcraftxlhost.log",
"warcraftxl.before-texture-upload-fix.dll",
}
def compute_build_hash(files: list[dict]) -> str:
lines = [
@@ -25,15 +33,59 @@ def compute_build_hash(files: list[dict]) -> str:
def normalized_path_key(path: str) -> str:
"""Match client paths using Windows filesystem semantics."""
return path.replace("\\", "/").casefold()
return normalized_path(path)
def merge_manifests(base_manifest: dict, staging_manifest: dict) -> dict:
def missing_repository_patches(manifest: dict) -> list[str]:
paths = {
normalized_path_key(item["path"])
for item in manifest.get("files", [])
if isinstance(item, dict) and isinstance(item.get("path"), str)
}
return sorted(PATCH_LAYOUT.repository_patches - paths)
def external_graphics_patches(manifest: dict) -> list[str]:
return sorted(
item["path"]
for item in manifest.get("files", [])
if isinstance(item, dict)
and isinstance(item.get("path"), str)
and PATCH_LAYOUT.is_external_graphics_patch(item["path"])
)
def is_legacy_managed_path(path: str) -> bool:
key = normalized_path_key(path)
return (
key in LEGACY_MANAGED_FILES
or key in {"wow.exe", "d3d9.dll", "warcraftxl.dll", "utils/d3d9-native.dll"}
or key.startswith("extensions/")
or key.startswith("data/patch-wxl.mpq/")
)
def merge_manifests(
base_manifest: dict,
staging_manifest: dict,
previous_managed_paths: list[str] | None = None,
) -> dict:
previous_managed_keys = {
normalized_path_key(path) for path in (previous_managed_paths or [])
}
files_by_path = {
normalized_path_key(item["path"]): item
for item in base_manifest.get("files", [])
if not (
normalized_path_key(item["path"]) in previous_managed_keys
or is_legacy_managed_path(item["path"])
or PATCH_LAYOUT.is_repository_patch(item["path"])
)
or PATCH_LAYOUT.is_external_graphics_patch(item["path"])
}
for item in staging_manifest.get("files", []):
if PATCH_LAYOUT.is_external_graphics_patch(item["path"]):
continue
files_by_path[normalized_path_key(item["path"])] = item
files = sorted(files_by_path.values(), key=lambda item: item["path"].casefold())
@@ -43,6 +95,36 @@ def merge_manifests(base_manifest: dict, staging_manifest: dict) -> dict:
}
def find_stale_managed_paths(
base_manifest: dict,
staging_manifest: dict,
previous_managed_paths: list[str] | None = None,
) -> list[str]:
previous_managed_keys = {
normalized_path_key(path) for path in (previous_managed_paths or [])
}
staging_keys = {
normalized_path_key(item["path"])
for item in staging_manifest.get("files", [])
}
return sorted(
(
item["path"]
for item in base_manifest.get("files", [])
if (
(
normalized_path_key(item["path"]) in previous_managed_keys
or is_legacy_managed_path(item["path"])
or PATCH_LAYOUT.is_repository_patch(item["path"])
)
and not PATCH_LAYOUT.is_external_graphics_patch(item["path"])
)
and normalized_path_key(item["path"]) not in staging_keys
),
key=str.casefold,
)
def load_dotenv(env_path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not env_path.exists():
@@ -103,6 +185,24 @@ def main() -> int:
print(f"ERROR: unable to read local manifest.json: {error}", file=sys.stderr)
return 1
missing_repository = missing_repository_patches(staging_manifest)
if missing_repository:
print(
"ERROR: local manifest is missing repository patches: "
+ ", ".join(missing_repository),
file=sys.stderr,
)
return 1
unexpected_graphics = external_graphics_patches(staging_manifest)
if unexpected_graphics:
print(
"ERROR: managed manifest contains externally managed graphics patches: "
+ ", ".join(unexpected_graphics),
file=sys.stderr,
)
return 1
try:
response = s3.get_object(Bucket=bucket, Key=MANIFEST_S3_KEY)
production_manifest = json.loads(response["Body"].read())
@@ -116,7 +216,32 @@ def main() -> int:
print(f"ERROR: invalid production manifest: {error}", file=sys.stderr)
return 1
merged_manifest = merge_manifests(production_manifest, staging_manifest)
try:
response = s3.get_object(Bucket=bucket, Key=MANAGED_MANIFEST_S3_KEY)
previous_managed_manifest = json.loads(response["Body"].read())
previous_managed_paths = [
item["path"] for item in previous_managed_manifest.get("files", [])
]
except ClientError as error:
error_code = error.response.get("Error", {}).get("Code")
if error_code not in {"NoSuchKey", "404"}:
print(f"ERROR: unable to read managed manifest: {error}", file=sys.stderr)
return 1
previous_managed_paths = []
except (UnicodeDecodeError, json.JSONDecodeError, KeyError) as error:
print(f"ERROR: invalid managed manifest: {error}", file=sys.stderr)
return 1
merged_manifest = merge_manifests(
production_manifest,
staging_manifest,
previous_managed_paths,
)
stale_managed_paths = find_stale_managed_paths(
production_manifest,
staging_manifest,
previous_managed_paths,
)
print(
"Manifest merge: "
f"production={len(production_manifest.get('files', []))}, "
@@ -139,6 +264,9 @@ def main() -> int:
for local_path in files:
rel_path = local_path.relative_to(dist_dir).as_posix()
if PATCH_LAYOUT.is_external_graphics_patch(rel_path):
print(f"Skipping externally managed graphics patch: {rel_path}")
continue
s3_key = f"{S3_PREFIX}/{rel_path}"
size_mb = local_path.stat().st_size / (1024 * 1024)
print(f"Uploading {rel_path} ({size_mb:.1f} MB) -> s3://{bucket}/{s3_key}")
@@ -154,27 +282,50 @@ def main() -> int:
print("\nFinished with errors; production manifest was not changed.")
return 1
manifest_path.write_text(
json.dumps(merged_manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
newline="\n",
json_content_type = "application/json"
no_cache = "no-cache, no-store, must-revalidate"
print(
f"Uploading managed package inventory -> "
f"s3://{bucket}/{MANAGED_MANIFEST_S3_KEY}"
)
try:
s3.put_object(
Bucket=bucket,
Key=MANAGED_MANIFEST_S3_KEY,
Body=(json.dumps(staging_manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
ContentType=json_content_type,
CacheControl=no_cache,
)
print(" OK")
except Exception as e:
print(f" ERROR: {e}", file=sys.stderr)
return 1
print(f"Uploading manifest.json -> s3://{bucket}/{MANIFEST_S3_KEY}")
try:
s3.upload_file(
str(manifest_path),
bucket,
MANIFEST_S3_KEY,
ExtraArgs={
"ContentType": "application/json",
"CacheControl": "no-cache, no-store, must-revalidate",
},
s3.put_object(
Bucket=bucket,
Key=MANIFEST_S3_KEY,
Body=(json.dumps(merged_manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
ContentType=json_content_type,
CacheControl=no_cache,
)
print(f" OK")
except Exception as e:
print(f" ERROR: {e}", file=sys.stderr)
errors = True
return 1
if stale_managed_paths:
print(f"Deleting {len(stale_managed_paths)} stale managed object(s)...")
for relative_path in stale_managed_paths:
s3_key = f"{S3_PREFIX}/{relative_path.replace(chr(92), '/')}"
try:
s3.delete_object(Bucket=bucket, Key=s3_key)
print(f" Deleted {s3_key}")
except Exception as e:
print(f" ERROR deleting {s3_key}: {e}", file=sys.stderr)
errors = True
if errors:
print("\nFinished with errors.")
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
import argparse
import sys
from pathlib import Path
from patch_layout import PATCH_LAYOUT, normalized_path
EXTENSIONS = {
"MoonWell",
"wxl-db2",
"wxl-fdid-moonwell",
"wxl-grasswind",
"wxl-modern-adt",
"wxl-modern-blp",
"wxl-modern-m2",
"wxl-modern-wmo",
"wxl-moonwell-storage-fallback",
"wxl-unit-outline",
}
EXTENSION_KEYS = {name.casefold() for name in EXTENSIONS}
ALLOWED_TOP_LEVEL_FILES = {
"d3d9.dll",
"warcraftxl.dll",
"wow.exe",
}
ALLOWED_TOP_LEVEL_DIRS = {"data", "extensions", "utils"}
REQUIRED_RUNTIME_FILES = {
"wow.exe",
"d3d9.dll",
"warcraftxl.dll",
*PATCH_LAYOUT.repository_patches,
"utils/d3d9-native.dll",
*(f"extensions/{name.casefold()}/{name.casefold()}.dll" for name in EXTENSIONS),
}
ALLOWED_DLL_FILES = {
path for path in REQUIRED_RUNTIME_FILES if path.endswith(".dll")
}
BUILD_ONLY_SUFFIXES = {
".bak",
".exp",
".ilk",
".lib",
".log",
".obj",
".old",
".orig",
".pdb",
".pyc",
".rej",
".temp",
".tmp",
}
def normalized(path: Path) -> str:
return normalized_path(path.as_posix())
def validate(root: Path) -> list[str]:
errors: list[str] = []
files = sorted(path for path in root.rglob("*") if path.is_file())
relative_files = {normalized(path.relative_to(root)) for path in files}
for missing in sorted(REQUIRED_RUNTIME_FILES - relative_files):
errors.append(f"missing required runtime file: {missing}")
for entry in root.iterdir():
key = entry.name.casefold()
if entry.is_file() and key not in ALLOWED_TOP_LEVEL_FILES:
errors.append(f"unexpected top-level file: {entry.name}")
elif entry.is_dir() and key not in ALLOWED_TOP_LEVEL_DIRS:
errors.append(f"unexpected top-level directory: {entry.name}")
for path in files:
relative = path.relative_to(root)
relative_key = normalized(relative)
parts = relative.parts
if PATCH_LAYOUT.is_external_graphics_patch(relative.as_posix()):
errors.append(
f"externally managed graphics patch in repository package: {relative.as_posix()}"
)
if path.suffix.casefold() in BUILD_ONLY_SUFFIXES:
errors.append(f"build-only or temporary file: {relative.as_posix()}")
if path.suffix.casefold() == ".dll" and relative_key not in ALLOWED_DLL_FILES:
errors.append(f"unexpected DLL: {relative.as_posix()}")
if path.suffix.casefold() == ".exe" and relative_key != "wow.exe":
errors.append(f"unexpected executable: {relative.as_posix()}")
if parts and parts[0].casefold() == "extensions":
if len(parts) != 3:
errors.append(f"unexpected Extensions layout: {relative.as_posix()}")
continue
extension_name = parts[1]
expected_file = f"{extension_name}.dll"
if extension_name.casefold() not in EXTENSION_KEYS:
errors.append(f"unexpected extension: {relative.as_posix()}")
elif parts[2].casefold() != expected_file.casefold():
errors.append(f"unexpected file in extension package: {relative.as_posix()}")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description="Validate MoonWell package layout")
parser.add_argument("--dir", required=True, help="Package staging directory")
args = parser.parse_args()
root = Path(args.dir).resolve()
if not root.is_dir():
print(f"ERROR: package directory not found: {root}", file=sys.stderr)
return 1
errors = validate(root)
if errors:
print("ERROR: package validation failed:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
return 1
print(f"OK: package layout validated ({len(list(root.rglob('*')))} entries)")
return 0
if __name__ == "__main__":
raise SystemExit(main())