diff --git a/.gitignore b/.gitignore
index a023f99..1bad363 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@ manifest.json
.vscode
dist
build/
+/new customization mpqs/*.mpq
Wow*.exe
*.backup.exe
Logs/
diff --git a/.gitmodules b/.gitmodules
index 09ab9b4..a02daad 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -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
diff --git a/CVPATCH_COMPATIBILITY.md b/CVPATCH_COMPATIBILITY.md
new file mode 100644
index 0000000..5d484bd
--- /dev/null
+++ b/CVPATCH_COMPATIBILITY.md
@@ -0,0 +1,355 @@
+# Совместимость CVPatch и расширенной кастомизации
+
+Документ описывает фактический набор графических MPQ MoonWell для клиента
+**WoW 3.3.5a, build 12340**, их порядок загрузки, конфликты DBC, сборку слоя
+совместимости и серверные требования парикмахерской.
+
+## Исходные данные и происхождение
+
+Старые архивы `patch-ruRU-A..W` относятся к **CVPatch (Collection Visual
+Patch)** от Vary/vvladoon. Найденная тема оригинального проекта:
+
+
+
+Источник новых архивов утрачен. Единственная сохранённая подпись находится в
+`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
+/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
+```
diff --git a/README.md b/README.md
index 6015579..5bcfd88 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/assets/dbc/3.3.5a/CreatureDisplayInfo.dbc b/assets/dbc/3.3.5a/CreatureDisplayInfo.dbc
index ac0fd4d..04b6156 100644
Binary files a/assets/dbc/3.3.5a/CreatureDisplayInfo.dbc and b/assets/dbc/3.3.5a/CreatureDisplayInfo.dbc differ
diff --git a/assets/dbc/3.3.5a/CreatureModelData.dbc b/assets/dbc/3.3.5a/CreatureModelData.dbc
index 05faab4..41b326b 100644
Binary files a/assets/dbc/3.3.5a/CreatureModelData.dbc and b/assets/dbc/3.3.5a/CreatureModelData.dbc differ
diff --git a/build-warcraftxl.ps1 b/build-warcraftxl.ps1
index d47e2c7..0202766 100644
--- a/build-warcraftxl.ps1
+++ b/build-warcraftxl.ps1
@@ -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"
}
diff --git a/build_manifest.py b/build_manifest.py
index a93c889..0db138a 100644
--- a/build_manifest.py
+++ b/build_manifest.py
@@ -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])
diff --git a/deploy.ps1 b/deploy.ps1
index c98b7bc..db61662 100644
--- a/deploy.ps1
+++ b/deploy.ps1
@@ -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.' }
diff --git a/patch-layout.json b/patch-layout.json
new file mode 100644
index 0000000..bbc8f5f
--- /dev/null
+++ b/patch-layout.json
@@ -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"
+ ]
+}
diff --git a/patch_layout.py b/patch_layout.py
new file mode 100644
index 0000000..27d8b50
--- /dev/null
+++ b/patch_layout.py
@@ -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()
diff --git a/pipeline-layout.ps1 b/pipeline-layout.ps1
new file mode 100644
index 0000000..4a6415d
--- /dev/null
+++ b/pipeline-layout.ps1
@@ -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
+}
diff --git a/run.ps1 b/run.ps1
index f24551d..c720a11 100644
--- a/run.ps1
+++ b/run.ps1
@@ -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) {
diff --git a/src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc b/src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc
new file mode 100644
index 0000000..65e626b
Binary files /dev/null and b/src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc differ
diff --git a/src/Data/ruRU/patch-ruRU-Z/DBFilesClient/CreatureDisplayInfoExtra.dbc b/src/Data/ruRU/patch-ruRU-Z/DBFilesClient/CreatureDisplayInfoExtra.dbc
new file mode 100644
index 0000000..ee32f5e
Binary files /dev/null and b/src/Data/ruRU/patch-ruRU-Z/DBFilesClient/CreatureDisplayInfoExtra.dbc differ
diff --git a/src/Data/ruRU/patch-ruRU-Z/DBFilesClient/SpellVisualKitModelAttach.dbc b/src/Data/ruRU/patch-ruRU-Z/DBFilesClient/SpellVisualKitModelAttach.dbc
new file mode 100644
index 0000000..88560c4
Binary files /dev/null and b/src/Data/ruRU/patch-ruRU-Z/DBFilesClient/SpellVisualKitModelAttach.dbc differ
diff --git a/tool/scripts/build-customization-compat.ps1 b/tool/scripts/build-customization-compat.ps1
new file mode 100644
index 0000000..44384f0
--- /dev/null
+++ b/tool/scripts/build-customization-compat.ps1
@@ -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"
diff --git a/tool/src/main.rs b/tool/src/main.rs
index d20ebe8..cab3d84 100644
--- a/tool/src/main.rs
+++ b/tool/src/main.rs
@@ -167,17 +167,21 @@ fn main() -> Result<(), Box> {
}
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)?;
}
}
diff --git a/upload_to_s3.py b/upload_to_s3.py
index d97d80f..da5ceed 100644
--- a/upload_to_s3.py
+++ b/upload_to_s3.py
@@ -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.")
diff --git a/validate_package.py b/validate_package.py
new file mode 100644
index 0000000..7598116
--- /dev/null
+++ b/validate_package.py
@@ -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())
diff --git a/vendor/modules/wxl-db2 b/vendor/modules/wxl-db2
index b3b4e31..30e4f2c 160000
--- a/vendor/modules/wxl-db2
+++ b/vendor/modules/wxl-db2
@@ -1 +1 @@
-Subproject commit b3b4e31cc43218faf47745facc4d7e4d65dfbbf1
+Subproject commit 30e4f2c8ed887a8d8d4b4472d688d4b8a02c310d
diff --git a/vendor/modules/wxl-modern-m2 b/vendor/modules/wxl-modern-m2
index ca841be..b5a823a 160000
--- a/vendor/modules/wxl-modern-m2
+++ b/vendor/modules/wxl-modern-m2
@@ -1 +1 @@
-Subproject commit ca841be8a928b2f717b088c0b5efd670daf640df
+Subproject commit b5a823a44b6e15208ba75b92a79845db9a5308e7
diff --git a/vendor/warcraftxl b/vendor/warcraftxl
index 4895cef..1508beb 160000
--- a/vendor/warcraftxl
+++ b/vendor/warcraftxl
@@ -1 +1 @@
-Subproject commit 4895cef6f41ade7fc89946d21009fed9f4fcfbe0
+Subproject commit 1508beb6ec1eeb337a243f22fe6fe27deb9a8cf0