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