diff --git a/CVPATCH_COMPATIBILITY.md b/CVPATCH_COMPATIBILITY.md new file mode 100644 index 0000000..3825c7e --- /dev/null +++ b/CVPATCH_COMPATIBILITY.md @@ -0,0 +1,354 @@ +# Совместимость 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; +- копирует `patch-a-001.mpq` в `dist\Data\ruRU\patch-ruRU-X.MPQ`; +- копирует `patch-b-002.mpq` в `dist\Data\ruRU\patch-ruRU-Y.MPQ`; +- не добавляет `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. + +Технически уже установленный клиент может запускаться без исходников в +репозитории, если X/Y остаются в `Data\ruRU`. Однако удаление исходников сломает +повторную сборку `dist`, восстановление клиента и генерацию совместимости. +Поэтому `patch-a-001.mpq` и `patch-b-002.mpq` нужно хранить. + +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/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/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..a763e43 100644 --- a/tool/src/main.rs +++ b/tool/src/main.rs @@ -119,6 +119,35 @@ fn stage_loose_assets( Ok(()) } +fn stage_customization_archives( + project_root: &Path, + output_dir: &Path, +) -> Result<(), Box> { + let source_dir = project_root.join("new customization mpqs"); + let locale_dir = output_dir.join("Data").join("ruRU"); + let archives = [ + ("patch-a-001.mpq", "patch-ruRU-X.MPQ"), + ("patch-b-002.mpq", "patch-ruRU-Y.MPQ"), + ]; + + for (source_name, target_name) in archives { + let source = source_dir.join(source_name); + if !source.is_file() { + println!( + "Customization archive not staged (missing): {}", + source.display() + ); + continue; + } + fs::create_dir_all(&locale_dir)?; + let target = locale_dir.join(target_name); + fs::copy(&source, &target)?; + println!("Staged customization archive: {}", target.display()); + } + + Ok(()) +} + fn main() -> Result<(), Box> { let args: Vec = env::args().collect(); @@ -167,20 +196,26 @@ 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)?; } } + stage_customization_archives(&project_root, &output_dir)?; + println!("\nAll MPQ archives built successfully."); Ok(()) }