Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87ec8c2ce9 |
@@ -8,4 +8,5 @@ build/
|
|||||||
Wow*.exe
|
Wow*.exe
|
||||||
*.backup.exe
|
*.backup.exe
|
||||||
Logs/
|
Logs/
|
||||||
|
local-assets/
|
||||||
!Wow_Original.exe
|
!Wow_Original.exe
|
||||||
|
|||||||
@@ -66,6 +66,8 @@ wxl_add_external_extension(wxl-unit-outline "${WXL_EXTERNAL_MODULES_DIR}/wxl-uni
|
|||||||
# MoonWell-owned extensions. wxl-fdid-moonwell sorts after wxl-db2 and before
|
# MoonWell-owned extensions. wxl-fdid-moonwell sorts after wxl-db2 and before
|
||||||
# the modern asset extensions, so it can layer custom CSV mappings over wxl.fdid.
|
# the modern asset extensions, so it can layer custom CSV mappings over wxl.fdid.
|
||||||
wxl_add_external_extension(MoonWell "${WXL_LOCAL_MODULES_DIR}/moonwell")
|
wxl_add_external_extension(MoonWell "${WXL_LOCAL_MODULES_DIR}/moonwell")
|
||||||
|
wxl_add_external_extension(MoonwellCustomization
|
||||||
|
"${WXL_LOCAL_MODULES_DIR}/moonwell-customization")
|
||||||
wxl_add_external_extension(wxl-fdid-moonwell "${WXL_LOCAL_MODULES_DIR}/moonwell-fdid")
|
wxl_add_external_extension(wxl-fdid-moonwell "${WXL_LOCAL_MODULES_DIR}/moonwell-fdid")
|
||||||
wxl_add_external_extension(wxl-moonwell-storage-fallback
|
wxl_add_external_extension(wxl-moonwell-storage-fallback
|
||||||
"${WXL_LOCAL_MODULES_DIR}/moonwell-storage-fallback")
|
"${WXL_LOCAL_MODULES_DIR}/moonwell-storage-fallback")
|
||||||
|
|||||||
@@ -1,354 +0,0 @@
|
|||||||
# Совместимость CVPatch и расширенной кастомизации
|
|
||||||
|
|
||||||
Документ описывает фактический набор графических MPQ MoonWell для клиента
|
|
||||||
**WoW 3.3.5a, build 12340**, их порядок загрузки, конфликты DBC, сборку слоя
|
|
||||||
совместимости и серверные требования парикмахерской.
|
|
||||||
|
|
||||||
## Исходные данные и происхождение
|
|
||||||
|
|
||||||
Старые архивы `patch-ruRU-A..W` относятся к **CVPatch (Collection Visual
|
|
||||||
Patch)** от Vary/vvladoon. Найденная тема оригинального проекта:
|
|
||||||
|
|
||||||
<https://forum.uwow.biz/threads/cvpatch-collection-visual-patch-lich-king-3-3-5.296264/>
|
|
||||||
|
|
||||||
Источник новых архивов утрачен. Единственная сохранённая подпись находится в
|
|
||||||
`new customization mpqs/readme.txt`:
|
|
||||||
|
|
||||||
- `patch-a-001.mpq` — `Model and Textures NPC`;
|
|
||||||
- `patch-b-002.mpq` — `Characters HD The War Within`;
|
|
||||||
- `patch-k.mpq` — `Interface`.
|
|
||||||
|
|
||||||
Новые архивы нельзя считать официальным или полностью автономным комплектом
|
|
||||||
для 3.3.5a: в них отсутствовал совместимый `BarberShopStyle.dbc`, а часть DBC
|
|
||||||
содержит данные более новых версий игры.
|
|
||||||
|
|
||||||
## Установленная часть CVPatch
|
|
||||||
|
|
||||||
| Патч | Назначение | Зависимости и пересечения |
|
|
||||||
|---|---|---|
|
|
||||||
| A | Основные модели существ, гуманоидов и транспорта | Основа для старого B |
|
|
||||||
| B | Альтернативные модели существ, гуманоидов и транспорта | Требует A |
|
|
||||||
| E | Текстуры экипировки в повышенном разрешении | Независимый |
|
|
||||||
| H | HD-модели игровых персонажей Leeviathan | Перекрывается новыми X/Y на одинаковых путях |
|
|
||||||
| I | Новые модели и текстуры брони и оружия | Изначально требовал H; его item-поля сохраняются при слиянии |
|
|
||||||
| M | Карты классических подземелий и пещер | Независимый |
|
|
||||||
| S | Эффекты заклинаний и способностей | Содержит собственные spell DBC |
|
|
||||||
| T | Текстуры зданий и зон | Независимый |
|
|
||||||
| U | Окружение Низины Арати и Ущелья Песни Войны | Независимый |
|
|
||||||
| W | Вода | Независимый |
|
|
||||||
|
|
||||||
Старые буквенные имена не меняются. X, Y и Z специально выбраны после W,
|
|
||||||
чтобы новый комплект и исправления имели приоритет без переименования CVPatch.
|
|
||||||
|
|
||||||
## Новые исходные архивы
|
|
||||||
|
|
||||||
### `patch-a-001.mpq` → `patch-ruRU-X.MPQ`
|
|
||||||
|
|
||||||
Размер исходника — около 2,49 GiB, `(listfile)` содержит 29 495 файлов:
|
|
||||||
|
|
||||||
- 16 023 запечённых NPC-текстуры в `Textures\bakednpctextures`;
|
|
||||||
- 13 465 файлов моделей, skin, анимаций и текстур в `Character`;
|
|
||||||
- модели стандартных рас и ряда NPC-рас: Vrykul, Naga, Taunka, Forest Troll и
|
|
||||||
других;
|
|
||||||
- 28 120 BLP, 1 245 ANIM, 94 SKIN и 29 M2;
|
|
||||||
- 7 DBC.
|
|
||||||
|
|
||||||
DBC внутри X:
|
|
||||||
|
|
||||||
| DBC | Строк | Роль |
|
|
||||||
|---|---:|---|
|
|
||||||
| `CharacterFacialHairStyles.dbc` | 272 | Штатный объём лицевой кастомизации |
|
|
||||||
| `CharHairGeosets.dbc` | 370 | Штатный объём причёсок |
|
|
||||||
| `CharSections.dbc` | 10 060 | Секции текстур персонажей |
|
|
||||||
| `CreatureDisplayInfoExtra.dbc` | 15 477 | Дополнительные параметры отображения существ |
|
|
||||||
| `EmotesTextSound.dbc` | 782 | Звуки текстовых эмоций |
|
|
||||||
| `HelmetGeosetVisData.dbc` | 21 | Скрытие geoset под шлемами |
|
|
||||||
| `SpellVisualKitModelAttach.dbc` | 594 | Крепление моделей визуальных эффектов |
|
|
||||||
|
|
||||||
X нужен текущему комбинированному комплекту для NPC-моделей и запечённых
|
|
||||||
текстур. Для новых причёсок главным является Y, который загружается позже и
|
|
||||||
перекрывает одноимённые таблицы X.
|
|
||||||
|
|
||||||
### `patch-b-002.mpq` → `patch-ruRU-Y.MPQ`
|
|
||||||
|
|
||||||
Размер исходника — около 2,80 GiB, `(listfile)` содержит 36 191 файл:
|
|
||||||
|
|
||||||
- 36 180 файлов в `Character`;
|
|
||||||
- HD-модели и текстуры десяти стандартных игровых рас WotLK;
|
|
||||||
- 34 934 BLP, 1 021 ANIM, 125 BONE, 80 SKIN и 20 M2;
|
|
||||||
- 11 DBC с существенно расширенной кастомизацией.
|
|
||||||
|
|
||||||
DBC внутри Y:
|
|
||||||
|
|
||||||
| DBC | Строк | Роль |
|
|
||||||
|---|---:|---|
|
|
||||||
| `CharacterFacialHairStyles.dbc` | 3 616 | Бороды, серьги, рога и другие лицевые варианты |
|
|
||||||
| `CharHairGeosets.dbc` | 854 | Geoset и номера причёсок |
|
|
||||||
| `CharHairTextures.dbc` | 177 | Текстуры волос |
|
|
||||||
| `CharSections.dbc` | 614 212 | Расширенные секции кожи, лица, волос и их сочетаний |
|
|
||||||
| `CharVariations.dbc` | 62 | Наборы вариаций персонажей |
|
|
||||||
| `ChrRaces.dbc` | 41 | Описания рас, включая данные за пределами штатного WotLK |
|
|
||||||
| `CreatureDisplayInfo.dbc` | 24 282 | Display ID моделей |
|
|
||||||
| `CreatureDisplayInfoExtra.dbc` | 15 477 | Раса, пол, внешность и предметные поля display ID |
|
|
||||||
| `CreatureFamily.dbc` | 40 | Семейства существ |
|
|
||||||
| `CreatureModelData.dbc` | 1 353 | Пути и параметры моделей |
|
|
||||||
| `HelmetGeosetVisData.dbc` | 65 | Расширенные правила отображения под шлемами |
|
|
||||||
|
|
||||||
Y обязателен для расширенной кастомизации. Z не содержит копии всех этих
|
|
||||||
таблиц и не заменяет Y.
|
|
||||||
|
|
||||||
Наличие современных race ID в DBC не добавляет новые игровые расы в ядро или
|
|
||||||
интерфейс 3.3.5a. Текущая интеграция поддерживает расширение внешности десяти
|
|
||||||
штатных рас. Новые игровые расы потребуют отдельной доработки клиента, UI,
|
|
||||||
сетевого протокола и сервера.
|
|
||||||
|
|
||||||
### `patch-k.mpq` — не устанавливается
|
|
||||||
|
|
||||||
Размер — около 53 MiB, `(listfile)` содержит 673 файла только в `Interface`:
|
|
||||||
|
|
||||||
- 547 BLP;
|
|
||||||
- 68 SKIN и 24 M2 для экранов входа и выбора персонажа;
|
|
||||||
- 15 Lua, 12 XML, TOC и вспомогательные файлы.
|
|
||||||
|
|
||||||
Архив меняет `Interface\Glues`, `Interface\GlueXML`, модели экранов входа и
|
|
||||||
создания персонажа. Он намеренно исключён из сборки, потому что перекрывает
|
|
||||||
кастомный интерфейс MoonWell. Устанавливать его вручную поверх текущего клиента
|
|
||||||
нельзя без отдельного разбора и слияния UI.
|
|
||||||
|
|
||||||
## Итоговый порядок загрузки
|
|
||||||
|
|
||||||
В локализованной цепочке `Data\ruRU` используется следующий порядок:
|
|
||||||
|
|
||||||
1. штатные архивы клиента, включая `patch-ruRU-3.MPQ`;
|
|
||||||
2. старые CVPatch `patch-ruRU-A..W`;
|
|
||||||
3. `patch-ruRU-X.MPQ` — неизменённая копия `patch-a-001.mpq`;
|
|
||||||
4. `patch-ruRU-Y.MPQ` — неизменённая копия `patch-b-002.mpq`;
|
|
||||||
5. `patch-ruRU-Z.MPQ` — сгенерированный слой совместимости MoonWell.
|
|
||||||
|
|
||||||
Z является последней инстанцией для конфликтующих DBC. Нельзя переносить его
|
|
||||||
раньше X/Y или устанавливать другой locale MPQ после Z, который содержит те же
|
|
||||||
таблицы.
|
|
||||||
|
|
||||||
## Содержимое `patch-ruRU-Z.MPQ`
|
|
||||||
|
|
||||||
В текущей сборке Z содержит:
|
|
||||||
|
|
||||||
- `BarberShopStyle.dbc` — расширенная таблица парикмахерской;
|
|
||||||
- `CreatureDisplayInfoExtra.dbc` — результат слияния новых display ID и
|
|
||||||
предметных полей CVPatch I;
|
|
||||||
- `SpellVisualKitModelAttach.dbc` — более полная версия из CVPatch S;
|
|
||||||
- `CreatureModelData.dbc` — сгенерированное слияние штатных, CVPatch и новых
|
|
||||||
моделей;
|
|
||||||
- `CreatureDisplayInfo.dbc` — сгенерированное слияние display ID;
|
|
||||||
- `WXLFileData.csv` и `WXLSpellOverrides.tsv` для WarcraftXL.
|
|
||||||
|
|
||||||
Последние четыре файла добавляются упаковщиком автоматически; поэтому состав
|
|
||||||
папки `src\Data\ruRU\patch-ruRU-Z` и готового MPQ не обязан совпадать буквально.
|
|
||||||
|
|
||||||
### Правила слияния DBC
|
|
||||||
|
|
||||||
`tool/scripts/build-customization-compat.ps1` выполняет следующие операции:
|
|
||||||
|
|
||||||
- берёт штатную базу из `patch-ruRU-3.MPQ`;
|
|
||||||
- сохраняет уникальные изменения `CreatureModelData` из CVPatch A;
|
|
||||||
- сохраняет уникальные изменения `CreatureDisplayInfo` из CVPatch B;
|
|
||||||
- отдаёт приоритет Y при реальном конфликте одной и той же строки;
|
|
||||||
- переносит изменённые item-поля `CreatureDisplayInfoExtra` из CVPatch I в
|
|
||||||
новые строки Y;
|
|
||||||
- восстанавливает `SpellVisualKitModelAttach.dbc` из CVPatch S;
|
|
||||||
- генерирует расширенный `BarberShopStyle.dbc`;
|
|
||||||
- обновляет входные `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc` для
|
|
||||||
генератора MoonWell creatures.
|
|
||||||
|
|
||||||
Так Z не позволяет Y случайно уничтожить старые изменения существ, экипировки
|
|
||||||
и эффектов, а генерация MoonWell creatures не уничтожает новые display ID.
|
|
||||||
|
|
||||||
## Расширенная парикмахерская
|
|
||||||
|
|
||||||
### Причина исходного падения
|
|
||||||
|
|
||||||
Y расширил:
|
|
||||||
|
|
||||||
- `CharHairGeosets.dbc` с 370 до 854 строк;
|
|
||||||
- `CharacterFacialHairStyles.dbc` с 272 до 3 616 строк.
|
|
||||||
|
|
||||||
При этом исходные новые MPQ не содержали `BarberShopStyle.dbc`. Клиент продолжал
|
|
||||||
использовать штатную таблицу на 491 строку. При выборе нового варианта функция
|
|
||||||
`GetBarberShopTotalCost` не находила запись, после чего WoW падал с
|
|
||||||
`ERROR #132 / ACCESS_VIOLATION`. В зафиксированном крэше вызов шёл от
|
|
||||||
`BarberShopFrameSelector1Next`.
|
|
||||||
|
|
||||||
### Как сформирована новая таблица
|
|
||||||
|
|
||||||
Генератор:
|
|
||||||
|
|
||||||
- сохраняет все 491 штатную строку и исходную строковую таблицу байт-в-байт;
|
|
||||||
- добавляет отсутствующие причёски из `CharHairGeosets.dbc` Y;
|
|
||||||
- добавляет отсутствующую растительность и украшения из
|
|
||||||
`CharacterFacialHairStyles.dbc` Y;
|
|
||||||
- работает только с сочетаниями расы, пола и типа, уже существующими в штатном
|
|
||||||
`BarberShopStyle.dbc`, то есть с поддерживаемыми расами WotLK;
|
|
||||||
- сохраняет штатные строки типа 3 для особой кастомизации рыцарей смерти;
|
|
||||||
- назначает новые уникальные ID после максимального штатного ID;
|
|
||||||
- проверяет отсутствие дубликатов и полноту покрытия.
|
|
||||||
|
|
||||||
Результат:
|
|
||||||
|
|
||||||
- 3 765 строк всего;
|
|
||||||
- 152 новые причёски;
|
|
||||||
- 3 122 новых варианта растительности, серёг и других особенностей;
|
|
||||||
- максимальный номер лицевого варианта — 255, что помещается в поле внешности
|
|
||||||
персонажа 3.3.5a.
|
|
||||||
|
|
||||||
В исходном комплекте нет названий новых стилей. Поэтому сейчас они отображаются
|
|
||||||
как `New hairstyle N` и `New appearance N`. Это только подписи: на выбор модели
|
|
||||||
и сохранение внешности они не влияют.
|
|
||||||
|
|
||||||
## Что требуется клиенту и серверу
|
|
||||||
|
|
||||||
| Компонент | Клиент | Сервер |
|
|
||||||
|---|---|---|
|
|
||||||
| `patch-ruRU-X.MPQ` | Да, для текущего комплекта NPC/персонажей | Нет |
|
|
||||||
| `patch-ruRU-Y.MPQ` | Да, для моделей, текстур и основных customization DBC | Нет |
|
|
||||||
| `patch-ruRU-Z.MPQ` | Да, для совместимости и исправления парикмахерской | Нет как MPQ |
|
|
||||||
| Сгенерированный `BarberShopStyle.dbc` | Уже находится внутри Z | Да, отдельным DBC |
|
|
||||||
| Остальные клиентские DBC из X/Y | Загружаются из MPQ | Для текущих десяти рас не копируются |
|
|
||||||
|
|
||||||
Серверную копию нужно положить в каталог DBC worldserver, обычно:
|
|
||||||
|
|
||||||
```text
|
|
||||||
<server>/data/dbc/BarberShopStyle.dbc
|
|
||||||
```
|
|
||||||
|
|
||||||
После замены требуется полный перезапуск worldserver: DBC загружаются при
|
|
||||||
старте. Клиентская и серверная копии `BarberShopStyle.dbc` должны быть одним и
|
|
||||||
тем же сгенерированным файлом. Без серверной копии клиент больше не должен
|
|
||||||
падать при переключении, но сервер может отклонить применение нового стиля или
|
|
||||||
неверно рассчитать его стоимость.
|
|
||||||
|
|
||||||
Готовая серверная копия после запуска генератора находится в:
|
|
||||||
|
|
||||||
```text
|
|
||||||
build/customization-compat/server-dbc/BarberShopStyle.dbc
|
|
||||||
```
|
|
||||||
|
|
||||||
Тот же отслеживаемый исходник для Z находится в:
|
|
||||||
|
|
||||||
```text
|
|
||||||
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc
|
|
||||||
```
|
|
||||||
|
|
||||||
Не следует копировать на сервер `ChrRaces.dbc`, `CharSections.dbc` или весь
|
|
||||||
набор DBC из Y без отдельной задачи и проверки ядра.
|
|
||||||
|
|
||||||
## Сборка
|
|
||||||
|
|
||||||
### Предварительные условия
|
|
||||||
|
|
||||||
В `.env` должен быть задан корректный `WOW_HOME`. В клиенте должны находиться:
|
|
||||||
|
|
||||||
- штатный `Data\ruRU\patch-ruRU-3.MPQ`;
|
|
||||||
- CVPatch A, B, I и S, используемые генератором как источники слияния;
|
|
||||||
- остальные CVPatch, если они должны остаться в итоговой установке.
|
|
||||||
|
|
||||||
В репозитории должны оставаться:
|
|
||||||
|
|
||||||
- `new customization mpqs\patch-a-001.mpq`;
|
|
||||||
- `new customization mpqs\patch-b-002.mpq`;
|
|
||||||
- `tool\target\release\mpqread.exe`.
|
|
||||||
|
|
||||||
### Пересоздание DBC совместимости
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\tool\scripts\build-customization-compat.ps1
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт обновляет:
|
|
||||||
|
|
||||||
```text
|
|
||||||
assets/dbc/3.3.5a/CreatureModelData.dbc
|
|
||||||
assets/dbc/3.3.5a/CreatureDisplayInfo.dbc
|
|
||||||
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/CreatureDisplayInfoExtra.dbc
|
|
||||||
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/SpellVisualKitModelAttach.dbc
|
|
||||||
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc
|
|
||||||
build/customization-compat/server-dbc/BarberShopStyle.dbc
|
|
||||||
```
|
|
||||||
|
|
||||||
### Упаковка MPQ
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
.\tool\target\release\tool.exe .\src .\dist
|
|
||||||
```
|
|
||||||
|
|
||||||
Упаковщик:
|
|
||||||
|
|
||||||
- собирает Z из исходников и сгенерированных MoonWell DBC;
|
|
||||||
- копирует `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
|
|
||||||
```
|
|
||||||
Binary file not shown.
Binary file not shown.
@@ -112,6 +112,7 @@ $selectedProxy = Join-Path $win32ArtifactDir 'd3d9.dll'
|
|||||||
$recoveryProxy = Join-Path $win32BuildDir "artifacts\$Configuration\d3d9-native.dll"
|
$recoveryProxy = Join-Path $win32BuildDir "artifacts\$Configuration\d3d9-native.dll"
|
||||||
$extensionNames = @(
|
$extensionNames = @(
|
||||||
'MoonWell',
|
'MoonWell',
|
||||||
|
'MoonwellCustomization',
|
||||||
'wxl-db2',
|
'wxl-db2',
|
||||||
'wxl-fdid-moonwell',
|
'wxl-fdid-moonwell',
|
||||||
'wxl-grasswind',
|
'wxl-grasswind',
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Asset manifest
|
||||||
|
|
||||||
|
## Blood Elf Female
|
||||||
|
|
||||||
|
- Retail build: `WOW-68887patch12.0.7_Retail`
|
||||||
|
- Race ID: `10`
|
||||||
|
- ChrModelID: `20`
|
||||||
|
- Character model FileDataID: `1100258`
|
||||||
|
- Option: Demon Hunter Horns
|
||||||
|
- Retail OptionID: `126`
|
||||||
|
- Runtime element type: Geoset in an attached collection M2
|
||||||
|
- Collection model FileDataID: `7760202`
|
||||||
|
- Collection compatibility: `RETAIL_WITH_WXL`
|
||||||
|
- Runtime virtual path: `MoonWell\Customization\BloodElfFemale\7760202_be_f.m2`
|
||||||
|
- Prototype attachment point: `11` (stock head point). Retail collection point `19` is absent from
|
||||||
|
the 3.3.5 Blood Elf model and cannot safely be force-attached.
|
||||||
|
|
||||||
|
| Prototype choice | Retail ChoiceID | Retail ElementID | GeosetID | Compatibility |
|
||||||
|
|---|---:|---:|---:|---|
|
||||||
|
| None | 1850 | — | — | RETAIL_WITH_WXL |
|
||||||
|
| Betrayer | 1851 | 2051 | 2401 | RETAIL_WITH_WXL |
|
||||||
|
| Beast | 1852 | 2052 | 2402 | RETAIL_WITH_WXL |
|
||||||
|
| Dreadlord | 1853 | 2053 | 2403 | RETAIL_WITH_WXL |
|
||||||
|
|
||||||
|
The source catalog reports two collection textures:
|
||||||
|
|
||||||
|
| FileDataID | Internal path | Type | Purpose |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 1277002 | `item\objectcomponents\collections\bloodelf_female_dh_belt.blp` | BLP | Collection material dependency |
|
||||||
|
| 2764488 | `item\objectcomponents\collections\bloodelffemale_dh_horns.blp` | BLP | Collection material dependency |
|
||||||
|
|
||||||
|
Skin files exported with the model are FileDataID `7766030` (base), `7766031`, `7766032`, and
|
||||||
|
`7766033` (LODs). The MVP requests the base sibling `7760202_be_f00.skin`.
|
||||||
|
|
||||||
|
No fallback asset is committed or silently substituted. Classification is `RETAIL_WITH_WXL`, not
|
||||||
|
`RETAIL_NATIVE`: stock 3.3.5 cannot parse this modern collection without the WarcraftXL modern-M2
|
||||||
|
extension. If it fails in a real client session, the documented next fallback is a small WotLK M2
|
||||||
|
mounted at the same virtual path; such a placeholder would be classified `WOTLK_FALLBACK` and must be
|
||||||
|
recorded here before use.
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
# Glue integration follow-up
|
||||||
|
|
||||||
|
The runtime prototype intentionally does not alter character creation. WarcraftXL v1.1 exposes typed
|
||||||
|
Glue/Lua bindings in `game/Glue.hpp` and `game/Script.hpp`, while the existing MoonWell extension shows
|
||||||
|
the validated `Lua.RegisterFunction` / `Lua.ValidateFunctionPointer` bridge used by build 12340.
|
||||||
|
|
||||||
|
A safe follow-up is to register
|
||||||
|
`ModelFrame:SetMoonwellCustomization(optionID, choiceID)` through that existing bridge, resolve the
|
||||||
|
Glue model's `CharModelObject`, and pass it to the same `CustomizationState` and renderer used here.
|
||||||
|
Cleanup must follow Glue model destruction rather than `OnWorldLeave`. This should be implemented only
|
||||||
|
after the runtime collection attachment has passed interactive visual/lifetime testing; no extra client
|
||||||
|
offset or duplicate renderer is justified for the current vertical slice.
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
# MoonwellCustomization vertical slice
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
`MoonwellCustomization.dll` is a runtime-only WarcraftXL 1.1 extension for WoW 3.3.5a build
|
||||||
|
12340. It changes only an active Blood Elf Female player. State is kept in memory and is deliberately
|
||||||
|
separate from rendering; there are no server packets, database changes, AzerothCore changes, or new
|
||||||
|
Wow.exe offsets.
|
||||||
|
|
||||||
|
The data flow is:
|
||||||
|
|
||||||
|
`CustomizationOption (126) -> CustomizationChoice -> CustomizationElement (Geoset) -> collection M2`
|
||||||
|
|
||||||
|
The explicit prototype state is None plus three retail choices. The renderer owns one independent
|
||||||
|
collection render context at a time. Reapplying the same choice is a no-op; replacing a choice releases
|
||||||
|
the old context first. The overlay copies the active character's placement and bone palette without
|
||||||
|
joining the stock attachment readiness chain. `OnWorldLeave` always releases it and drops all borrowed
|
||||||
|
player pointers.
|
||||||
|
|
||||||
|
## Verified WarcraftXL API
|
||||||
|
|
||||||
|
The repository pins `vendor/warcraftxl` to `v1.1.220` (`4895cef`) on the `v1.1` branch. Source code,
|
||||||
|
not README assumptions, establishes the following behavior:
|
||||||
|
|
||||||
|
- `WXL_Query`/`WXL_Load` are the extension entry points; the runtime loads DLLs found below
|
||||||
|
`Extensions/<name>/`.
|
||||||
|
- `WXL_Api::UiAddPanel` registers an immediate-mode overlay panel. WarcraftXL v1.1 hard-codes `F9`
|
||||||
|
as its overlay toggle; press it and select **MoonWell Customization**.
|
||||||
|
- `world::ActivePlayerGuid` and `world::ResolveObject(..., kTypeMaskPlayer)` find the active player;
|
||||||
|
`unit::Model` returns its body-model/scene-node binding.
|
||||||
|
- The upstream typed `SceneNode` and `CharModelObject` views provide owner, race, gender, and root
|
||||||
|
scene node. The extension adds no offsets.
|
||||||
|
- `m2::GetRenderCtx` always creates a new scene model; `AttachToScene(..., slot 19, true)` attaches a
|
||||||
|
collection M2. Core explicitly documents 19 as the collection-M2 attachment point.
|
||||||
|
- `OnM2SkinFinalize` is the safe pre-finalize window to filter collection geosets. Runtime changes
|
||||||
|
call `FinalizeSkin` once; a recursion guard prevents its event from re-entering the filter.
|
||||||
|
- `OnBuildBonePalette` is post-engine and therefore the safe point to copy the parent character bone
|
||||||
|
palette into the collection model immediately before upload.
|
||||||
|
- `OnItemSlotChange` and `OnItemSlotClear` schedule a replacement on the next logic tick, after native
|
||||||
|
equipment work. Native equipment is never overridden.
|
||||||
|
- `OnWorldLeave` is the cleanup event. Events and UI run on WarcraftXL's main/render paths; no worker
|
||||||
|
thread calls client model bindings.
|
||||||
|
|
||||||
|
## Build
|
||||||
|
|
||||||
|
From a Visual Studio 2022 developer environment or PowerShell:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\build-warcraftxl.ps1 -Configuration Release
|
||||||
|
```
|
||||||
|
|
||||||
|
The required artifact is:
|
||||||
|
|
||||||
|
`build\warcraftxl-win32\Release\MoonwellCustomization.dll`
|
||||||
|
|
||||||
|
The target is Win32. The top-level CMake build compiles the extension together with the existing
|
||||||
|
WarcraftXL SDK sources; there is no second CMake project.
|
||||||
|
|
||||||
|
## Install and run
|
||||||
|
|
||||||
|
1. Install the asset files described in `docs/wow-export-assets.md` into a client MPQ.
|
||||||
|
2. Copy the DLL to
|
||||||
|
`Extensions\MoonwellCustomization\MoonwellCustomization.dll` under the client directory. The build
|
||||||
|
script does this automatically when invoked with `-Deploy -ClientPath <client>`.
|
||||||
|
3. Start the stock build-12340 client through the installed WarcraftXL `d3d9.dll` proxy.
|
||||||
|
4. Press `F9` to open the WarcraftXL overlay and select **MoonWell Customization**.
|
||||||
|
5. On Blood Elf Female, press None / Betrayer / Beast / Dreadlord. None must remove the collection;
|
||||||
|
repeated selections must not stack it. Equip and remove a helmet, then log out and back in.
|
||||||
|
6. On every other race or gender, the panel reports that the prototype is unsupported and exposes no
|
||||||
|
choice controls.
|
||||||
|
|
||||||
|
Expected log lines are tagged `[MoonwellCustomization]`: `loaded`, `player detected`, race/gender,
|
||||||
|
apply, model load, attach, detach, unsupported player, and actionable failure text.
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- The repository intentionally excludes Blizzard assets. Without the collection M2 and sibling skin,
|
||||||
|
the client loader can fall back to its placeholder model; that is an asset-install failure, not a
|
||||||
|
successful visual test.
|
||||||
|
- Retail collection model 7760202 is a modern MD21 asset. It requires the existing `wxl-modern-m2`,
|
||||||
|
`wxl-modern-blp`, `wxl-db2`, and FDID extensions in this repository.
|
||||||
|
- Bone copying assumes the retail Blood Elf Female collection uses the same ordered skeleton as its
|
||||||
|
parent model. The code clamps to the smaller palette, but a retail build changing bone order would
|
||||||
|
need an explicit bone map.
|
||||||
|
- `FinalizeSkin` in WXL 1.1 has small internal re-finalize leaks. The implementation therefore performs
|
||||||
|
it only on an actual choice change/equipment refresh, never per frame.
|
||||||
|
- This source-level build proves compilation and packaging. A successful visual result and crash-free
|
||||||
|
logout still require an interactive client session with the external assets installed.
|
||||||
|
|
||||||
|
The next minimal step toward a broader retail-like system is to move the static option table to an
|
||||||
|
external, versioned catalog and add renderers for Texture and independent M2Attachment elements while
|
||||||
|
keeping the same `optionID -> choiceID` state boundary.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
# Exporting the Blood Elf Female prototype assets
|
||||||
|
|
||||||
|
The local wow.export checkout has no documented headless CLI, so this document does not invent one.
|
||||||
|
Its MoonWell exporter generated the inspected catalog at:
|
||||||
|
|
||||||
|
`moonwell-customization/wotlk-races/catalog.json`
|
||||||
|
|
||||||
|
The exact source is retail `WOW-68887patch12.0.7_Retail`. Use wow.export's UI to select that product,
|
||||||
|
then export the WotLK races customization catalog/asset set. From the generated output, take:
|
||||||
|
|
||||||
|
| FileDataID | Exported file / internal path | Type | Why |
|
||||||
|
|---:|---|---|---|
|
||||||
|
| 7760202 | `assets/collections/7760202/7760202_be_f.m2` | M2 | Blood Elf Female collection containing Demon Hunter horn geosets |
|
||||||
|
| 7766030 | `assets/collections/7760202/7760202_be_f00.skin` | SKIN | Base geometry/index profile required by the M2 |
|
||||||
|
| 1277002 | `item/objectcomponents/collections/bloodelf_female_dh_belt.blp` | BLP | Declared collection texture dependency |
|
||||||
|
| 2764488 | `item/objectcomponents/collections/bloodelffemale_dh_horns.blp` | BLP | Declared collection texture dependency |
|
||||||
|
|
||||||
|
Do not add these files to git. Stage them under the ignored `local-assets/` directory, then put them in
|
||||||
|
a local MPQ with these virtual names:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MoonWell\Customization\BloodElfFemale\7760202_be_f.m2
|
||||||
|
MoonWell\Customization\BloodElfFemale\7760202_be_f00.skin
|
||||||
|
item\objectcomponents\collections\bloodelf_female_dh_belt.blp
|
||||||
|
item\objectcomponents\collections\bloodelffemale_dh_horns.blp
|
||||||
|
```
|
||||||
|
|
||||||
|
Install that MPQ as a high-priority `Data\patch-*.MPQ` beside the existing MoonWell patches. Keep the
|
||||||
|
three LOD skins (`7766031`–`7766033`) locally for later testing, but the current MVP does not request
|
||||||
|
them explicitly.
|
||||||
|
|
||||||
|
To reproduce the DB result without changing wow.export, run the repository's read-only catalog helper:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
node tools/find_customization_assets.js `
|
||||||
|
C:\Users\sindo\wow.export\moonwell-customization\wotlk-races\catalog.json `
|
||||||
|
"Blood Elf" female Horns
|
||||||
|
```
|
||||||
|
|
||||||
|
It must report OptionID `126`, then ChoiceIDs `1851`, `1852`, `1853`. Their skinned-model elements
|
||||||
|
map geoset type `24` and IDs `1`, `2`, `3` to skin sections `2401`, `2402`, `2403`. The helper reads
|
||||||
|
already-exported JSON only; it does not access CASC and is not a runtime dependency.
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace moonwell::customization
|
||||||
|
{
|
||||||
|
enum class ElementType : uint8_t
|
||||||
|
{
|
||||||
|
None,
|
||||||
|
M2Attachment,
|
||||||
|
Geoset,
|
||||||
|
Texture,
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CustomizationElement
|
||||||
|
{
|
||||||
|
ElementType type;
|
||||||
|
uint32_t geosetId;
|
||||||
|
const char* modelPath;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CustomizationChoice
|
||||||
|
{
|
||||||
|
uint32_t id;
|
||||||
|
const char* name;
|
||||||
|
std::array<CustomizationElement, 1> elements;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CustomizationOption
|
||||||
|
{
|
||||||
|
uint32_t id;
|
||||||
|
const char* name;
|
||||||
|
std::array<CustomizationChoice, 4> choices;
|
||||||
|
};
|
||||||
|
|
||||||
|
inline constexpr uint32_t kBloodElfRace = 10;
|
||||||
|
inline constexpr uint32_t kFemaleGender = 1;
|
||||||
|
inline constexpr uint32_t kCollectionFileDataId = 7760202;
|
||||||
|
// Stock 3.3.5 Blood Elf models have no retail collection point 19.
|
||||||
|
// Use the native head attachment point; forcing absent point 19 makes
|
||||||
|
// the parent's drawable gate fail and hides the whole character.
|
||||||
|
inline constexpr uint32_t kCollectionAttachmentSlot = 11;
|
||||||
|
inline constexpr char kCollectionPath[] =
|
||||||
|
"MoonWell\\Customization\\BloodElfFemale\\7760202_be_f.m2";
|
||||||
|
|
||||||
|
// WOTLK_FALLBACK: native 3.3.5 head components for Blood Elf Female.
|
||||||
|
// The retail collection needs a real bone map to the differently ordered
|
||||||
|
// 3.3.5 skeleton, so the working vertical slice uses native components.
|
||||||
|
inline constexpr CustomizationOption kHornsOption = {
|
||||||
|
126,
|
||||||
|
"Head Decorations (WotLK fallback)",
|
||||||
|
{{
|
||||||
|
{1850, "None", {{{ElementType::None, 0, nullptr}}}},
|
||||||
|
{1851, "Blindfold", {{{ElementType::M2Attachment, 0,
|
||||||
|
"ITEM\\OBJECTCOMPONENTS\\HEAD\\Helm_Blindfold_A_01_BeF.m2"}}}},
|
||||||
|
{1852, "Circlet I", {{{ElementType::M2Attachment, 0,
|
||||||
|
"ITEM\\OBJECTCOMPONENTS\\HEAD\\Helm_Circlet_A_01_BeF.m2"}}}},
|
||||||
|
{1853, "Circlet II", {{{ElementType::M2Attachment, 0,
|
||||||
|
"ITEM\\OBJECTCOMPONENTS\\HEAD\\Helm_Circlet_B_01_BeF.m2"}}}},
|
||||||
|
}},
|
||||||
|
};
|
||||||
|
|
||||||
|
inline const CustomizationChoice* FindChoice(uint32_t id)
|
||||||
|
{
|
||||||
|
for (const auto& choice : kHornsOption.choices)
|
||||||
|
if (choice.id == id) return &choice;
|
||||||
|
return nullptr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
#include "CustomizationRenderer.hpp"
|
||||||
|
|
||||||
|
#include <wxl/PluginApi.h>
|
||||||
|
|
||||||
|
#include "engine/events/Event.hpp"
|
||||||
|
#include "game/M2.hpp"
|
||||||
|
#include "game/World.hpp"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstring>
|
||||||
|
#include <limits>
|
||||||
|
#include <windows.h>
|
||||||
|
|
||||||
|
namespace moonwell::customization
|
||||||
|
{
|
||||||
|
namespace m2 = wxl::game::m2;
|
||||||
|
namespace world = wxl::game::world;
|
||||||
|
namespace sm2 = wxl::structure::m2;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
bool IsReadableRange(const void* address, size_t size)
|
||||||
|
{
|
||||||
|
if (!address || !size) return false;
|
||||||
|
|
||||||
|
const uintptr_t begin = reinterpret_cast<uintptr_t>(address);
|
||||||
|
if (size > std::numeric_limits<uintptr_t>::max() - begin) return false;
|
||||||
|
const uintptr_t end = begin + size;
|
||||||
|
uintptr_t cursor = begin;
|
||||||
|
|
||||||
|
while (cursor < end)
|
||||||
|
{
|
||||||
|
MEMORY_BASIC_INFORMATION memory{};
|
||||||
|
if (!VirtualQuery(reinterpret_cast<const void*>(cursor), &memory, sizeof memory))
|
||||||
|
return false;
|
||||||
|
if (memory.State != MEM_COMMIT ||
|
||||||
|
(memory.Protect & (PAGE_NOACCESS | PAGE_GUARD)) != 0)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
const uintptr_t regionEnd = reinterpret_cast<uintptr_t>(memory.BaseAddress) +
|
||||||
|
memory.RegionSize;
|
||||||
|
if (regionEnd <= cursor) return false;
|
||||||
|
cursor = std::min(regionEnd, end);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CustomizationRenderer::SetError(const char* message)
|
||||||
|
{
|
||||||
|
lastError_ = message;
|
||||||
|
api_->Log(WXL_LOG_ERROR, "MoonwellCustomization", "failed: %s", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CustomizationRenderer::DetectPlayer()
|
||||||
|
{
|
||||||
|
playerDetected_ = false;
|
||||||
|
supported_ = false;
|
||||||
|
charModelObject_ = nullptr;
|
||||||
|
parentRenderContext_ = nullptr;
|
||||||
|
race_ = gender_ = 0;
|
||||||
|
|
||||||
|
const auto guid = world::ActivePlayerGuid();
|
||||||
|
void* player = guid ? world::ResolveObject(guid, world::kTypeMaskPlayer) : nullptr;
|
||||||
|
if (!player)
|
||||||
|
{
|
||||||
|
lastError_ = "Waiting for active player object";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The native equipment events are the public WXL seam that supplies
|
||||||
|
// the actual CharModelObject. Unit::Model is not useful for identity:
|
||||||
|
// it exposes a different model layer. Match the root instance's world
|
||||||
|
// transform to the active player's authoritative world position so an
|
||||||
|
// event for another visible character cannot be adopted accidentally.
|
||||||
|
auto* cmo = static_cast<m2::off::CharModelObject*>(observedCharModelObject_);
|
||||||
|
if (!cmo || !cmo->sceneNode)
|
||||||
|
{
|
||||||
|
lastError_ = "Waiting for active player CharModelObject event";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
auto* root = static_cast<m2::off::M2Instance*>(cmo->sceneNode);
|
||||||
|
float playerPosition[3]{};
|
||||||
|
world::UnitPosition(player, playerPosition);
|
||||||
|
const float dxRow = root->placement[12] - playerPosition[0];
|
||||||
|
const float dyRow = root->placement[13] - playerPosition[1];
|
||||||
|
const float dzRow = root->placement[14] - playerPosition[2];
|
||||||
|
const float rowDistanceSq = dxRow * dxRow + dyRow * dyRow + dzRow * dzRow;
|
||||||
|
const float dxColumn = root->placement[3] - playerPosition[0];
|
||||||
|
const float dyColumn = root->placement[7] - playerPosition[1];
|
||||||
|
const float dzColumn = root->placement[11] - playerPosition[2];
|
||||||
|
const float columnDistanceSq = dxColumn * dxColumn + dyColumn * dyColumn + dzColumn * dzColumn;
|
||||||
|
if (std::min(rowDistanceSq, columnDistanceSq) > 9.0f)
|
||||||
|
{
|
||||||
|
lastError_ = "Waiting for matching active player CharModelObject";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cmo->raceId == 0 ||
|
||||||
|
cmo->raceId > 11 || cmo->genderId > 1)
|
||||||
|
{
|
||||||
|
lastError_ = "Active player CharModelObject is not ready";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
charModelObject_ = cmo;
|
||||||
|
parentRenderContext_ = cmo->sceneNode;
|
||||||
|
race_ = cmo->raceId;
|
||||||
|
gender_ = cmo->genderId;
|
||||||
|
playerDetected_ = true;
|
||||||
|
supported_ = race_ == kBloodElfRace && gender_ == kFemaleGender;
|
||||||
|
lastError_ = supported_ ? "Ready" : "Prototype supports Blood Elf Female only.";
|
||||||
|
|
||||||
|
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "player detected");
|
||||||
|
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "race=%u gender=%u", race_, gender_);
|
||||||
|
if (!supported_)
|
||||||
|
api_->Log(WXL_LOG_WARN, "MoonwellCustomization", "unsupported player race");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CustomizationRenderer::OnWorldEnter()
|
||||||
|
{
|
||||||
|
inWorld_ = true;
|
||||||
|
detectDelay_ = 0.0f;
|
||||||
|
DetectPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
void CustomizationRenderer::OnWorldLeave()
|
||||||
|
{
|
||||||
|
inWorld_ = false;
|
||||||
|
Detach();
|
||||||
|
observedCharModelObject_ = nullptr;
|
||||||
|
charModelObject_ = nullptr;
|
||||||
|
parentRenderContext_ = nullptr;
|
||||||
|
playerDetected_ = supported_ = false;
|
||||||
|
race_ = gender_ = 0;
|
||||||
|
detectDelay_ = 0.0f;
|
||||||
|
refreshRequested_ = false;
|
||||||
|
geosetApplied_ = false;
|
||||||
|
attached_ = false;
|
||||||
|
finalizeInProgress_ = false;
|
||||||
|
lastError_ = "Outside world";
|
||||||
|
}
|
||||||
|
|
||||||
|
void CustomizationRenderer::OnUpdate(float dt)
|
||||||
|
{
|
||||||
|
// OnWorldEnter is an edge notification and can be missed when the
|
||||||
|
// extension is loaded after a world transition has already begun.
|
||||||
|
// Reconcile against the authoritative active-player GUID so the
|
||||||
|
// renderer also recovers after late loads and unusual glue flows.
|
||||||
|
const auto activePlayerGuid = world::ActivePlayerGuid();
|
||||||
|
if (!activePlayerGuid)
|
||||||
|
{
|
||||||
|
if (inWorld_) OnWorldLeave();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!inWorld_)
|
||||||
|
{
|
||||||
|
inWorld_ = true;
|
||||||
|
detectDelay_ = 0.0f;
|
||||||
|
lastError_ = "Waiting for active player model";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playerDetected_)
|
||||||
|
{
|
||||||
|
detectDelay_ -= dt;
|
||||||
|
if (detectDelay_ <= 0.0f)
|
||||||
|
{
|
||||||
|
detectDelay_ = 0.25f;
|
||||||
|
DetectPlayer();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshRequested_)
|
||||||
|
{
|
||||||
|
refreshRequested_ = false;
|
||||||
|
const uint32_t choiceId = CurrentChoice();
|
||||||
|
if (choiceId != 0 && choiceId != kHornsOption.choices[0].id)
|
||||||
|
{
|
||||||
|
Detach();
|
||||||
|
if (const auto* choice = FindChoice(choiceId)) Attach(*choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachmentRenderContext_ && attachmentModel_ && !geosetApplied_)
|
||||||
|
{
|
||||||
|
const auto* choice = FindChoice(CurrentChoice());
|
||||||
|
if (choice && choice->elements[0].type == ElementType::Geoset)
|
||||||
|
FilterGeoset(attachmentModel_, choice->elements[0].geosetId, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSceneModel loads asynchronously. Attaching it before the stock
|
||||||
|
// readiness gate succeeds makes the parent character non-drawable too.
|
||||||
|
// Keep it unattached while loading, then join the stock head point only after the
|
||||||
|
// child itself reports drawable.
|
||||||
|
if (attachmentRenderContext_ && parentRenderContext_)
|
||||||
|
{
|
||||||
|
auto* child = static_cast<m2::off::M2Instance*>(attachmentRenderContext_);
|
||||||
|
auto* parent = static_cast<m2::off::M2Instance*>(parentRenderContext_);
|
||||||
|
const auto* choice = FindChoice(CurrentChoice());
|
||||||
|
const bool collectionGeoset = choice &&
|
||||||
|
choice->elements[0].type == ElementType::Geoset;
|
||||||
|
if (collectionGeoset && !attached_)
|
||||||
|
{
|
||||||
|
std::memcpy(child->placement, parent->placement, sizeof child->placement);
|
||||||
|
child->viewDistSq = parent->viewDistSq;
|
||||||
|
child->alphaBase = parent->alphaBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (geosetApplied_ && !attached_)
|
||||||
|
{
|
||||||
|
const int drawable = wxl::game::Native<m2::off::M2_IsDrawableFn>(
|
||||||
|
m2::off::kIsDrawable)(child, nullptr, 0, 0);
|
||||||
|
if (drawable)
|
||||||
|
{
|
||||||
|
m2::AttachToScene(parentRenderContext_, child,
|
||||||
|
kCollectionAttachmentSlot, false);
|
||||||
|
if (reinterpret_cast<void*>(static_cast<uintptr_t>(child->parent)) ==
|
||||||
|
parentRenderContext_)
|
||||||
|
{
|
||||||
|
attached_ = true;
|
||||||
|
lastError_ = "Ready";
|
||||||
|
api_->Log(WXL_LOG_INFO, "MoonwellCustomization",
|
||||||
|
"attached ready overlay slot=%u", kCollectionAttachmentSlot);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lastError_ = "Native head attachment was rejected";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
lastError_ = "Collection loaded; waiting for drawable readiness";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CustomizationRenderer::OnEquipmentChanged(void* charModelObject)
|
||||||
|
{
|
||||||
|
if (!playerDetected_ && charModelObject)
|
||||||
|
{
|
||||||
|
observedCharModelObject_ = charModelObject;
|
||||||
|
DetectPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Item events are emitted around native equipment maintenance. Rebuild on
|
||||||
|
// the next logic tick so native slot work is complete before slot 19 is touched.
|
||||||
|
if (charModelObject == charModelObject_)
|
||||||
|
{
|
||||||
|
const auto* choice = FindChoice(CurrentChoice());
|
||||||
|
if (choice && choice->elements[0].type != ElementType::None)
|
||||||
|
refreshRequested_ = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CustomizationRenderer::OnSkinFinalize(void* model)
|
||||||
|
{
|
||||||
|
if (!model || model != attachmentModel_ || finalizeInProgress_) return;
|
||||||
|
const auto* choice = FindChoice(CurrentChoice());
|
||||||
|
if (choice && choice->elements[0].type == ElementType::Geoset)
|
||||||
|
FilterGeoset(model, choice->elements[0].geosetId, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CustomizationRenderer::OnBuildBonePalette(void* renderContext)
|
||||||
|
{
|
||||||
|
if (!renderContext || renderContext != attachmentRenderContext_) return;
|
||||||
|
|
||||||
|
const auto* choice = FindChoice(CurrentChoice());
|
||||||
|
if (!choice || choice->elements[0].type != ElementType::Geoset) return;
|
||||||
|
|
||||||
|
auto* child = static_cast<m2::off::M2Instance*>(renderContext);
|
||||||
|
auto* parent = static_cast<m2::off::M2Instance*>(parentRenderContext_);
|
||||||
|
if (!parent || !child->bonePalettePtr || !parent->bonePalettePtr)
|
||||||
|
return;
|
||||||
|
|
||||||
|
m2::M2Model childModel(reinterpret_cast<void*>(child->model));
|
||||||
|
m2::M2Model parentModel(reinterpret_cast<void*>(parent->model));
|
||||||
|
if (!childModel || !parentModel || !childModel.GetHeader() || !parentModel.GetHeader()) return;
|
||||||
|
|
||||||
|
const uint32_t count = std::min(childModel.GetHeader()->bones.count,
|
||||||
|
parentModel.GetHeader()->bones.count);
|
||||||
|
if (count)
|
||||||
|
std::memcpy(reinterpret_cast<void*>(child->bonePalettePtr),
|
||||||
|
reinterpret_cast<const void*>(parent->bonePalettePtr),
|
||||||
|
static_cast<size_t>(count) * m2::off::kBonePaletteStride);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CustomizationRenderer::FilterGeoset(void* rawModel, uint32_t geosetId, bool finalize)
|
||||||
|
{
|
||||||
|
m2::M2Model model(rawModel);
|
||||||
|
auto* skin = model ? model.GetSkin() : nullptr;
|
||||||
|
if (!IsReadableRange(skin, sizeof *skin) || !skin->indices ||
|
||||||
|
!skin->submeshes || !skin->indexCount || !skin->submeshCount)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
// A newly created async scene model can expose the skin object before
|
||||||
|
// its offset-valued arrays have been relocated to pointers. Do not
|
||||||
|
// touch those arrays until both complete ranges are readable.
|
||||||
|
constexpr uint32_t kMaxIndexCount = 10u * 1024u * 1024u;
|
||||||
|
constexpr uint32_t kMaxSubmeshCount = 100u * 1024u;
|
||||||
|
if (skin->indexCount > kMaxIndexCount || skin->submeshCount > kMaxSubmeshCount ||
|
||||||
|
!IsReadableRange(skin->indices,
|
||||||
|
static_cast<size_t>(skin->indexCount) * sizeof(uint16_t)) ||
|
||||||
|
!IsReadableRange(skin->submeshes,
|
||||||
|
static_cast<size_t>(skin->submeshCount) * sizeof(sm2::M2SkinSection)))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
bool found = false;
|
||||||
|
for (uint32_t i = 0; i < skin->submeshCount; ++i)
|
||||||
|
{
|
||||||
|
const sm2::M2SkinSection& section = skin->submeshes[i];
|
||||||
|
const uint32_t start = (uint32_t(section.level) << 16) | section.indexStart;
|
||||||
|
const uint32_t count = section.indexCount;
|
||||||
|
if (start > skin->indexCount || count > skin->indexCount - start) continue;
|
||||||
|
if (section.skinSectionId == geosetId)
|
||||||
|
{
|
||||||
|
found = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
std::fill_n(skin->indices + start, count, uint16_t(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!found)
|
||||||
|
{
|
||||||
|
SetError("requested customization geoset is absent from collection skin");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (finalize)
|
||||||
|
{
|
||||||
|
finalizeInProgress_ = true;
|
||||||
|
model.FinalizeSkin();
|
||||||
|
finalizeInProgress_ = false;
|
||||||
|
}
|
||||||
|
attachmentModel_ = rawModel;
|
||||||
|
geosetApplied_ = true;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CustomizationRenderer::Attach(const CustomizationChoice& choice)
|
||||||
|
{
|
||||||
|
if (!supported_ || !charModelObject_ || !parentRenderContext_)
|
||||||
|
{
|
||||||
|
SetError("supported player model is unavailable");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const auto& element = choice.elements[0];
|
||||||
|
if (!element.modelPath)
|
||||||
|
{
|
||||||
|
SetError("customization model path is absent");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "loading model %s", element.modelPath);
|
||||||
|
auto* parent = static_cast<m2::off::M2Instance*>(parentRenderContext_);
|
||||||
|
void* scene = reinterpret_cast<void*>(static_cast<uintptr_t>(parent->scene));
|
||||||
|
if (!scene)
|
||||||
|
{
|
||||||
|
SetError("active player scene is unavailable");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateSceneModel's native receiver is the owning scene, not the
|
||||||
|
// CharModelObject. Passing the CMO makes the loader interpret cmo+4
|
||||||
|
// as the scene's model table and faults inside 0x0081C4F0.
|
||||||
|
void* child = m2::GetRenderCtx(scene, const_cast<char*>(element.modelPath));
|
||||||
|
if (!child)
|
||||||
|
{
|
||||||
|
SetError("CreateSceneModel returned null");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
attachmentRenderContext_ = child;
|
||||||
|
auto* instance = static_cast<m2::off::M2Instance*>(child);
|
||||||
|
attachmentModel_ = reinterpret_cast<void*>(instance->model);
|
||||||
|
geosetApplied_ = element.type != ElementType::Geoset;
|
||||||
|
attached_ = false;
|
||||||
|
|
||||||
|
// Native WotLK head components must retain their own local transform;
|
||||||
|
// AttachToScene composes it with the parent's head point. Collection
|
||||||
|
// geosets use a full-character coordinate system and are seeded below.
|
||||||
|
if (element.type == ElementType::Geoset)
|
||||||
|
{
|
||||||
|
auto* parentInstance = static_cast<m2::off::M2Instance*>(parentRenderContext_);
|
||||||
|
std::memcpy(instance->placement, parentInstance->placement, sizeof instance->placement);
|
||||||
|
instance->viewDistSq = parentInstance->viewDistSq;
|
||||||
|
instance->alphaBase = parentInstance->alphaBase;
|
||||||
|
}
|
||||||
|
|
||||||
|
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "created overlay geoset=%u",
|
||||||
|
choice.elements[0].geosetId);
|
||||||
|
lastError_ = "Loading collection model";
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void CustomizationRenderer::Detach()
|
||||||
|
{
|
||||||
|
if (!attachmentRenderContext_) return;
|
||||||
|
if (attached_ && parentRenderContext_)
|
||||||
|
m2::DetachSlot(parentRenderContext_, kCollectionAttachmentSlot);
|
||||||
|
else
|
||||||
|
m2::ReleaseRenderCtx(attachmentRenderContext_);
|
||||||
|
attachmentRenderContext_ = nullptr;
|
||||||
|
attachmentModel_ = nullptr;
|
||||||
|
geosetApplied_ = false;
|
||||||
|
attached_ = false;
|
||||||
|
finalizeInProgress_ = false;
|
||||||
|
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "detached");
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CustomizationRenderer::Apply(uint32_t optionId, uint32_t choiceId)
|
||||||
|
{
|
||||||
|
const auto* choice = FindChoice(choiceId);
|
||||||
|
if (!choice || optionId != kHornsOption.id) return false;
|
||||||
|
if (!supported_)
|
||||||
|
{
|
||||||
|
lastError_ = "Prototype supports Blood Elf Female only.";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const bool isNone = choice->elements[0].type == ElementType::None;
|
||||||
|
if (CurrentChoice() == choiceId && (isNone || attachmentRenderContext_)) return true;
|
||||||
|
|
||||||
|
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "apply option=%u choice=%u",
|
||||||
|
optionId, choiceId);
|
||||||
|
Detach();
|
||||||
|
state_.SetChoice(optionId, choiceId);
|
||||||
|
if (isNone) return true;
|
||||||
|
return Attach(*choice);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CustomizationState.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
struct WXL_Api;
|
||||||
|
|
||||||
|
namespace moonwell::customization
|
||||||
|
{
|
||||||
|
class CustomizationRenderer
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
explicit CustomizationRenderer(const WXL_Api* api) : api_(api) {}
|
||||||
|
|
||||||
|
void OnWorldEnter();
|
||||||
|
void OnWorldLeave();
|
||||||
|
void OnUpdate(float dt);
|
||||||
|
void OnEquipmentChanged(void* charModelObject);
|
||||||
|
void OnSkinFinalize(void* model);
|
||||||
|
void OnBuildBonePalette(void* renderContext);
|
||||||
|
|
||||||
|
bool Apply(uint32_t optionId, uint32_t choiceId);
|
||||||
|
bool PlayerDetected() const { return playerDetected_; }
|
||||||
|
bool Supported() const { return supported_; }
|
||||||
|
uint32_t Race() const { return race_; }
|
||||||
|
uint32_t Gender() const { return gender_; }
|
||||||
|
uint32_t CurrentChoice() const { return state_.Choice(kHornsOption.id); }
|
||||||
|
const char* LastError() const { return lastError_; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
bool DetectPlayer();
|
||||||
|
bool Attach(const CustomizationChoice& choice);
|
||||||
|
void Detach();
|
||||||
|
bool FilterGeoset(void* model, uint32_t geosetId, bool finalize);
|
||||||
|
void SetError(const char* message);
|
||||||
|
|
||||||
|
const WXL_Api* api_ = nullptr;
|
||||||
|
CustomizationState state_;
|
||||||
|
void* observedCharModelObject_ = nullptr;
|
||||||
|
void* charModelObject_ = nullptr;
|
||||||
|
void* parentRenderContext_ = nullptr;
|
||||||
|
void* attachmentRenderContext_ = nullptr;
|
||||||
|
void* attachmentModel_ = nullptr;
|
||||||
|
uint32_t race_ = 0;
|
||||||
|
uint32_t gender_ = 0;
|
||||||
|
float detectDelay_ = 0.0f;
|
||||||
|
bool inWorld_ = false;
|
||||||
|
bool playerDetected_ = false;
|
||||||
|
bool supported_ = false;
|
||||||
|
bool refreshRequested_ = false;
|
||||||
|
bool geosetApplied_ = false;
|
||||||
|
bool attached_ = false;
|
||||||
|
bool finalizeInProgress_ = false;
|
||||||
|
const char* lastError_ = "Waiting for player";
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "CustomizationData.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace moonwell::customization
|
||||||
|
{
|
||||||
|
class CustomizationState
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
uint32_t Choice(uint32_t optionId) const
|
||||||
|
{
|
||||||
|
return optionId == kHornsOption.id ? hornsChoice_ : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SetChoice(uint32_t optionId, uint32_t choiceId)
|
||||||
|
{
|
||||||
|
if (optionId != kHornsOption.id || !FindChoice(choiceId)) return false;
|
||||||
|
hornsChoice_ = choiceId;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
void Clear() { hornsChoice_ = 0; }
|
||||||
|
|
||||||
|
private:
|
||||||
|
uint32_t hornsChoice_ = 0;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
#include "CustomizationRenderer.hpp"
|
||||||
|
|
||||||
|
#include <wxl/PluginApi.h>
|
||||||
|
|
||||||
|
#include "engine/events/Event.hpp"
|
||||||
|
|
||||||
|
#include <cstdint>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <memory>
|
||||||
|
|
||||||
|
namespace moonwell::customization
|
||||||
|
{
|
||||||
|
namespace ev = wxl::events;
|
||||||
|
|
||||||
|
const WXL_Api* g_api = nullptr;
|
||||||
|
std::unique_ptr<CustomizationRenderer> g_renderer;
|
||||||
|
|
||||||
|
void __cdecl OnWorldEnter(void*, const void*) { g_renderer->OnWorldEnter(); }
|
||||||
|
void __cdecl OnWorldLeave(void*, const void*) { g_renderer->OnWorldLeave(); }
|
||||||
|
|
||||||
|
void __cdecl OnUpdate(void*, const void* raw)
|
||||||
|
{
|
||||||
|
const auto* args = static_cast<const ev::UpdateArgs*>(raw);
|
||||||
|
g_renderer->OnUpdate(args ? args->dt : 0.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
void __cdecl OnItemSlotChange(void*, const void* raw)
|
||||||
|
{
|
||||||
|
const auto* args = static_cast<const ev::ItemSlotChangeArgs*>(raw);
|
||||||
|
if (args) g_renderer->OnEquipmentChanged(args->charModelObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
void __cdecl OnItemSlotClear(void*, const void* raw)
|
||||||
|
{
|
||||||
|
const auto* args = static_cast<const ev::ItemSlotClearArgs*>(raw);
|
||||||
|
if (args) g_renderer->OnEquipmentChanged(args->charModelObj);
|
||||||
|
}
|
||||||
|
|
||||||
|
void __cdecl OnSkinFinalize(void*, const void* raw)
|
||||||
|
{
|
||||||
|
const auto* args = static_cast<const ev::M2SkinFinalizeArgs*>(raw);
|
||||||
|
if (args) g_renderer->OnSkinFinalize(args->model);
|
||||||
|
}
|
||||||
|
|
||||||
|
void __cdecl OnBuildBonePalette(void*, const void* raw)
|
||||||
|
{
|
||||||
|
const auto* args = static_cast<const ev::BuildBonePaletteArgs*>(raw);
|
||||||
|
if (args) g_renderer->OnBuildBonePalette(args->renderCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DrawPanel(void*)
|
||||||
|
{
|
||||||
|
char line[128]{};
|
||||||
|
if (!g_renderer->PlayerDetected())
|
||||||
|
{
|
||||||
|
g_api->UiText("Player: detecting...");
|
||||||
|
g_api->UiText(g_renderer->LastError());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::snprintf(line, sizeof(line), "Race ID: %u%s", g_renderer->Race(),
|
||||||
|
g_renderer->Race() == kBloodElfRace ? " (Blood Elf)" : "");
|
||||||
|
g_api->UiText(line);
|
||||||
|
std::snprintf(line, sizeof(line), "Gender ID: %u%s", g_renderer->Gender(),
|
||||||
|
g_renderer->Gender() == kFemaleGender ? " (Female)" : "");
|
||||||
|
g_api->UiText(line);
|
||||||
|
g_api->UiSeparator();
|
||||||
|
|
||||||
|
if (!g_renderer->Supported())
|
||||||
|
{
|
||||||
|
g_api->UiText("Prototype supports Blood Elf Female only.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
g_api->UiText("Option: Head Decorations (WotLK fallback)");
|
||||||
|
for (const auto& choice : kHornsOption.choices)
|
||||||
|
{
|
||||||
|
std::snprintf(line, sizeof(line), "%s%s", choice.name,
|
||||||
|
g_renderer->CurrentChoice() == choice.id ? " [selected]" : "");
|
||||||
|
if (g_api->UiButton(line))
|
||||||
|
g_renderer->Apply(kHornsOption.id, choice.id);
|
||||||
|
}
|
||||||
|
g_api->UiSeparator();
|
||||||
|
g_api->UiText(g_renderer->LastError());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const WXL_PluginInfo* __cdecl WXL_Query(void)
|
||||||
|
{
|
||||||
|
static const WXL_PluginInfo info = {
|
||||||
|
sizeof(WXL_PluginInfo), WXL_API_VERSION, "MoonwellCustomization", 1, WXL_CLIENT_BUILD,
|
||||||
|
};
|
||||||
|
return &info;
|
||||||
|
}
|
||||||
|
|
||||||
|
int __cdecl WXL_Load(const WXL_Api* api)
|
||||||
|
{
|
||||||
|
using namespace moonwell::customization;
|
||||||
|
if (!api || api->apiVersion != WXL_API_VERSION ||
|
||||||
|
api->structSize < sizeof(WXL_Api) || !api->Subscribe || !api->UiAddPanel)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
g_api = api;
|
||||||
|
g_renderer = std::make_unique<CustomizationRenderer>(api);
|
||||||
|
api->Subscribe(uint32_t(ev::Event::OnWorldEnter), &OnWorldEnter, nullptr);
|
||||||
|
api->Subscribe(uint32_t(ev::Event::OnWorldLeave), &OnWorldLeave, nullptr);
|
||||||
|
api->Subscribe(uint32_t(ev::Event::OnUpdate), &OnUpdate, nullptr);
|
||||||
|
api->Subscribe(uint32_t(ev::Event::OnItemSlotChange), &OnItemSlotChange, nullptr);
|
||||||
|
api->Subscribe(uint32_t(ev::Event::OnItemSlotClear), &OnItemSlotClear, nullptr);
|
||||||
|
api->Subscribe(uint32_t(ev::Event::OnM2SkinFinalize), &OnSkinFinalize, nullptr);
|
||||||
|
api->Subscribe(uint32_t(ev::Event::OnBuildBonePalette), &OnBuildBonePalette, nullptr);
|
||||||
|
api->UiAddPanel("MoonWell Customization", &DrawPanel, nullptr);
|
||||||
|
api->Log(WXL_LOG_INFO, "MoonwellCustomization", "loaded");
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,410 +0,0 @@
|
|||||||
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"
|
|
||||||
+7
-42
@@ -119,35 +119,6 @@ fn stage_loose_assets(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stage_customization_archives(
|
|
||||||
project_root: &Path,
|
|
||||||
output_dir: &Path,
|
|
||||||
) -> Result<(), Box<dyn Error>> {
|
|
||||||
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<dyn Error>> {
|
fn main() -> Result<(), Box<dyn Error>> {
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
|
|
||||||
@@ -196,26 +167,20 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
let sub_name = sub.file_name().to_string_lossy().to_string();
|
let sub_name = sub.file_name().to_string_lossy().to_string();
|
||||||
let output_mpq = output_dir.join("Data").join(&name).join(&sub_name);
|
let output_mpq = output_dir.join("Data").join(&name).join(&sub_name);
|
||||||
// The final ruRU Z layer is the compatibility authority for customization,
|
build_mpq(&sub_path, &output_mpq, None)?;
|
||||||
// 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 {
|
} else {
|
||||||
// Regular patch dir, e.g. patch-4/ or patch-Z/
|
// Regular patch dir, e.g. patch-4/ or patch-Z/
|
||||||
let output_mpq = output_dir.join("Data").join(&name);
|
let output_mpq = output_dir.join("Data").join(&name);
|
||||||
build_mpq(&path, &output_mpq, None)?;
|
let creature_files = if name.eq_ignore_ascii_case("patch-Z") {
|
||||||
|
generated_creatures.as_ref()
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
build_mpq(&path, &output_mpq, creature_files)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stage_customization_archives(&project_root, &output_dir)?;
|
|
||||||
|
|
||||||
println!("\nAll MPQ archives built successfully.");
|
println!("\nAll MPQ archives built successfully.");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
// Development-only catalog reader. It consumes the JSON generated by the
|
||||||
|
// MoonWell-enabled wow.export checkout; wow.export is not a runtime dependency.
|
||||||
|
const fs = require('fs');
|
||||||
|
|
||||||
|
const [catalogPath, raceName = 'Blood Elf', sexName = 'female', optionName = 'Earrings'] = process.argv.slice(2);
|
||||||
|
if (!catalogPath) {
|
||||||
|
console.error('Usage: node tools/find_customization_assets.js <catalog.json> [race] [sex] [option]');
|
||||||
|
process.exit(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
|
||||||
|
const race = catalog.races.find(value => value.name.toLowerCase() === raceName.toLowerCase());
|
||||||
|
const sex = race && race.sexes.find(value => value.name.toLowerCase() === sexName.toLowerCase());
|
||||||
|
const option = sex && sex.options.find(value => value.name.toLowerCase() === optionName.toLowerCase());
|
||||||
|
if (!race || !sex || !option) {
|
||||||
|
console.error(`Not found: race=${raceName}, sex=${sexName}, option=${optionName}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`retailBuild=${catalog.source && catalog.source.build}`);
|
||||||
|
console.log(`raceID=${race.id} chrModelID=${sex.chrModelID} modelFileDataID=${sex.modelFileDataID}`);
|
||||||
|
console.log(`optionID=${option.id} option=${option.name}`);
|
||||||
|
for (const choice of option.choices) {
|
||||||
|
for (const element of choice.elements) {
|
||||||
|
const fields = [
|
||||||
|
`choiceID=${choice.id}`,
|
||||||
|
`order=${choice.orderIndex}`,
|
||||||
|
`elementID=${element.id}`,
|
||||||
|
`geosetID=${element.geosetID || 0}`,
|
||||||
|
`skinnedModelID=${element.skinnedModelID || 0}`,
|
||||||
|
];
|
||||||
|
if (element.material) fields.push(`FileDataID=${element.material.FileDataID || 0}`);
|
||||||
|
if (element.skinnedModel) fields.push(`collectionFileDataID=${element.skinnedModel.collectionFileDataID || 0}`);
|
||||||
|
console.log(fields.join(' '));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user