Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1c2b4e8bb | |||
| 83f5e08689 | |||
| 76ca0cc851 | |||
| 398245d27d | |||
| 887ab7879b | |||
| e3b8d2649e | |||
| e916cdea19 | |||
| befdaa456d | |||
| 7291fff664 | |||
| 790506fc7a | |||
| 87329f2633 | |||
| 8375b95f7b | |||
| 01f476ca1b | |||
| 63c9af7846 | |||
| f02f4d73f0 | |||
| 721833bb50 | |||
| 85f80445d1 | |||
| 9fdb7ed718 | |||
| 698de84762 | |||
| cddaf2c515 |
@@ -1,15 +1,20 @@
|
||||
[submodule "vendor/warcraftxl"]
|
||||
path = vendor/warcraftxl
|
||||
url = https://github.com/WarcraftXL/wxl-core.git
|
||||
branch = main
|
||||
[submodule "vendor/modules/wxl-modern-assets"]
|
||||
path = vendor/modules/wxl-modern-assets
|
||||
url = https://github.com/WarcraftXL/wxl-modern-assets.git
|
||||
branch = main
|
||||
[submodule "vendor/modules/wxl-modern-render"]
|
||||
path = vendor/modules/wxl-modern-render
|
||||
url = https://github.com/WarcraftXL/wxl-modern-render.git
|
||||
branch = main
|
||||
[submodule "vendor/modules/wxl-unit-outline"]
|
||||
path = vendor/modules/wxl-unit-outline
|
||||
url = https://github.com/WarcraftXL/wxl-unit-outline.git
|
||||
branch = main
|
||||
[submodule "vendor/modules/wxl-modern-adt"]
|
||||
path = vendor/modules/wxl-modern-adt
|
||||
url = https://github.com/WarcraftXL/wxl-modern-adt.git
|
||||
branch = main
|
||||
|
||||
@@ -41,6 +41,8 @@ if(TARGET WarcraftXL)
|
||||
endif()
|
||||
|
||||
if(TARGET WarcraftXLHost)
|
||||
wxl_collect_sources(WXL_MOONWELL_HOST
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/modules/moonwell/host/*.cpp")
|
||||
wxl_collect_sources(WXL_MODERN_ASSETS_HOST
|
||||
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-assets/host/*.cpp"
|
||||
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-assets/shared/*.cpp")
|
||||
@@ -53,6 +55,7 @@ if(TARGET WarcraftXLHost)
|
||||
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-adt/shared/*.cpp")
|
||||
|
||||
target_sources(WarcraftXLHost PRIVATE
|
||||
${WXL_MOONWELL_HOST}
|
||||
${WXL_MODERN_ASSETS_HOST}
|
||||
${WXL_MODERN_ADT_HOST})
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,788 @@
|
||||
# План расширенной кастомизации персонажей MoonWell
|
||||
|
||||
## 1. Цель
|
||||
|
||||
Создать для клиента WoW 3.3.5a (build 12340) расширенную систему внешности, визуально и по удобству близкую к retail, сохранив оригинальный `Wow.exe` и используя WarcraftXL для всех клиентских расширений.
|
||||
|
||||
Система должна:
|
||||
|
||||
- поддерживать стандартные параметры 3.3.5a и дополнительные параметры MoonWell;
|
||||
- показывать одинаковую внешность при создании, выборе персонажа, входе в мир и другим игрокам;
|
||||
- хранить дополнительные параметры на сервере и считать сервер источником истины;
|
||||
- поддерживать текстурные слои, geoset базовой модели и отдельные прикрепляемые M2-модели;
|
||||
- работать без Eluna/AIO в качестве постоянного транспорта состояния;
|
||||
- позволять добавлять новые варианты преимущественно данными и ассетами;
|
||||
- иметь версионирование формата и безопасный откат.
|
||||
|
||||
## 2. Не входит в первую версию
|
||||
|
||||
- полное копирование внутренних `ChrCustomization*` DB2 и протокола актуального retail-клиента;
|
||||
- поддержка всех retail-рас, которых нет в MoonWell;
|
||||
- динамическое изменение скелета или пропорций тела;
|
||||
- автоматический импорт всех retail-ассетов без ручной проверки;
|
||||
- публикация полного дампа современных файлов Blizzard;
|
||||
- изменение бинарника `Wow.exe` на диске.
|
||||
|
||||
## 3. Базовые ограничения
|
||||
|
||||
### Клиент
|
||||
|
||||
- WoW 3.3.5a, build 12340, Lua 5.1, `## Interface: 30300`.
|
||||
- `Wow.exe` должен оставаться оригинальным с контролируемым SHA-256.
|
||||
- Новое нативное поведение реализуется модулем MoonWell внутри WarcraftXL.
|
||||
- GlueXML и FrameXML не используют retail API (`C_Timer`, `CreateFramePool`, `PixelUtil` и т. п.).
|
||||
- Для анимаций интерфейса используется `OnUpdate`.
|
||||
- UI-текстуры: `.blp`/`.tga`, размеры по степеням двойки, не более 512×512 на отдельный UI-тайл.
|
||||
- В Lua/XML использовать `SetWidth`/`SetHeight`, а не `SetSize`.
|
||||
|
||||
### Ассеты и рендер
|
||||
|
||||
- Исходные современные M2/MD21 обрабатываются `wxl-modern-assets`.
|
||||
- Каждая модель проверяется на поддерживаемую версию формата, лимит костей, материалы, анимации и attachment points.
|
||||
- Не использовать OBJ/FBX/glTF как вход для игрового клиента: нужны исходные `.m2`, `.skin`, `.skel`, `.anim` и `.blp`.
|
||||
- UI-превью и модель в мире должны использовать один каталог вариантов и одинаковые идентификаторы.
|
||||
|
||||
### Сервер
|
||||
|
||||
- Сервер валидирует расу, пол, доступность и совместимость каждого выбора.
|
||||
- Клиент не может назначить себе недоступный вариант внешности.
|
||||
- Дополнительные параметры хранятся отдельно от стандартных байтов внешности 3.3.5a.
|
||||
- Изменения схемы БД поставляются миграциями и имеют обратимый путь.
|
||||
|
||||
## 4. Целевая архитектура
|
||||
|
||||
```text
|
||||
Retail CDN (выбранные FileDataID)
|
||||
│
|
||||
▼
|
||||
Asset manifest + downloader + validation
|
||||
│
|
||||
▼
|
||||
Patch*.MPQ / loose Patch-WXL assets
|
||||
│
|
||||
├── wxl-modern-assets
|
||||
├── адаптированный wxl-equip-extension
|
||||
└── MoonWell customization runtime
|
||||
│
|
||||
Lua API / GlueXML UI
|
||||
│
|
||||
▼
|
||||
MoonWell customization protocol
|
||||
│
|
||||
▼
|
||||
AzerothCore storage + validation + broadcast
|
||||
```
|
||||
|
||||
### Компоненты
|
||||
|
||||
1. **Asset pipeline** — получает только перечисленные зависимости по `FileDataID`, сохраняет манифест и проверяет комплектность.
|
||||
2. **`wxl-modern-assets`** — загружает и приводит современные M2/MD21 и текстуры к контракту клиента 3.3.5a.
|
||||
3. **Адаптированный `wxl-equip-extension`** — прикрепляет M2, поддерживает collection M2, два модельных канала, attachment points и фильтрацию geoset.
|
||||
4. **MoonWell customization runtime** — управляет независимыми косметическими каналами, текстурными слоями, geoset базовой модели и Lua API.
|
||||
5. **GlueXML UI** — категории, список вариантов, камера, случайный выбор, сброс и предварительный просмотр.
|
||||
6. **Серверный модуль** — хранение, валидация, загрузка, изменение и распространение внешности.
|
||||
|
||||
## 5. Типы параметров внешности
|
||||
|
||||
### Стандартные параметры 3.3.5a
|
||||
|
||||
- пол;
|
||||
- цвет кожи;
|
||||
- лицо;
|
||||
- причёска;
|
||||
- цвет волос;
|
||||
- стандартная особенность лица/борода.
|
||||
|
||||
Они продолжают использовать штатные поля персонажа и стандартный пакет создания.
|
||||
|
||||
### Дополнительные текстурные параметры
|
||||
|
||||
- шрамы;
|
||||
- татуировки тела;
|
||||
- татуировки лица;
|
||||
- макияж;
|
||||
- рисунок/отметины;
|
||||
- дополнительные оттенки кожи;
|
||||
- дополнительные слои лица.
|
||||
|
||||
Реализация: расширение `CharSections`, каталог слоёв и модификация/перехват клиентского композитора текстуры персонажа.
|
||||
|
||||
### Geoset базовой модели
|
||||
|
||||
- дополнительные причёски;
|
||||
- части бороды и усов;
|
||||
- форма ушей/бровей;
|
||||
- варианты клыков;
|
||||
- встроенные рога;
|
||||
- скрытие частей базовой модели.
|
||||
|
||||
Реализация: таблица выбора geoset по расе, полу, категории и варианту.
|
||||
|
||||
### Прикрепляемые M2-элементы
|
||||
|
||||
- серьги и пирсинг;
|
||||
- украшения для волос;
|
||||
- объёмные бороды;
|
||||
- рога и дополнительные клыки;
|
||||
- протезы;
|
||||
- венцы, диадемы и прочие аксессуары;
|
||||
- независимые объёмные элементы тела.
|
||||
|
||||
Реализация: косметические каналы поверх механизма `wxl-equip-extension`.
|
||||
|
||||
## 6. Косметические каналы
|
||||
|
||||
Кастомизация не должна занимать реальные слоты экипировки. Нужен отдельный реестр каналов:
|
||||
|
||||
```text
|
||||
hair_extra
|
||||
beard_extra
|
||||
horns
|
||||
ears
|
||||
tusks
|
||||
piercing_1
|
||||
piercing_2
|
||||
accessory_head
|
||||
accessory_face
|
||||
accessory_body_1
|
||||
accessory_body_2
|
||||
```
|
||||
|
||||
Для каждого канала задаются:
|
||||
|
||||
- стабильный числовой `option_id`;
|
||||
- допустимые расы и пол;
|
||||
- `display_id` или прямое описание M2;
|
||||
- Model1/Model2;
|
||||
- Texture1/Texture2;
|
||||
- attachment point для каждого модельного канала;
|
||||
- список разрешённых geoset collection M2;
|
||||
- флаги суффиксов расы/пола и структуры каталогов;
|
||||
- правила конфликта со шлемом и настоящей экипировкой;
|
||||
- приоритет и взаимоисключающие каналы;
|
||||
- значение сброса (`choice_id = 0`).
|
||||
|
||||
## 7. Адаптация `wxl-equip-extension`
|
||||
|
||||
### Обязательные работы
|
||||
|
||||
- закрепить точную ревизию репозитория как submodule;
|
||||
- адаптировать сборочную структуру к текущему WarcraftXL MoonWell;
|
||||
- проверить пересечение хуков с `wxl-modern-assets` и существующими `OnItemSlotChange`, `OnItemSlotClear`, `OnM2PerFrameUpdate`;
|
||||
- вынести разбор Model1/Model2, `Icon2`, attachment points и collection geoset в переиспользуемый слой;
|
||||
- добавить API независимых косметических каналов, не привязанных к надетому предмету;
|
||||
- устранить утечку/остаток collection M2 после снятия или смены варианта;
|
||||
- обеспечить корректное уничтожение дочерних моделей при смене персонажа, карты, формы и пола;
|
||||
- исключить двойное прикрепление модели при повторной синхронизации;
|
||||
- очистить мусорные Model1/Model2 в старых строках `ItemDisplayInfo.dbc`;
|
||||
- добавить диагностический лог разрешения пути, модели, текстуры, attachment point и geoset;
|
||||
- реализовать безопасный fallback при отсутствии ассета.
|
||||
|
||||
### Проверки совместимости
|
||||
|
||||
- персонаж игрока;
|
||||
- другие игроки;
|
||||
- NPC с тем же `display_id`;
|
||||
- экран создания;
|
||||
- экран выбора;
|
||||
- парикмахерская;
|
||||
- смена экипировки;
|
||||
- превращения и shapeshift;
|
||||
- транспорт;
|
||||
- телепорт и смена карты;
|
||||
- повторный вход и `/reload`.
|
||||
|
||||
## 8. Каталог данных кастомизации
|
||||
|
||||
Нужен единый, версионируемый источник данных, из которого генерируются клиентское и серверное представления.
|
||||
|
||||
Предлагаемая запись:
|
||||
|
||||
```text
|
||||
option_id
|
||||
choice_id
|
||||
race_mask
|
||||
gender_mask
|
||||
category
|
||||
label_key
|
||||
render_type
|
||||
char_section_id
|
||||
geoset_ids
|
||||
model_1
|
||||
model_2
|
||||
texture_1
|
||||
texture_2
|
||||
attachment_1
|
||||
attachment_2
|
||||
flags
|
||||
conflict_group
|
||||
unlock_condition
|
||||
asset_manifest_id
|
||||
enabled
|
||||
```
|
||||
|
||||
Требования:
|
||||
|
||||
- `(option_id, choice_id)` никогда не переиспользуется с другим смыслом;
|
||||
- удалённый вариант остаётся распознаваемым для миграции старых персонажей;
|
||||
- порядок отображения UI хранится отдельно от стабильного идентификатора;
|
||||
- клиент и сервер собираются из одной версии каталога;
|
||||
- версия каталога входит в сетевое рукопожатие.
|
||||
|
||||
## 9. Серверное хранение
|
||||
|
||||
### Предлагаемая таблица
|
||||
|
||||
```sql
|
||||
character_customization (
|
||||
guid INT UNSIGNED NOT NULL,
|
||||
option_id SMALLINT UNSIGNED NOT NULL,
|
||||
choice_id INT UNSIGNED NOT NULL,
|
||||
updated_at TIMESTAMP NOT NULL,
|
||||
PRIMARY KEY (guid, option_id)
|
||||
)
|
||||
```
|
||||
|
||||
Дополнительно:
|
||||
|
||||
- индекс по `guid`;
|
||||
- внешний ключ или очистка записей при удалении персонажа;
|
||||
- миграция с начальными значениями для существующих персонажей;
|
||||
- сервис сброса неизвестных/отключённых вариантов;
|
||||
- аудит изменения внешности при необходимости.
|
||||
|
||||
### Серверная логика
|
||||
|
||||
- загрузить набор при `Character::LoadFromDB` или эквивалентном этапе;
|
||||
- валидировать выбор по расе, полу, режиму игры и доступности;
|
||||
- сохранять транзакционно;
|
||||
- включать состояние в данные экрана выбора персонажа;
|
||||
- отправлять полный snapshot при входе в мир;
|
||||
- отправлять delta при изменении;
|
||||
- рассылать актуальное состояние игрокам в зоне видимости;
|
||||
- отправлять snapshot при появлении объекта в зоне видимости нового клиента;
|
||||
- ограничить частоту изменений;
|
||||
- запретить недоступные комбинации независимо от клиентского UI.
|
||||
|
||||
## 10. Сетевой протокол MoonWell
|
||||
|
||||
### Общие требования
|
||||
|
||||
- собственный namespace/opcode или надёжный WarcraftXL transport;
|
||||
- версия протокола и версия каталога;
|
||||
- явная длина сообщения и лимит числа записей;
|
||||
- little-endian поля с фиксированной шириной;
|
||||
- сервер является источником истины;
|
||||
- неизвестные поля пропускаются или отклоняются согласно версии;
|
||||
- отсутствие расширения не ломает стандартный вход в игру.
|
||||
|
||||
### Сообщения
|
||||
|
||||
```text
|
||||
CMSG_MOONWELL_CUSTOMIZATION_CREATE
|
||||
protocol_version
|
||||
catalog_version
|
||||
option_count
|
||||
repeated(option_id, choice_id)
|
||||
|
||||
SMSG_MOONWELL_CUSTOMIZATION_RESULT
|
||||
result_code
|
||||
normalized choices
|
||||
|
||||
SMSG_MOONWELL_CUSTOMIZATION_SNAPSHOT
|
||||
player_guid
|
||||
catalog_version
|
||||
option_count
|
||||
repeated(option_id, choice_id)
|
||||
|
||||
CMSG_MOONWELL_CUSTOMIZATION_UPDATE
|
||||
option_count
|
||||
repeated(option_id, choice_id)
|
||||
|
||||
SMSG_MOONWELL_CUSTOMIZATION_DELTA
|
||||
player_guid
|
||||
option_count
|
||||
repeated(option_id, choice_id)
|
||||
```
|
||||
|
||||
### Экран выбора персонажа
|
||||
|
||||
Предпочтительный вариант — отдельный snapshot после получения списка персонажей. Не расширять штатный пакет без строгой проверки длины и совместимости.
|
||||
|
||||
Клиент должен:
|
||||
|
||||
- сопоставить snapshot с GUID персонажа;
|
||||
- применить его к модели выбранного персонажа;
|
||||
- хранить данные только до обновления от сервера;
|
||||
- показывать стандартную внешность, если snapshot отсутствует или версия несовместима.
|
||||
|
||||
## 11. Клиентский runtime API
|
||||
|
||||
Планируемый API WarcraftXL:
|
||||
|
||||
```text
|
||||
MoonWellCustomizationGetCatalogVersion()
|
||||
MoonWellCustomizationGetOptions(race, gender)
|
||||
MoonWellCustomizationGetChoices(optionId)
|
||||
MoonWellCustomizationGetCurrent(optionId)
|
||||
MoonWellCustomizationPreview(optionId, choiceId)
|
||||
MoonWellCustomizationResetPreview()
|
||||
MoonWellCustomizationCommitPreview()
|
||||
MoonWellCustomizationRandomize()
|
||||
MoonWellCustomizationApplySnapshot(guid, data)
|
||||
MoonWellCustomizationClear(guid)
|
||||
```
|
||||
|
||||
Требования:
|
||||
|
||||
- Lua получает только безопасные операции высокого уровня;
|
||||
- нативный слой владеет указателями моделей и их жизненным циклом;
|
||||
- повторное применение snapshot идемпотентно;
|
||||
- preview не меняет серверное состояние;
|
||||
- при ошибке модель возвращается к последнему подтверждённому snapshot;
|
||||
- смена расы или пола пересчитывает допустимость всех параметров;
|
||||
- отсутствие M2/текстуры отключает только конкретный вариант, а не весь экран.
|
||||
|
||||
## 12. Применение внешности
|
||||
|
||||
Порядок применения должен быть фиксированным:
|
||||
|
||||
1. базовая раса и пол;
|
||||
2. стандартные параметры 3.3.5a;
|
||||
3. `CharSections` и текстурные слои;
|
||||
4. geoset базовой модели;
|
||||
5. скрытие/конфликты с экипировкой;
|
||||
6. независимые косметические M2-каналы;
|
||||
7. обновление материалов и композитной текстуры;
|
||||
8. окончательное обновление позы и камеры превью.
|
||||
|
||||
При смене одного параметра обновляется только затронутый слой, если это безопасно. Полная пересборка модели используется как fallback.
|
||||
|
||||
## 13. Интерфейс создания персонажа
|
||||
|
||||
### Структура
|
||||
|
||||
- категории слева или справа от модели;
|
||||
- прокручиваемая сетка вариантов;
|
||||
- текстовое название активной категории;
|
||||
- состояние выбранного варианта;
|
||||
- кнопки случайного выбора и сброса;
|
||||
- возврат к выбору расы/класса без потери валидных параметров;
|
||||
- подтверждение создания только после серверной валидации;
|
||||
- плавное приближение камеры к зоне редактирования.
|
||||
|
||||
### Категории первой версии
|
||||
|
||||
- кожа;
|
||||
- лицо;
|
||||
- причёска;
|
||||
- цвет волос;
|
||||
- борода/особенность лица;
|
||||
- шрамы;
|
||||
- татуировки;
|
||||
- серьги/пирсинг;
|
||||
- расовый аксессуар.
|
||||
|
||||
Категории скрываются, если для текущей расы/пола нет вариантов.
|
||||
|
||||
### Камера
|
||||
|
||||
- профиль камеры задаётся по расе, полу и категории;
|
||||
- отдельно настраиваются distance, вертикальная цель, горизонтальный сдвиг и FOV;
|
||||
- переход использует easing через `OnUpdate`;
|
||||
- короткие и средние расы имеют отдельные профили;
|
||||
- при выходе из кастомизации камера плавно возвращается к полному росту;
|
||||
- вращение персонажа доступно во всех режимах;
|
||||
- UI не перекрывает лицо и активный аксессуар на 4:3, 16:9 и 16:10.
|
||||
|
||||
### Ограничения UI 3.3.5a
|
||||
|
||||
- XML загружается раньше Lua;
|
||||
- именованные Button и ScrollFrame;
|
||||
- `relativeTo`, а не `relativeKey`;
|
||||
- `$parentX`, а не `$parent.X`;
|
||||
- `SetWidth`/`SetHeight` вместо `SetSize`;
|
||||
- таймеры и анимации через `OnUpdate`;
|
||||
- UI-атласы режутся на тайлы не более 512×512.
|
||||
|
||||
## 14. Парикмахерская и изменение существующего персонажа
|
||||
|
||||
- использовать тот же каталог и preview API;
|
||||
- получать текущий серверный snapshot при открытии;
|
||||
- считать стоимость на сервере;
|
||||
- подтверждать изменение одним атомарным запросом;
|
||||
- откатывать preview при закрытии или отказе сервера;
|
||||
- блокировать недоступные варианты;
|
||||
- поддерживать бесплатные/платные категории отдельными правилами;
|
||||
- обновлять внешность окружающим только после подтверждения.
|
||||
|
||||
## 15. Получение retail-ассетов без установки клиента
|
||||
|
||||
### Инструменты
|
||||
|
||||
- `wow.export` в online-режиме — поиск и предварительный просмотр;
|
||||
- Wago Tools/wow-listfile — поиск пути и `FileDataID`;
|
||||
- TACTSharp/TACTTool — точечная загрузка исходных файлов с CDN Blizzard.
|
||||
|
||||
### Манифест ассета
|
||||
|
||||
```text
|
||||
asset_id
|
||||
source_product
|
||||
source_build
|
||||
file_data_id
|
||||
original_path
|
||||
target_path
|
||||
sha256
|
||||
kind
|
||||
parent_asset_id
|
||||
license_note
|
||||
```
|
||||
|
||||
### Пайплайн
|
||||
|
||||
1. Выбрать модель и исходный retail build.
|
||||
2. Зафиксировать корневой `FileDataID`.
|
||||
3. Выгрузить M2 и определить зависимости.
|
||||
4. Загрузить `.skin`, `.skel`, `.anim` и текстуры.
|
||||
5. Проверить хэши и комплектность.
|
||||
6. Проверить модель в изолированном preview.
|
||||
7. Проверить downport через `wxl-modern-assets`.
|
||||
8. Добавить запись в каталог кастомизации.
|
||||
9. Упаковать только выбранные файлы.
|
||||
10. Не помещать массовый дамп retail-клиента в Git.
|
||||
|
||||
### Автоматические проверки ассета
|
||||
|
||||
- поддерживаемая версия M2/MD21;
|
||||
- наличие всех skin-профилей;
|
||||
- разрешимые FileDataID и пути текстур;
|
||||
- наличие внешнего скелета/анимаций;
|
||||
- attachment points для целевой расы;
|
||||
- лимит костей на draw после разбиения;
|
||||
- валидные материалы и blend modes;
|
||||
- отсутствие коллизий целевых путей;
|
||||
- размер итогового MPQ и память после загрузки;
|
||||
- корректное освобождение ресурсов.
|
||||
|
||||
## 16. Этапы реализации
|
||||
|
||||
### Этап 0. Исследование и фиксация контрактов
|
||||
|
||||
- [ ] Зафиксировать ревизии WarcraftXL и всех модулей.
|
||||
- [ ] Зафиксировать исходный retail build для ассетов.
|
||||
- [ ] Составить карту существующих хуков персонажа и DBC.
|
||||
- [ ] Проверить структуру штатных пакетов создания/выбора персонажа.
|
||||
- [ ] Зафиксировать каталог option/choice ID.
|
||||
- [ ] Утвердить правила доступности и монетизации.
|
||||
- [ ] Выбрать первую расу для вертикального среза: человек.
|
||||
|
||||
**Результат:** техническая спецификация без конфликтующих точек расширения.
|
||||
|
||||
### Этап 1. Интеграция equip-extension
|
||||
|
||||
- [ ] Добавить репозиторий как закреплённый submodule.
|
||||
- [ ] Адаптировать CMake/module layout.
|
||||
- [ ] Собрать Win32 runtime и x64 host.
|
||||
- [ ] Устранить конфликт хуков с текущим WarcraftXL.
|
||||
- [ ] Исправить снятие collection M2.
|
||||
- [ ] Очистить мусорные поля `ItemDisplayInfo.dbc`.
|
||||
- [ ] Добавить smoke-тест Model1/Model2 на каждом поддержанном слоте.
|
||||
- [ ] Добавить диагностическое логирование.
|
||||
|
||||
**Результат:** современная объёмная экипировка работает и корректно снимается.
|
||||
|
||||
### Этап 2. Независимые косметические каналы
|
||||
|
||||
- [ ] Отделить attachment runtime от реальных слотов экипировки.
|
||||
- [ ] Реализовать реестр косметических каналов.
|
||||
- [ ] Реализовать add/replace/remove/clear.
|
||||
- [ ] Реализовать conflict groups и приоритеты.
|
||||
- [ ] Реализовать скрытие под шлемом.
|
||||
- [ ] Добавить API preview.
|
||||
- [ ] Проверить смену карты, формы и пола.
|
||||
|
||||
**Результат:** аксессуар можно показать без создания фиктивного предмета.
|
||||
|
||||
### Этап 3. Asset pipeline
|
||||
|
||||
- [ ] Добавить входной manifest с FileDataID.
|
||||
- [ ] Реализовать пакетную загрузку через CDN.
|
||||
- [ ] Добавить локальный кэш по хэшу.
|
||||
- [ ] Автоматически проверять зависимости.
|
||||
- [ ] Формировать отчёт о пропущенных файлах.
|
||||
- [ ] Интегрировать выбранные ассеты в MPQ build.
|
||||
- [ ] Добавить контроль размера и дубликатов.
|
||||
|
||||
**Результат:** воспроизводимая сборка выбранных retail-ассетов без полного клиента.
|
||||
|
||||
### Этап 4. Серверное хранение и протокол
|
||||
|
||||
- [ ] Добавить миграцию `character_customization`.
|
||||
- [ ] Реализовать серверный каталог допустимых вариантов.
|
||||
- [ ] Реализовать create/update validation.
|
||||
- [ ] Реализовать snapshot и delta.
|
||||
- [ ] Подключить данные к character enum/select flow.
|
||||
- [ ] Рассылать состояние в visibility range.
|
||||
- [ ] Добавить rate limit и журнал ошибок.
|
||||
- [ ] Добавить fallback для клиента без совместимой версии.
|
||||
|
||||
**Результат:** параметры сохраняются и одинаково видны всем клиентам.
|
||||
|
||||
### Этап 5. Текстурные слои и geoset
|
||||
|
||||
- [ ] Расширить/переопределить `CharSections` данными MoonWell.
|
||||
- [ ] Реализовать слои шрамов, татуировок и макияжа.
|
||||
- [ ] Реализовать таблицу geoset базовой модели.
|
||||
- [ ] Синхронизировать цветовые зависимости.
|
||||
- [ ] Обеспечить совместимость с надетой бронёй.
|
||||
- [ ] Добавить полную пересборку как fallback.
|
||||
|
||||
**Результат:** работают не только прикрепляемые M2, но и полноценные параметры тела/лица.
|
||||
|
||||
### Этап 6. Новый UI создания персонажа
|
||||
|
||||
- [ ] Создать адаптивную панель категорий.
|
||||
- [ ] Создать прокручиваемую сетку choices.
|
||||
- [ ] Подключить preview API.
|
||||
- [ ] Добавить race/gender/category camera profiles.
|
||||
- [ ] Добавить randomize и reset.
|
||||
- [ ] Добавить обработку недоступного ассета.
|
||||
- [ ] Добавить серверное подтверждение создания.
|
||||
- [ ] Проверить 4:3, 16:9, 16:10 и UI scale.
|
||||
|
||||
**Результат:** законченный retail-подобный пользовательский сценарий.
|
||||
|
||||
### Этап 7. Вертикальный срез — человек
|
||||
|
||||
- [ ] Мужская и женская модели.
|
||||
- [ ] Стандартные категории.
|
||||
- [ ] Минимум одна новая текстурная категория.
|
||||
- [ ] Минимум два M2-косметических канала.
|
||||
- [ ] Сохранение, выбор персонажа и отображение в мире.
|
||||
- [ ] Видимость другим игрокам.
|
||||
- [ ] Парикмахерская/изменение существующего персонажа.
|
||||
- [ ] Полный regression-тест экипировки.
|
||||
|
||||
**Результат:** одна раса полностью проходит production-сценарий.
|
||||
|
||||
### Этап 8. Масштабирование на все расы WotLK
|
||||
|
||||
- [ ] Дворфы.
|
||||
- [ ] Ночные эльфы.
|
||||
- [ ] Гномы.
|
||||
- [ ] Дренеи.
|
||||
- [ ] Орки.
|
||||
- [ ] Нежить.
|
||||
- [ ] Таурены.
|
||||
- [ ] Тролли.
|
||||
- [ ] Эльфы крови.
|
||||
- [ ] Отдельная настройка камер для каждого пола.
|
||||
- [ ] Матрица конфликтов расовых элементов с экипировкой.
|
||||
|
||||
**Результат:** единый уровень качества для всех доступных рас.
|
||||
|
||||
### Этап 9. Стабилизация и выпуск
|
||||
|
||||
- [ ] Нагрузочный тест большого скопления игроков.
|
||||
- [ ] Проверка утечек модели/текстур при многократной смене вариантов.
|
||||
- [ ] Проверка холодного кэша WarcraftXLHost.
|
||||
- [ ] Проверка отсутствующих/повреждённых ассетов.
|
||||
- [ ] Версионирование manifest, каталога и протокола.
|
||||
- [ ] Миграция существующих персонажей.
|
||||
- [ ] Rollback-пакет.
|
||||
- [ ] Документация для добавления новой категории/расы.
|
||||
- [ ] Обновление deploy-скрипта и launcher manifest.
|
||||
|
||||
**Результат:** воспроизводимый production-релиз.
|
||||
|
||||
## 17. Матрица тестирования
|
||||
|
||||
### Персонажи
|
||||
|
||||
- каждая раса × каждый пол;
|
||||
- стандартный режим × предатель;
|
||||
- новый персонаж × существующий персонаж;
|
||||
- минимальное × максимальное число выбранных параметров;
|
||||
- случайная комбинация × преднамеренно конфликтная комбинация.
|
||||
|
||||
### Состояния клиента
|
||||
|
||||
- создание;
|
||||
- выбор;
|
||||
- загрузочный экран;
|
||||
- вход в мир;
|
||||
- появление другого игрока;
|
||||
- телепорт;
|
||||
- смерть/дух;
|
||||
- транспорт;
|
||||
- shapeshift/превращение;
|
||||
- парикмахерская;
|
||||
- смена экипировки;
|
||||
- скрытие шлема/плаща;
|
||||
- `/reload`;
|
||||
- переподключение;
|
||||
- выход и повторный вход.
|
||||
|
||||
### Графика
|
||||
|
||||
- native renderer и основной D3D9On12 путь;
|
||||
- разные уровни component texture quality;
|
||||
- включённые/выключенные SSAA, SSAO и AA;
|
||||
- дальние LOD;
|
||||
- тени;
|
||||
- прозрачные и emissive материалы;
|
||||
- несколько одинаковых моделей одновременно;
|
||||
- 4:3, 16:9, 16:10 и разные UI scale.
|
||||
|
||||
### Сеть и сервер
|
||||
|
||||
- потеря/повтор snapshot;
|
||||
- snapshot до появления модели и после неё;
|
||||
- неизвестная версия каталога;
|
||||
- неизвестный option/choice;
|
||||
- запрещённый выбор;
|
||||
- слишком большое сообщение;
|
||||
- быстрое повторное изменение;
|
||||
- одновременное изменение и телепорт;
|
||||
- сохранение при logout/crash;
|
||||
- удаление и восстановление персонажа.
|
||||
|
||||
## 18. Производительность
|
||||
|
||||
Целевые ограничения:
|
||||
|
||||
- не выполнять разбор каталога каждый кадр;
|
||||
- не пересобирать всю модель при изменении независимого аксессуара;
|
||||
- кэшировать разрешённые пути и обработанные modern assets;
|
||||
- не создавать дубликаты одной и той же дочерней M2-модели;
|
||||
- освобождать attachment при удалении/скрытии объекта;
|
||||
- ограничить число одновременных косметических M2 на персонажа;
|
||||
- иметь настройку отключения дальних косметических attachment;
|
||||
- измерять время первой загрузки, потребление RAM/VRAM и frame time;
|
||||
- логирование hot path отключено в release-сборке.
|
||||
|
||||
Предварительный бюджет первой версии:
|
||||
|
||||
- до 6 независимых M2-каналов на персонажа;
|
||||
- не более двух моделей на канал;
|
||||
- отсутствие постоянной работы Lua `OnUpdate`, кроме активной UI-анимации;
|
||||
- пересборка полной внешности только при snapshot, смене расы/пола или восстановлении после ошибки.
|
||||
|
||||
## 19. Безопасность и целостность
|
||||
|
||||
- сервер проверяет все ID и комбинации;
|
||||
- клиент не передаёт пути файлов серверу;
|
||||
- протокол содержит только числовые option/choice ID;
|
||||
- сообщения имеют максимальный размер и максимальное число записей;
|
||||
- сервер нормализует дубликаты option ID;
|
||||
- неизвестные значения не записываются в БД;
|
||||
- стоимость изменения рассчитывается сервером;
|
||||
- unlock condition проверяется сервером;
|
||||
- клиентский каталог не считается доказательством доступности;
|
||||
- журналировать несовпадение версии и попытки недопустимых изменений.
|
||||
|
||||
## 20. Сборка, доставка и откат
|
||||
|
||||
### Сборка
|
||||
|
||||
- закреплённые submodule revisions;
|
||||
- asset manifest с SHA-256;
|
||||
- генерация клиентского и серверного каталога из одного источника;
|
||||
- сборка WarcraftXL Win32 и Host x64;
|
||||
- сборка MPQ;
|
||||
- автоматическая проверка оригинального `Wow.exe`;
|
||||
- формирование launcher manifest.
|
||||
|
||||
### Версионирование
|
||||
|
||||
- `customization_protocol_version`;
|
||||
- `customization_catalog_version`;
|
||||
- `customization_asset_version`;
|
||||
- совместимая версия WarcraftXL runtime.
|
||||
|
||||
### Откат
|
||||
|
||||
- сервер умеет не отправлять расширенные сообщения;
|
||||
- клиент при отсутствии данных показывает стандартную внешность;
|
||||
- отключение модуля не блокирует вход персонажа;
|
||||
- миграция БД не удаляет стандартные параметры;
|
||||
- предыдущие MPQ/runtime хранятся как отдельный release artifact;
|
||||
- неизвестные сохранённые choices не удаляются автоматически без миграции.
|
||||
|
||||
## 21. Оценка трудоёмкости
|
||||
|
||||
Для одного разработчика при наличии подготовленных ассетов:
|
||||
|
||||
| Работа | Оценка |
|
||||
|---|---:|
|
||||
| Адаптация и исправление equip-extension | 4–7 дней |
|
||||
| Независимые косметические каналы | 4–7 дней |
|
||||
| Asset pipeline | 3–5 дней |
|
||||
| Серверное хранение и протокол | 4–7 дней |
|
||||
| Текстурные слои и geoset | 5–10 дней |
|
||||
| Новый UI создания/изменения | 5–8 дней |
|
||||
| Полный вертикальный срез одной расы | 5–10 дней |
|
||||
| Каждая следующая раса | 2–5 дней |
|
||||
| Стабилизация и regression | 7–12 дней |
|
||||
|
||||
Ориентиры:
|
||||
|
||||
- технический прототип одной расы: 1–2 недели;
|
||||
- production-вертикаль людей: 3–5 недель;
|
||||
- все десять рас WotLK: 2–3 месяца;
|
||||
- насыщенность категориями, близкая к retail: 3–6 месяцев.
|
||||
|
||||
Главный переменный фактор — подготовка и проверка ассетов, а не серверная схема.
|
||||
|
||||
## 22. Рекомендуемый первый milestone
|
||||
|
||||
Вертикальный срез для людей обоих полов:
|
||||
|
||||
- штатные пять категорий;
|
||||
- одна текстурная категория: шрамы;
|
||||
- два независимых M2-канала: серьги и аксессуар для волос;
|
||||
- серверное сохранение;
|
||||
- snapshot на экране выбора;
|
||||
- видимость другим игрокам;
|
||||
- изменение через парикмахерскую;
|
||||
- камера по категориям;
|
||||
- корректная работа со шлемом;
|
||||
- один полный набор automated/manual acceptance checks.
|
||||
|
||||
Этот milestone должен подтвердить всю архитектуру до масштабирования на остальные расы.
|
||||
|
||||
## 23. Критерии готовности production-версии
|
||||
|
||||
- [ ] `Wow.exe` идентичен оригиналу по SHA-256.
|
||||
- [ ] Все расширения поставляются через WarcraftXL и MPQ.
|
||||
- [ ] Клиент и сервер используют одинаковую версию каталога.
|
||||
- [ ] Внешность сохраняется после выхода и перезапуска клиента.
|
||||
- [ ] Экран создания, экран выбора и модель в мире совпадают.
|
||||
- [ ] Другие игроки видят подтверждённую сервером внешность.
|
||||
- [ ] Недоступные варианты невозможно применить изменённым клиентом.
|
||||
- [ ] Смена/снятие варианта не оставляет collection M2.
|
||||
- [ ] Нет placeholder-моделей из мусорных строк `ItemDisplayInfo.dbc`.
|
||||
- [ ] Нет заметных утечек памяти при многократном preview.
|
||||
- [ ] Отсутствующий ассет не приводит к падению клиента.
|
||||
- [ ] Все расы и оба пола проходят тестовую матрицу.
|
||||
- [ ] Камера корректно кадрирует низких, средних и высоких персонажей.
|
||||
- [ ] UI работает на 4:3, 16:9, 16:10 и разных UI scale.
|
||||
- [ ] Реализован и проверен rollback.
|
||||
- [ ] Описан процесс добавления новой категории, choice и ассета.
|
||||
|
||||
## 24. Открытые решения перед началом реализации
|
||||
|
||||
- точная ревизия `wxl-equip-extension` для закрепления;
|
||||
- список категорий первого релиза;
|
||||
- исходный retail build для моделей;
|
||||
- набор моделей для вертикального среза людей;
|
||||
- формат единого каталога: JSON, CSV или генератор из SQL/YAML;
|
||||
- транспорт custom packets между WarcraftXL и AzerothCore;
|
||||
- способ передачи snapshots на экране выбора;
|
||||
- политика скрытия аксессуаров под шлемом;
|
||||
- правила доступности и разблокировки вариантов;
|
||||
- использование парикмахерской или отдельного UI изменения внешности;
|
||||
- лимит косметических M2 на одного персонажа;
|
||||
- политика хранения выбранных Blizzard-ассетов и доступа к ним;
|
||||
- минимальная поддерживаемая версия launcher/runtime.
|
||||
@@ -0,0 +1,83 @@
|
||||
# Автоматический импорт mount и pet
|
||||
|
||||
Список современных моделей находится в `custom-creatures.json`. В нём есть две независимые
|
||||
секции:
|
||||
|
||||
```json
|
||||
{
|
||||
"mounts": [
|
||||
"assets/creatures/my_mount"
|
||||
],
|
||||
"pets": [
|
||||
"assets/creatures/my_pet"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Папка должна быть результатом **Raw M2 export** из wow.export и содержать:
|
||||
|
||||
- один основной `.m2`;
|
||||
- `*.manifest.json`;
|
||||
- все упомянутые в manifest текстуры, skin, skeleton, bone и animation-файлы.
|
||||
|
||||
Разрешены абсолютные пути, например `C:/Users/sindo/wow.export/creature/catslime`, но для
|
||||
воспроизводимой сборки папку лучше скопировать внутрь репозитория и указать относительный путь.
|
||||
|
||||
## Что делает сборщик
|
||||
|
||||
При обычном запуске `tool.exe`, `run.ps1` или `deploy.ps1` сборщик:
|
||||
|
||||
1. читает manifest каждой модели и размещает retail M2 со всеми зависимостями в loose-каталоге
|
||||
`Data/Patch-WXL.MPQ`, создавая рядом `.mdx`-алиас для загрузчика WoW 3.3.5, чтобы WarcraftXL всегда
|
||||
преобразовывал MD21 до загрузки клиентом;
|
||||
2. генерирует `WXLFileData.csv` для WarcraftXL;
|
||||
3. дописывает модели в `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc`;
|
||||
4. сохраняет стабильные ID в `custom-creatures.lock.json`;
|
||||
5. для `pets` создаёт companion summon spell и строку skill line `778`;
|
||||
6. кладёт серверные DBC, SQL и таблицу ID в `build/server-dbc`.
|
||||
|
||||
Полный `Spell.dbc` в патч не копируется. WarcraftXL читает компактный
|
||||
`WXLSpellOverrides.tsv` и при открытии DBC добавляет новые spell-записи к штатному файлу клиента.
|
||||
|
||||
Перестановка моделей в списке не меняет уже выданные ID. Lock-файл необходимо хранить в Git.
|
||||
|
||||
## Дополнительные параметры
|
||||
|
||||
Если значений по умолчанию недостаточно, вместо строки используется объект:
|
||||
|
||||
```json
|
||||
{
|
||||
"folder": "assets/creatures/my_mount",
|
||||
"key": "my_mount",
|
||||
"name": "My Mount",
|
||||
"name_ru": "Мой транспорт",
|
||||
"archive": "Creature/MyMount",
|
||||
"model_file": "my_mount.m2",
|
||||
"template_display_id": 2404,
|
||||
"display_scale": 1.0,
|
||||
"model_scale": 1.0,
|
||||
"collision_width": 0.6111,
|
||||
"collision_height": 2.031,
|
||||
"mount_height": 1.8657,
|
||||
"flags": 0
|
||||
}
|
||||
```
|
||||
|
||||
Для `mounts` по умолчанию клонируется верховая лошадь с display ID `2404`, для `pets` —
|
||||
компаньон-кот с display ID `5448`. Шаблон задаёт физические размеры, звук и прочие поля DBC;
|
||||
параметры объекта позволяют их переопределить.
|
||||
|
||||
## Установка на сервер
|
||||
|
||||
Для каждого элемента `pets` сборщик автоматически выдаёт `creatureEntry`, `summonSpellID` и
|
||||
`skillLineAbilityID`. Значения находятся в `build/server-dbc/custom-creatures.csv`.
|
||||
|
||||
1. Скопировать `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc` из `build/server-dbc` в
|
||||
серверный каталог `data/dbc`.
|
||||
2. Применить `build/server-dbc/custom-creatures.sql` к `acore_world`.
|
||||
3. Перезапустить worldserver: DBC и таблицы `spell_dbc` загружаются только при старте.
|
||||
4. Для проверки изучить выданный spell командой `.learn <summonSpellID>`.
|
||||
|
||||
SQL создаёт `creature_template`, `creature_model_info`, `creature_template_model`, companion spell и строку skill line.
|
||||
Генерация полноценного mount spell для секции `mounts` остаётся отдельным этапом: mount требует
|
||||
другой spell-шаблон с аурами скорости и `SPELL_AURA_MOUNTED`.
|
||||
Binary file not shown.
Binary file not shown.
+111
-8
@@ -8,6 +8,73 @@ param(
|
||||
[switch]$NativeRenderer
|
||||
)
|
||||
|
||||
function Enable-LargeAddressAware {
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Path
|
||||
)
|
||||
|
||||
$bytes = [System.IO.File]::ReadAllBytes($Path)
|
||||
|
||||
if ($bytes.Length -lt 64) {
|
||||
throw "File is too small to be a valid PE executable: $Path"
|
||||
}
|
||||
|
||||
# DOS header: MZ
|
||||
if ($bytes[0] -ne 0x4D -or $bytes[1] -ne 0x5A) {
|
||||
throw "DOS MZ signature was not found: $Path"
|
||||
}
|
||||
|
||||
# IMAGE_DOS_HEADER.e_lfanew
|
||||
$peOffset = [BitConverter]::ToInt32($bytes, 0x3C)
|
||||
|
||||
if ($peOffset -lt 0 -or ($peOffset + 24) -gt $bytes.Length) {
|
||||
throw "Invalid PE header offset in: $Path"
|
||||
}
|
||||
|
||||
# PE\0\0 signature
|
||||
$peSignature = [BitConverter]::ToUInt32($bytes, $peOffset)
|
||||
|
||||
if ($peSignature -ne 0x00004550) {
|
||||
throw "PE signature was not found: $Path"
|
||||
}
|
||||
|
||||
# IMAGE_FILE_HEADER starts after the 4-byte PE signature.
|
||||
# Characteristics is at offset 18 inside IMAGE_FILE_HEADER.
|
||||
$characteristicsOffset = $peOffset + 4 + 18
|
||||
$characteristics = [BitConverter]::ToUInt16(
|
||||
$bytes,
|
||||
$characteristicsOffset
|
||||
)
|
||||
|
||||
$newCharacteristics = [uint16](
|
||||
$characteristics -bor 0x0020
|
||||
)
|
||||
|
||||
if ($newCharacteristics -ne $characteristics) {
|
||||
$encoded = [BitConverter]::GetBytes($newCharacteristics)
|
||||
|
||||
$bytes[$characteristicsOffset] = $encoded[0]
|
||||
$bytes[$characteristicsOffset + 1] = $encoded[1]
|
||||
|
||||
[System.IO.File]::WriteAllBytes($Path, $bytes)
|
||||
|
||||
Write-Host (
|
||||
"LAA enabled for {0}: 0x{1:X4} -> 0x{2:X4}" -f
|
||||
$Path,
|
||||
$characteristics,
|
||||
$newCharacteristics
|
||||
) -ForegroundColor Green
|
||||
}
|
||||
else {
|
||||
Write-Host (
|
||||
"LAA is already enabled for {0}: 0x{1:X4}" -f
|
||||
$Path,
|
||||
$characteristics
|
||||
) -ForegroundColor DarkGreen
|
||||
}
|
||||
}
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$repoRoot = $PSScriptRoot
|
||||
$cmake = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe'
|
||||
@@ -55,19 +122,55 @@ $selectedProxy = if ($NativeRenderer) { $nativeProxy } else { $modernProxy }
|
||||
$adtResources = Join-Path $repoRoot 'vendor\modules\wxl-modern-adt\_resources'
|
||||
|
||||
function Install-WarcraftXLArtifacts {
|
||||
param([Parameter(Mandatory)][string]$Destination)
|
||||
param(
|
||||
[Parameter(Mandatory)]
|
||||
[string]$Destination
|
||||
)
|
||||
|
||||
$utils = Join-Path $Destination 'Utils'
|
||||
$loosePatch = Join-Path $Destination 'Data\Patch-WXL.MPQ'
|
||||
New-Item -ItemType Directory -Path $Destination, $utils, $loosePatch -Force | Out-Null
|
||||
|
||||
Copy-Item -LiteralPath $stockExe -Destination (Join-Path $Destination 'Wow.exe') -Force
|
||||
Copy-Item -LiteralPath $warcraftXL -Destination (Join-Path $Destination 'WarcraftXL.dll') -Force
|
||||
Copy-Item -LiteralPath $selectedProxy -Destination (Join-Path $Destination 'd3d9.dll') -Force
|
||||
Copy-Item -LiteralPath $nativeProxy -Destination (Join-Path $utils 'd3d9-native.dll') -Force
|
||||
Copy-Item -LiteralPath $hostExe -Destination (Join-Path $utils 'WarcraftXLHost.exe') -Force
|
||||
New-Item `
|
||||
-ItemType Directory `
|
||||
-Path $Destination, $utils, $loosePatch `
|
||||
-Force |
|
||||
Out-Null
|
||||
|
||||
$destinationExe = Join-Path $Destination 'Wow.exe'
|
||||
|
||||
Copy-Item `
|
||||
-LiteralPath $stockExe `
|
||||
-Destination $destinationExe `
|
||||
-Force
|
||||
|
||||
Enable-LargeAddressAware -Path $destinationExe
|
||||
|
||||
Copy-Item `
|
||||
-LiteralPath $warcraftXL `
|
||||
-Destination (Join-Path $Destination 'WarcraftXL.dll') `
|
||||
-Force
|
||||
|
||||
Copy-Item `
|
||||
-LiteralPath $selectedProxy `
|
||||
-Destination (Join-Path $Destination 'd3d9.dll') `
|
||||
-Force
|
||||
|
||||
Copy-Item `
|
||||
-LiteralPath $nativeProxy `
|
||||
-Destination (Join-Path $utils 'd3d9-native.dll') `
|
||||
-Force
|
||||
|
||||
Copy-Item `
|
||||
-LiteralPath $hostExe `
|
||||
-Destination (Join-Path $utils 'WarcraftXLHost.exe') `
|
||||
-Force
|
||||
|
||||
Get-ChildItem -LiteralPath $adtResources | ForEach-Object {
|
||||
Copy-Item -LiteralPath $_.FullName -Destination $loosePatch -Recurse -Force
|
||||
Copy-Item `
|
||||
-LiteralPath $_.FullName `
|
||||
-Destination $loosePatch `
|
||||
-Recurse `
|
||||
-Force
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ IGNORED_TOP_LEVEL_DIRS = {
|
||||
IGNORED_DIRS_ANYWHERE = {
|
||||
".git",
|
||||
".moonwell_launcher",
|
||||
".vscode"
|
||||
}
|
||||
IGNORED_TOP_LEVEL_FILES = {
|
||||
"Wow.moonwell-patched.backup.exe",
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"base_dbc_dir": "assets/dbc/3.3.5a",
|
||||
"model_data_id_start": 50000,
|
||||
"display_info_id_start": 50000,
|
||||
"creature_entry_id_start": 5000000,
|
||||
"summon_spell_id_start": 110000,
|
||||
"skill_line_ability_id_start": 30000,
|
||||
"defaults": {
|
||||
"mount_template_display_id": 2404,
|
||||
"pet_template_display_id": 5448
|
||||
},
|
||||
"mounts": [],
|
||||
"pets": [
|
||||
{
|
||||
"folder": "src/Data/patch-Z/Creature/Catslime",
|
||||
"name": "Cat Slime",
|
||||
"name_ru": "Котослизень",
|
||||
"display_scale": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"models": {
|
||||
"catslime": {
|
||||
"model_data_id": 50000,
|
||||
"display_info_id": 50000,
|
||||
"creature_entry": 5000000,
|
||||
"summon_spell_id": 110000,
|
||||
"skill_line_ability_id": 30000
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -76,11 +76,12 @@ New-Item -ItemType Directory -Path $PackagePath -Force | Out-Null
|
||||
if (-not $SkipDataBuild) {
|
||||
Write-Host '[2/5] Building MPQ patches...'
|
||||
$tool = Join-Path $repoRoot 'tool\target\release\tool.exe'
|
||||
if (-not (Test-Path -LiteralPath $tool -PathType Leaf)) {
|
||||
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
|
||||
if (-not $cargo) { throw 'Cargo was not found and the MPQ builder is not compiled.' }
|
||||
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
|
||||
if ($cargo) {
|
||||
& $cargo.Source build --release --manifest-path (Join-Path $repoRoot 'tool\Cargo.toml')
|
||||
if ($LASTEXITCODE -ne 0) { throw 'MPQ builder compilation failed.' }
|
||||
} elseif (-not (Test-Path -LiteralPath $tool -PathType Leaf)) {
|
||||
throw 'Cargo was not found and the MPQ builder is not compiled.'
|
||||
}
|
||||
|
||||
& $tool (Join-Path $repoRoot 'src') $PackagePath
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Appends compact MoonWell spell overrides to the stock 3.3.5 DBCs at open time.
|
||||
|
||||
#include "Host.hpp"
|
||||
#include "core/Logger.hpp"
|
||||
#include "mpq/MpqStore.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
namespace moonwell::host
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr std::string_view kOverridesPath = "WXLSpellOverrides.tsv";
|
||||
constexpr std::string_view kSpellPath = "DBFilesClient\\Spell.dbc";
|
||||
constexpr std::string_view kSkillPath = "DBFilesClient\\SkillLineAbility.dbc";
|
||||
constexpr uint32_t kSpellFields = 234;
|
||||
constexpr uint32_t kSpellRecordSize = kSpellFields * 4;
|
||||
constexpr uint32_t kSkillFields = 14;
|
||||
constexpr uint32_t kSkillRecordSize = kSkillFields * 4;
|
||||
|
||||
struct Override
|
||||
{
|
||||
uint32_t spellId = 0;
|
||||
uint32_t templateSpellId = 0;
|
||||
uint32_t creatureEntry = 0;
|
||||
uint32_t skillLineAbilityId = 0;
|
||||
std::string name;
|
||||
};
|
||||
|
||||
std::once_flag g_loadOnce;
|
||||
bool g_ready = false;
|
||||
std::vector<uint8_t> g_spellDbc;
|
||||
std::vector<uint8_t> g_skillDbc;
|
||||
|
||||
uint32_t ReadU32(const uint8_t* data)
|
||||
{
|
||||
uint32_t value = 0;
|
||||
std::memcpy(&value, data, sizeof(value));
|
||||
return value;
|
||||
}
|
||||
|
||||
void WriteU32(uint8_t* data, uint32_t value)
|
||||
{
|
||||
std::memcpy(data, &value, sizeof(value));
|
||||
}
|
||||
|
||||
std::string_view Trim(std::string_view value)
|
||||
{
|
||||
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front())))
|
||||
value.remove_prefix(1);
|
||||
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.back())))
|
||||
value.remove_suffix(1);
|
||||
return value;
|
||||
}
|
||||
|
||||
bool ParseU32(std::string_view text, uint32_t& value)
|
||||
{
|
||||
text = Trim(text);
|
||||
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size();
|
||||
}
|
||||
|
||||
bool SamePath(std::string_view left, std::string_view right)
|
||||
{
|
||||
if (left.size() != right.size()) return false;
|
||||
for (size_t i = 0; i < left.size(); ++i)
|
||||
{
|
||||
const unsigned char a = static_cast<unsigned char>(left[i] == '/' ? '\\' : left[i]);
|
||||
const unsigned char b = static_cast<unsigned char>(right[i] == '/' ? '\\' : right[i]);
|
||||
if (std::tolower(a) != std::tolower(b)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParseOverrides(const std::vector<uint8_t>& bytes, std::vector<Override>& out)
|
||||
{
|
||||
const std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
|
||||
size_t lineStart = 0;
|
||||
while (lineStart < text.size())
|
||||
{
|
||||
size_t lineEnd = text.find('\n', lineStart);
|
||||
if (lineEnd == std::string_view::npos) lineEnd = text.size();
|
||||
std::string_view line = Trim(text.substr(lineStart, lineEnd - lineStart));
|
||||
lineStart = lineEnd + 1;
|
||||
if (line.empty() || line.front() == '#') continue;
|
||||
|
||||
std::string_view fields[5];
|
||||
size_t fieldStart = 0;
|
||||
bool valid = true;
|
||||
for (size_t i = 0; i < 4; ++i)
|
||||
{
|
||||
const size_t tab = line.find('\t', fieldStart);
|
||||
if (tab == std::string_view::npos) { valid = false; break; }
|
||||
fields[i] = line.substr(fieldStart, tab - fieldStart);
|
||||
fieldStart = tab + 1;
|
||||
}
|
||||
if (!valid) return false;
|
||||
fields[4] = Trim(line.substr(fieldStart));
|
||||
|
||||
Override item;
|
||||
if (!ParseU32(fields[0], item.spellId) ||
|
||||
!ParseU32(fields[1], item.templateSpellId) ||
|
||||
!ParseU32(fields[2], item.creatureEntry) ||
|
||||
!ParseU32(fields[3], item.skillLineAbilityId) || fields[4].empty())
|
||||
return false;
|
||||
item.name.assign(fields[4]);
|
||||
out.push_back(std::move(item));
|
||||
}
|
||||
return !out.empty();
|
||||
}
|
||||
|
||||
bool ValidateWdbc(const std::vector<uint8_t>& bytes, uint32_t fields, uint32_t recordSize,
|
||||
uint32_t& count, uint32_t& stringSize, size_t& recordsEnd)
|
||||
{
|
||||
if (bytes.size() < 20 || std::memcmp(bytes.data(), "WDBC", 4) != 0) return false;
|
||||
count = ReadU32(bytes.data() + 4);
|
||||
if (ReadU32(bytes.data() + 8) != fields || ReadU32(bytes.data() + 12) != recordSize)
|
||||
return false;
|
||||
stringSize = ReadU32(bytes.data() + 16);
|
||||
recordsEnd = 20ull + static_cast<size_t>(count) * recordSize;
|
||||
return recordsEnd <= bytes.size() && recordsEnd + stringSize == bytes.size();
|
||||
}
|
||||
|
||||
const uint8_t* FindSpell(const std::vector<uint8_t>& bytes, uint32_t count, uint32_t id)
|
||||
{
|
||||
for (uint32_t i = 0; i < count; ++i)
|
||||
{
|
||||
const uint8_t* row = bytes.data() + 20 + static_cast<size_t>(i) * kSpellRecordSize;
|
||||
if (ReadU32(row) == id) return row;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const uint8_t* FindSkillRow(const std::vector<uint8_t>& bytes, uint32_t count, uint32_t spellId)
|
||||
{
|
||||
for (uint32_t i = 0; i < count; ++i)
|
||||
{
|
||||
const uint8_t* row = bytes.data() + 20 + static_cast<size_t>(i) * kSkillRecordSize;
|
||||
if (ReadU32(row + 4) == 778 && ReadU32(row + 8) == spellId) return row;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool BuildSpellDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
|
||||
std::vector<uint8_t>& out)
|
||||
{
|
||||
uint32_t count = 0, stringSize = 0;
|
||||
size_t recordsEnd = 0;
|
||||
if (!ValidateWdbc(base, kSpellFields, kSpellRecordSize, count, stringSize, recordsEnd))
|
||||
return false;
|
||||
|
||||
std::vector<uint8_t> records;
|
||||
records.reserve(overrides.size() * kSpellRecordSize);
|
||||
std::vector<uint8_t> strings;
|
||||
for (const Override& item : overrides)
|
||||
{
|
||||
const uint8_t* source = FindSpell(base, count, item.templateSpellId);
|
||||
if (!source) return false;
|
||||
const size_t recordStart = records.size();
|
||||
records.insert(records.end(), source, source + kSpellRecordSize);
|
||||
uint8_t* row = records.data() + recordStart;
|
||||
WriteU32(row, item.spellId);
|
||||
WriteU32(row + 110 * 4, item.creatureEntry);
|
||||
|
||||
const uint32_t nameOffset = stringSize + static_cast<uint32_t>(strings.size());
|
||||
strings.insert(strings.end(), item.name.begin(), item.name.end());
|
||||
strings.push_back(0);
|
||||
const std::string description = "Призывает или отпускает спутника: " + item.name + ".";
|
||||
const uint32_t descriptionOffset = stringSize + static_cast<uint32_t>(strings.size());
|
||||
strings.insert(strings.end(), description.begin(), description.end());
|
||||
strings.push_back(0);
|
||||
for (uint32_t field = 136; field <= 151; ++field) WriteU32(row + field * 4, nameOffset);
|
||||
for (uint32_t field = 170; field <= 185; ++field) WriteU32(row + field * 4, descriptionOffset);
|
||||
for (uint32_t field = 187; field <= 202; ++field) WriteU32(row + field * 4, 0);
|
||||
}
|
||||
|
||||
out.reserve(base.size() + records.size() + strings.size());
|
||||
out.insert(out.end(), base.begin(), base.begin() + 20);
|
||||
WriteU32(out.data() + 4, count + static_cast<uint32_t>(overrides.size()));
|
||||
WriteU32(out.data() + 16, stringSize + static_cast<uint32_t>(strings.size()));
|
||||
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
|
||||
out.insert(out.end(), records.begin(), records.end());
|
||||
out.insert(out.end(), base.begin() + recordsEnd, base.end());
|
||||
out.insert(out.end(), strings.begin(), strings.end());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BuildSkillDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
|
||||
std::vector<uint8_t>& out)
|
||||
{
|
||||
uint32_t count = 0, stringSize = 0;
|
||||
size_t recordsEnd = 0;
|
||||
if (!ValidateWdbc(base, kSkillFields, kSkillRecordSize, count, stringSize, recordsEnd))
|
||||
return false;
|
||||
|
||||
std::vector<uint8_t> records;
|
||||
records.reserve(overrides.size() * kSkillRecordSize);
|
||||
for (const Override& item : overrides)
|
||||
{
|
||||
const uint8_t* source = FindSkillRow(base, count, item.templateSpellId);
|
||||
if (!source) return false;
|
||||
const size_t recordStart = records.size();
|
||||
records.insert(records.end(), source, source + kSkillRecordSize);
|
||||
uint8_t* row = records.data() + recordStart;
|
||||
WriteU32(row, item.skillLineAbilityId);
|
||||
WriteU32(row + 8, item.spellId);
|
||||
}
|
||||
|
||||
out.reserve(base.size() + records.size());
|
||||
out.insert(out.end(), base.begin(), base.begin() + 20);
|
||||
WriteU32(out.data() + 4, count + static_cast<uint32_t>(overrides.size()));
|
||||
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
|
||||
out.insert(out.end(), records.begin(), records.end());
|
||||
out.insert(out.end(), base.begin() + recordsEnd, base.end());
|
||||
return true;
|
||||
}
|
||||
|
||||
void Load()
|
||||
{
|
||||
const std::string root = wxl::host::ClientRoot();
|
||||
wxl::host::mpq::MpqStore store;
|
||||
std::vector<uint8_t> overrideBytes, spellBase, skillBase;
|
||||
if (root.empty() || !store.Mount(root) || !store.ReadAll(kOverridesPath, overrideBytes))
|
||||
return;
|
||||
|
||||
std::vector<Override> overrides;
|
||||
if (!ParseOverrides(overrideBytes, overrides) ||
|
||||
!store.ReadAll(kSpellPath, spellBase) || !store.ReadAll(kSkillPath, skillBase) ||
|
||||
!BuildSpellDbc(spellBase, overrides, g_spellDbc) ||
|
||||
!BuildSkillDbc(skillBase, overrides, g_skillDbc))
|
||||
{
|
||||
WLOG_ERROR("moonwell-spells: failed to build custom companion DBCs");
|
||||
return;
|
||||
}
|
||||
g_ready = true;
|
||||
WLOG_INFO("moonwell-spells: appended %zu companion spell(s)", overrides.size());
|
||||
}
|
||||
|
||||
bool Provide(std::string_view name, std::vector<uint8_t>& out)
|
||||
{
|
||||
if (!SamePath(name, kSpellPath) && !SamePath(name, kSkillPath)) return false;
|
||||
std::call_once(g_loadOnce, &Load);
|
||||
if (!g_ready) return false;
|
||||
out = SamePath(name, kSpellPath) ? g_spellDbc : g_skillDbc;
|
||||
return true;
|
||||
}
|
||||
|
||||
struct Registrar
|
||||
{
|
||||
Registrar() { wxl::host::RegisterProvider("moonwell-companion-spells", &Provide); }
|
||||
};
|
||||
|
||||
Registrar g_registrar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// FileDataID resolver for selected retail assets shipped in MoonWell patches.
|
||||
|
||||
#include "Host.hpp"
|
||||
#include "core/Logger.hpp"
|
||||
#include "mpq/MpqStore.hpp"
|
||||
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace moonwell::host
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr std::string_view kFileDataMap = "WXLFileData.csv";
|
||||
|
||||
std::once_flag g_loadOnce;
|
||||
std::unordered_map<uint32_t, std::string> g_paths;
|
||||
|
||||
std::string_view Trim(std::string_view value)
|
||||
{
|
||||
while (!value.empty() && (value.front() == ' ' || value.front() == '\t' ||
|
||||
value.front() == '\r' || value.front() == '\n'))
|
||||
value.remove_prefix(1);
|
||||
while (!value.empty() && (value.back() == ' ' || value.back() == '\t' ||
|
||||
value.back() == '\r' || value.back() == '\n'))
|
||||
value.remove_suffix(1);
|
||||
return value;
|
||||
}
|
||||
|
||||
void Load()
|
||||
{
|
||||
const std::string root = wxl::host::ClientRoot();
|
||||
wxl::host::mpq::MpqStore store;
|
||||
std::vector<uint8_t> bytes;
|
||||
if (root.empty() || !store.Mount(root) || !store.ReadAll(kFileDataMap, bytes))
|
||||
{
|
||||
WLOG_WARN("moonwell: FileDataID map '%.*s' was not found",
|
||||
int(kFileDataMap.size()), kFileDataMap.data());
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
|
||||
size_t lineStart = 0;
|
||||
while (lineStart < text.size())
|
||||
{
|
||||
size_t lineEnd = text.find('\n', lineStart);
|
||||
if (lineEnd == std::string_view::npos) lineEnd = text.size();
|
||||
std::string_view line = Trim(text.substr(lineStart, lineEnd - lineStart));
|
||||
lineStart = lineEnd + 1;
|
||||
|
||||
if (line.empty() || line.front() == '#') continue;
|
||||
const size_t comma = line.find(',');
|
||||
if (comma == std::string_view::npos) continue;
|
||||
|
||||
const std::string_view idText = Trim(line.substr(0, comma));
|
||||
std::string_view pathText = Trim(line.substr(comma + 1));
|
||||
uint32_t fileDataId = 0;
|
||||
const auto parsed = std::from_chars(idText.data(), idText.data() + idText.size(), fileDataId);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != idText.data() + idText.size() ||
|
||||
fileDataId == 0 || pathText.empty())
|
||||
continue;
|
||||
|
||||
std::string path(pathText);
|
||||
for (char& c : path) if (c == '/') c = '\\';
|
||||
g_paths[fileDataId] = std::move(path);
|
||||
}
|
||||
|
||||
WLOG_INFO("moonwell: loaded %zu FileDataID path(s) from %.*s",
|
||||
g_paths.size(), int(kFileDataMap.size()), kFileDataMap.data());
|
||||
}
|
||||
|
||||
bool Resolve(uint32_t fileDataId, std::string& outPath)
|
||||
{
|
||||
std::call_once(g_loadOnce, &Load);
|
||||
const auto found = g_paths.find(fileDataId);
|
||||
if (found == g_paths.end()) return false;
|
||||
outPath = found->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
struct Registrar
|
||||
{
|
||||
Registrar() { wxl::host::RegisterResolver("moonwell-filedata", &Resolve); }
|
||||
};
|
||||
|
||||
Registrar g_registrar;
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,9 @@ namespace moonwell
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr uint32_t kTraitorFlag = 0x40000000u;
|
||||
bool g_loginCharacterIsTraitor = false;
|
||||
|
||||
using GxSetProjectionFn = wxl::offsets::engine::gx::GxSetProjectionFn;
|
||||
GxSetProjectionFn g_nextSetProjection = nullptr;
|
||||
|
||||
@@ -216,6 +219,23 @@ namespace moonwell
|
||||
WLOG_INFO("moonwell: smooth character-create camera installed");
|
||||
}
|
||||
|
||||
int __cdecl SetLoginCharacterFlags(void* state)
|
||||
{
|
||||
uint32_t flags = 0;
|
||||
if (state && wxl::runtime::lua::IsNumber(state, 1))
|
||||
flags = static_cast<uint32_t>(wxl::runtime::lua::ToNumber(state, 1));
|
||||
|
||||
g_loginCharacterIsTraitor = (flags & kTraitorFlag) != 0;
|
||||
wxl::runtime::lua::PushBoolean(state, g_loginCharacterIsTraitor ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
int __cdecl IsTraitor(void* state)
|
||||
{
|
||||
wxl::runtime::lua::PushBoolean(state, g_loginCharacterIsTraitor ? 1 : 0);
|
||||
return 1;
|
||||
}
|
||||
|
||||
struct Patch
|
||||
{
|
||||
const char* name;
|
||||
@@ -392,6 +412,9 @@ namespace moonwell
|
||||
{
|
||||
Registration()
|
||||
{
|
||||
wxl::runtime::lua::RegisterFunction(
|
||||
"MoonWellSetLoginCharacterFlags", &SetLoginCharacterFlags);
|
||||
wxl::runtime::lua::RegisterFunction("MoonWellIsTraitor", &IsTraitor);
|
||||
wxl::runtime::lua::RegisterFunction(
|
||||
"MoonWellSetCharacterCreateCamera", &SetCharacterCreateCamera);
|
||||
wxl::runtime::modules::RegisterBoot("moonwell", &InstallBoot);
|
||||
|
||||
@@ -37,6 +37,42 @@ if (-not $env:WOW_HOME) {
|
||||
$WOW_HOME = $env:WOW_HOME
|
||||
Write-Host "WOW_HOME = $WOW_HOME"
|
||||
|
||||
function Test-ExistingFileWriteAccess {
|
||||
param([Parameter(Mandatory=$true)][string]$Path)
|
||||
|
||||
try {
|
||||
$stream = [System.IO.File]::Open(
|
||||
$Path,
|
||||
[System.IO.FileMode]::Open,
|
||||
[System.IO.FileAccess]::Write,
|
||||
[System.IO.FileShare]::ReadWrite
|
||||
)
|
||||
$stream.Close()
|
||||
return $true
|
||||
} catch [System.UnauthorizedAccessException] {
|
||||
return $false
|
||||
} catch {
|
||||
# Sharing violations are handled later by Stop-WowRuntime. They do not
|
||||
# indicate that the whole script needs an elevated token.
|
||||
return $true
|
||||
}
|
||||
}
|
||||
|
||||
$installedWarcraftXL = Join-Path $WOW_HOME "WarcraftXL.dll"
|
||||
$isAdministrator = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
|
||||
[Security.Principal.WindowsBuiltInRole]::Administrator
|
||||
)
|
||||
|
||||
if ((Test-Path -LiteralPath $installedWarcraftXL -PathType Leaf) -and
|
||||
-not (Test-ExistingFileWriteAccess -Path $installedWarcraftXL) -and
|
||||
-not $isAdministrator) {
|
||||
Write-Host "WarcraftXL.dll requires administrator access. Requesting elevation..."
|
||||
$elevatedProcess = Start-Process -FilePath "powershell.exe" -Verb RunAs -PassThru -Wait `
|
||||
-WorkingDirectory $ROOT `
|
||||
-ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$($MyInvocation.MyCommand.Path)`"", "-Env", $Env
|
||||
exit $elevatedProcess.ExitCode
|
||||
}
|
||||
|
||||
# --- Paths
|
||||
$SRC_DIR = Join-Path $ROOT "src"
|
||||
$DIST_DIR = Join-Path $ROOT "dist"
|
||||
@@ -44,6 +80,36 @@ $TOOL = Join-Path $ROOT "tool\target\release\tool.exe"
|
||||
$RELOAD_SCRIPT = Join-Path $ROOT "reload_wow.bat"
|
||||
$WXL_BUILD_SCRIPT = Join-Path $ROOT "build-warcraftxl.ps1"
|
||||
|
||||
function Stop-WowRuntime {
|
||||
param([Parameter(Mandatory=$true)][string]$ClientPath)
|
||||
|
||||
$clientRoot = [System.IO.Path]::GetFullPath($ClientPath).TrimEnd('\') + '\'
|
||||
$runtimeProcesses = @(Get-Process -Name "Wow", "WarcraftXLHost" -ErrorAction SilentlyContinue | Where-Object {
|
||||
try {
|
||||
$_.Path -and $_.Path.StartsWith($clientRoot, [System.StringComparison]::OrdinalIgnoreCase)
|
||||
} catch {
|
||||
$false
|
||||
}
|
||||
})
|
||||
|
||||
if (-not $runtimeProcesses.Count) {
|
||||
return
|
||||
}
|
||||
|
||||
Write-Host "Stopping the running WoW client and WarcraftXL host..."
|
||||
$runtimeProcesses | Stop-Process -Force
|
||||
|
||||
try {
|
||||
$runtimeProcesses | Wait-Process -Timeout 10 -ErrorAction Stop
|
||||
} catch {
|
||||
$stillRunning = @($runtimeProcesses | Where-Object { -not $_.HasExited })
|
||||
if ($stillRunning.Count) {
|
||||
$processNames = ($stillRunning | ForEach-Object { "$($_.ProcessName) (PID $($_.Id))" }) -join ", "
|
||||
throw "Could not stop processes that lock WarcraftXL files: $processNames"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# --- Ensure tool exists
|
||||
if (!(Test-Path $TOOL)) {
|
||||
Write-Host "Building Rust tool..."
|
||||
@@ -68,6 +134,7 @@ if (!(Test-Path (Join-Path $DIST_DIR "Data"))) {
|
||||
|
||||
# --- Build/package/deploy WarcraftXL before the dist sync. The packaged files
|
||||
# are uploaded by upload_to_s3.py and consumed by the launcher manifest.
|
||||
Stop-WowRuntime -ClientPath $WOW_HOME
|
||||
Write-Host "Building and packaging WarcraftXL..."
|
||||
& $WXL_BUILD_SCRIPT -Configuration Release -ClientPath $WOW_HOME `
|
||||
-PackagePath $DIST_DIR -Deploy
|
||||
|
||||
Binary file not shown.
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"fileDataID": 3730958,
|
||||
"textures": [
|
||||
{
|
||||
"fileDataID": 3732460,
|
||||
"file": "catslime_3732460.blp"
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732461,
|
||||
"file": "catslime_3732461.blp"
|
||||
}
|
||||
],
|
||||
"skins": [
|
||||
{
|
||||
"fileDataID": 3732472,
|
||||
"file": "catslime00.skin"
|
||||
}
|
||||
],
|
||||
"lodSkins": [
|
||||
{
|
||||
"fileDataID": 3732473,
|
||||
"file": "catslime_lod01.skin"
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732474,
|
||||
"file": "catslime_lod02.skin"
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732475,
|
||||
"file": "catslime_lod03.skin"
|
||||
}
|
||||
],
|
||||
"anims": [
|
||||
{
|
||||
"fileDataID": 3732462,
|
||||
"file": "catslime0097-00.anim",
|
||||
"animID": 97,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732463,
|
||||
"file": "catslime0096-00.anim",
|
||||
"animID": 96,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732464,
|
||||
"file": "catslime0098-00.anim",
|
||||
"animID": 98,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732465,
|
||||
"file": "catslime0100-00.anim",
|
||||
"animID": 100,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732466,
|
||||
"file": "catslime0099-00.anim",
|
||||
"animID": 99,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732467,
|
||||
"file": "catslime0101-00.anim",
|
||||
"animID": 101,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732468,
|
||||
"file": "catslime0069-00.anim",
|
||||
"animID": 69,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732469,
|
||||
"file": "catslime0123-00.anim",
|
||||
"animID": 123,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732470,
|
||||
"file": "catslime0061-00.anim",
|
||||
"animID": 61,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732471,
|
||||
"file": "catslime0075-00.anim",
|
||||
"animID": 75,
|
||||
"subAnimID": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -23,7 +23,7 @@ FACTION_BACKDROP_COLOR_TABLE = {
|
||||
["Alliance"] = {0.5, 0.5, 0.5, 0.09, 0.09, 0.19},
|
||||
["Horde"] = {0.5, 0.2, 0.2, 0.19, 0.05, 0.05},
|
||||
};
|
||||
FRAMES_TO_BACKDROP_COLOR = {
|
||||
FRAMES_TO_BACKDROP_COLOR = {
|
||||
"CharacterCreateCharacterRace",
|
||||
"CharacterCreateCharacterClass",
|
||||
-- "CharacterCreateCharacterFaction",
|
||||
@@ -36,11 +36,11 @@ RACE_ICON_TCOORDS = {
|
||||
["NIGHTELF_MALE"] = {0.126953125, 0.189453125, 0.000000000, 0.125953125},
|
||||
["DRAENEI_MALE"] = {0.255859375, 0.318359375, 0.000000000, 0.125953125},
|
||||
|
||||
["HUMAN_FEMALE"] = {0.000000000, 0.062500000, 0.128906250, 0.253906250},
|
||||
["DWARF_FEMALE"] = {0.063476563, 0.125976563, 0.128906250, 0.253906250},
|
||||
["GNOME_FEMALE"] = {0.192382813, 0.254882813, 0.128906250, 0.253906250},
|
||||
["NIGHTELF_FEMALE"] = {0.127441406, 0.189941406, 0.128906250, 0.253906250},
|
||||
["DRAENEI_FEMALE"] = {0.256347656, 0.318359375, 0.128906250, 0.253906250},
|
||||
["HUMAN_FEMALE"] = {0.000000000, 0.062500000, 0.128906250, 0.253906250},
|
||||
["DWARF_FEMALE"] = {0.063476563, 0.125976563, 0.128906250, 0.253906250},
|
||||
["GNOME_FEMALE"] = {0.192382813, 0.254882813, 0.128906250, 0.253906250},
|
||||
["NIGHTELF_FEMALE"] = {0.127441406, 0.189941406, 0.128906250, 0.253906250},
|
||||
["DRAENEI_FEMALE"] = {0.256347656, 0.318359375, 0.128906250, 0.253906250},
|
||||
|
||||
["ORC_MALE"] = {0.062011719, 0.000000000, 0.262695313, 0.384765625},
|
||||
["SCOURGE_MALE"] = {0.125976563, 0.063964844, 0.262695313, 0.384765625},
|
||||
@@ -48,10 +48,10 @@ RACE_ICON_TCOORDS = {
|
||||
["TROLL_MALE"] = {0.253906250, 0.192382813, 0.262695313, 0.384765625},
|
||||
["BLOODELF_MALE"] = {0.317871094, 0.256835938, 0.262695313, 0.384765625},
|
||||
|
||||
["ORC_FEMALE"] = {0.060546875, 0.000000000, 0.391601563, 0.512695313},
|
||||
["SCOURGE_FEMALE"] = {0.124023438, 0.063476563, 0.391601563, 0.512695313},
|
||||
["TAUREN_FEMALE"] = {0.187988281, 0.127441406, 0.391601563, 0.512695313},
|
||||
["TROLL_FEMALE"] = {0.252441406, 0.191894531, 0.391601563, 0.512695313},
|
||||
["ORC_FEMALE"] = {0.060546875, 0.000000000, 0.391601563, 0.512695313},
|
||||
["SCOURGE_FEMALE"] = {0.124023438, 0.063476563, 0.391601563, 0.512695313},
|
||||
["TAUREN_FEMALE"] = {0.187988281, 0.127441406, 0.391601563, 0.512695313},
|
||||
["TROLL_FEMALE"] = {0.252441406, 0.191894531, 0.391601563, 0.512695313},
|
||||
["BLOODELF_FEMALE"] = {0.316894531, 0.256835938, 0.391601563, 0.512695313},
|
||||
};
|
||||
CLASS_ICON_TCOORDS = {
|
||||
@@ -137,23 +137,23 @@ local function HideAllTooltips()
|
||||
if AllianceTooltip then
|
||||
AllianceTooltip:Hide()
|
||||
end
|
||||
|
||||
|
||||
if HordeTooltip then
|
||||
HordeTooltip:Hide()
|
||||
end
|
||||
end
|
||||
|
||||
local backdrop = {
|
||||
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
|
||||
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
|
||||
tile = true, tileSize = 16, edgeSize = 16,
|
||||
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
|
||||
edgeFile = "Interface\\Tooltips\\UI-Tooltip-Border",
|
||||
tile = true, tileSize = 16, edgeSize = 16,
|
||||
insets = { left = 4, right = 4, top = 4, bottom = 4 }
|
||||
};
|
||||
|
||||
local Backdrop2 = {
|
||||
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
|
||||
edgeFile = "Interface\\Tooltips\\ui-tooltip-border-maw",
|
||||
tile = true, tileSize = 16, edgeSize = 16,
|
||||
bgFile = "Interface\\Tooltips\\UI-Tooltip-Background",
|
||||
edgeFile = "Interface\\Tooltips\\ui-tooltip-border-maw",
|
||||
tile = true, tileSize = 16, edgeSize = 16,
|
||||
insets = { left = 4, right = 4, top = 4, bottom = 4 }
|
||||
};
|
||||
|
||||
@@ -164,10 +164,10 @@ local function GetOrCreateRaceTooltip(button)
|
||||
tooltip:SetSize(280, 150)
|
||||
tooltip:SetFrameStrata("TOOLTIP")
|
||||
tooltip:SetBackdropColor(0, 0, 0, 1)
|
||||
|
||||
|
||||
local raceID = button:GetID()
|
||||
local faction = _G.GetFactionForRaceID(raceID)
|
||||
|
||||
|
||||
if faction == "Alliance" then
|
||||
tooltip:SetPoint("LEFT", CharacterCreateFrame, "LEFT", 160, 50)
|
||||
elseif faction == "Horde" then
|
||||
@@ -194,7 +194,7 @@ local function GetOrCreateRaceTooltip(button)
|
||||
tooltip.rightClickText:SetPoint("TOP", tooltip.detailsText, "BOTTOM", 0, -10)
|
||||
tooltip.rightClickText:SetTextColor(0.8, 0.8, 0.8)
|
||||
tooltip.rightClickText:SetJustifyH("CENTER")
|
||||
|
||||
|
||||
raceTooltips[button] = tooltip
|
||||
end
|
||||
return raceTooltips[button]
|
||||
@@ -217,7 +217,7 @@ local function UpdateRaceTooltip(button, toggleDetails)
|
||||
|
||||
tooltip.text:ClearAllPoints()
|
||||
tooltip.text:SetPoint("TOPLEFT", tooltip, "TOPLEFT", 10, -5)
|
||||
|
||||
|
||||
tooltip.detailsText:ClearAllPoints()
|
||||
tooltip.detailsText:SetPoint("TOPLEFT", tooltip.text, "BOTTOMLEFT", 0, -5)
|
||||
|
||||
@@ -271,7 +271,7 @@ local function UpdateRaceTooltip(button, toggleDetails)
|
||||
|
||||
local containerHeight = tooltip["spellIcon"..i]:GetHeight() + tooltip["spellDesc"..i]:GetHeight() + 3
|
||||
tooltip["spellContainer"..i]:SetHeight(containerHeight)
|
||||
|
||||
|
||||
lastElement = tooltip["spellContainer"..i]
|
||||
end
|
||||
end
|
||||
@@ -308,10 +308,10 @@ local function UpdateRaceTooltip(button, toggleDetails)
|
||||
end
|
||||
end
|
||||
|
||||
local baseHeight = tooltip.text:GetHeight() +
|
||||
tooltip.detailsText:GetHeight() +
|
||||
local baseHeight = tooltip.text:GetHeight() +
|
||||
tooltip.detailsText:GetHeight() +
|
||||
tooltip.rightClickText:GetHeight() + 25
|
||||
|
||||
|
||||
tooltip:SetHeight(baseHeight + heightToAdd)
|
||||
detailedRaceTooltips[button] = toggleDetails
|
||||
tooltip:ClearAllPoints()
|
||||
@@ -346,7 +346,7 @@ local function GetOrCreateClassTooltip(button)
|
||||
tooltip.Roles:SetWidth(284)
|
||||
tooltip.Roles:SetWordWrap(true)
|
||||
tooltip.Roles:SetJustifyH("LEFT")
|
||||
|
||||
|
||||
classTooltips[button] = tooltip
|
||||
end
|
||||
|
||||
@@ -370,7 +370,7 @@ local function GetValidRacesForClass(classID)
|
||||
table.insert(hordeRaces, raceName)
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
return allianceRaces, hordeRaces
|
||||
end
|
||||
local function UpdateClassTooltip(button)
|
||||
@@ -382,7 +382,7 @@ local function UpdateClassTooltip(button)
|
||||
|
||||
local buttonX, buttonY = button:GetCenter()
|
||||
local parentX, parentY = CharacterCreateFrame:GetCenter()
|
||||
|
||||
|
||||
if buttonX > parentX then
|
||||
tooltip:SetPoint("BOTTOMRIGHT", button, "TOPRIGHT", 0, 10)
|
||||
else
|
||||
@@ -394,7 +394,7 @@ local function UpdateClassTooltip(button)
|
||||
|
||||
tooltip.text:ClearAllPoints()
|
||||
tooltip.text:SetPoint("TOPLEFT", tooltip, "TOPLEFT", 8, -8)
|
||||
|
||||
|
||||
tooltip.detailsText:ClearAllPoints()
|
||||
tooltip.detailsText:SetPoint("TOPLEFT", tooltip.text, "BOTTOMLEFT", 0, -5)
|
||||
|
||||
@@ -405,7 +405,7 @@ local function UpdateClassTooltip(button)
|
||||
tooltip.RestrictionText = tooltip.RestrictionText or tooltip:CreateFontString(nil, "OVERLAY")
|
||||
tooltip.RestrictionText:SetFont("Fonts\\FRIZQT__.TTF", 12, "OUTLINE")
|
||||
tooltip.RestrictionText:SetJustifyH("LEFT")
|
||||
|
||||
|
||||
tooltip.FactionText = tooltip.FactionText or tooltip:CreateFontString(nil, "OVERLAY")
|
||||
tooltip.FactionText:SetFont("Fonts\\FRIZQT__.TTF", 11)
|
||||
tooltip.FactionText:SetWordWrap(true)
|
||||
@@ -416,12 +416,12 @@ local function UpdateClassTooltip(button)
|
||||
coloredRoles = string.gsub(coloredRoles, "Дальний бой", "|cffff2020Дальний бой|r")
|
||||
coloredRoles = string.gsub(coloredRoles, "Танк", "|cff0070ddТанк|r")
|
||||
coloredRoles = string.gsub(coloredRoles, "Лекарь", "|cff20c000Лекарь|r")
|
||||
|
||||
|
||||
if not tooltip.Roles then
|
||||
tooltip.Roles = tooltip:CreateFontString(nil, "OVERLAY")
|
||||
tooltip.Roles:SetFont("Fonts\\FRIZQT__.TTF", 11)
|
||||
end
|
||||
|
||||
|
||||
tooltip.Roles:SetText("|cFFFFFFFF"..FUNTION_INF.."|r\n\n "..coloredRoles)
|
||||
|
||||
if showRestriction then
|
||||
@@ -435,20 +435,20 @@ local function UpdateClassTooltip(button)
|
||||
tooltip.RestrictionText:Show()
|
||||
|
||||
local allianceRaces, hordeRaces = GetValidRacesForClass(classID)
|
||||
|
||||
|
||||
local factionText = ""
|
||||
|
||||
|
||||
if #allianceRaces > 0 then
|
||||
factionText = ALLIANCE_RACE .. " |cFFFFFFFF" .. table.concat(allianceRaces, ", ") .. "|r"
|
||||
end
|
||||
|
||||
|
||||
if #hordeRaces > 0 then
|
||||
if factionText ~= "" then
|
||||
factionText = factionText .. "\n\n"
|
||||
end
|
||||
factionText = factionText .. "\n" .. HORDE_RACE .. " |cFFFFFFFF" .. table.concat(hordeRaces, ", ") .. "|r"
|
||||
end
|
||||
|
||||
|
||||
tooltip.FactionText:SetPoint("TOPLEFT", tooltip.RestrictionText, "BOTTOMLEFT", 0, -15)
|
||||
tooltip.FactionText:SetTextColor(1, 0.82, 0)
|
||||
tooltip.FactionText:SetText(factionText)
|
||||
@@ -459,18 +459,18 @@ local function UpdateClassTooltip(button)
|
||||
maxWidth = math.max(maxWidth, tooltip.RestrictionText:GetWidth())
|
||||
maxWidth = math.max(maxWidth, tooltip.FactionText:GetWidth())
|
||||
maxWidth = math.max(280, math.min(maxWidth + 16, 400))
|
||||
|
||||
|
||||
tooltip:SetWidth(maxWidth)
|
||||
tooltip.FactionText:SetWidth(maxWidth - 16)
|
||||
|
||||
local restrictionHeight = tooltip.text:GetHeight() +
|
||||
tooltip.detailsText:GetHeight() +
|
||||
|
||||
local restrictionHeight = tooltip.text:GetHeight() +
|
||||
tooltip.detailsText:GetHeight() +
|
||||
tooltip.Roles:GetHeight() +
|
||||
tooltip.RestrictionText:GetHeight() +
|
||||
tooltip.RestrictionText:GetHeight() +
|
||||
tooltip.FactionText:GetHeight() + 80
|
||||
|
||||
|
||||
tooltip:SetHeight(restrictionHeight)
|
||||
|
||||
|
||||
else
|
||||
tooltip.RestrictionText:Hide()
|
||||
tooltip.FactionText:Hide()
|
||||
@@ -484,10 +484,10 @@ local function UpdateClassTooltip(button)
|
||||
maxWidth = math.max(280, math.min(maxWidth + 16, 400))
|
||||
tooltip:SetWidth(maxWidth)
|
||||
|
||||
local baseHeight = tooltip.text:GetHeight() +
|
||||
tooltip.detailsText:GetHeight() +
|
||||
local baseHeight = tooltip.text:GetHeight() +
|
||||
tooltip.detailsText:GetHeight() +
|
||||
tooltip.Roles:GetHeight() + 30
|
||||
|
||||
|
||||
tooltip:SetHeight(baseHeight)
|
||||
end
|
||||
end
|
||||
@@ -498,7 +498,7 @@ function CharacterCreate_MoveTexturesToBackground()
|
||||
if button then
|
||||
local normalTex = _G[button:GetName().."NormalTexture"]
|
||||
local pushedTex = _G[button:GetName().."PushedTexture"]
|
||||
|
||||
|
||||
if normalTex then
|
||||
normalTex:SetDrawLayer("BACKGROUND")
|
||||
end
|
||||
@@ -513,7 +513,7 @@ function CharacterCreate_MoveTexturesToBackground()
|
||||
if button then
|
||||
local normalTex = _G[button:GetName().."NormalTexture"]
|
||||
local pushedTex = _G[button:GetName().."PushedTexture"]
|
||||
|
||||
|
||||
if normalTex then
|
||||
normalTex:SetDrawLayer("BACKGROUND")
|
||||
end
|
||||
@@ -525,12 +525,12 @@ function CharacterCreate_MoveTexturesToBackground()
|
||||
|
||||
local maleButton = CharacterCreateGenderButtonMale
|
||||
local femaleButton = CharacterCreateGenderButtonFemale
|
||||
|
||||
|
||||
if maleButton then
|
||||
_G[maleButton:GetName().."NormalTexture"]:SetDrawLayer("BACKGROUND")
|
||||
_G[maleButton:GetName().."PushedTexture"]:SetDrawLayer("BACKGROUND")
|
||||
end
|
||||
|
||||
|
||||
if femaleButton then
|
||||
_G[femaleButton:GetName().."NormalTexture"]:SetDrawLayer("BACKGROUND")
|
||||
_G[femaleButton:GetName().."PushedTexture"]:SetDrawLayer("BACKGROUND")
|
||||
@@ -558,7 +558,7 @@ local function CreateFactionTooltip(parent, factionName)
|
||||
tooltip.description:SetWordWrap(true)
|
||||
tooltip.description:SetJustifyH("LEFT")
|
||||
tooltip.description:SetTextColor(0.9, 0.9, 0.9)
|
||||
|
||||
|
||||
return tooltip
|
||||
end
|
||||
|
||||
@@ -566,7 +566,7 @@ local function UpdateTooltipSize(tooltip)
|
||||
local titleHeight = tooltip.title:GetHeight()
|
||||
local descHeight = tooltip.description:GetHeight()
|
||||
local totalHeight = titleHeight + descHeight + 30
|
||||
|
||||
|
||||
tooltip:SetHeight(totalHeight)
|
||||
tooltip:SetWidth(300)
|
||||
end
|
||||
@@ -580,7 +580,7 @@ function CharacterCreate_PositionRaceButtons()
|
||||
local button = _G["CharacterCreateRaceButton"..i]
|
||||
if button then
|
||||
button:ClearAllPoints()
|
||||
|
||||
|
||||
if i == 1 then
|
||||
button:SetPoint("TOP", AllianceLogoFrame, "BOTTOM", -horizontalOffset, verticalStart)
|
||||
else
|
||||
@@ -594,7 +594,7 @@ function CharacterCreate_PositionRaceButtons()
|
||||
local button = _G["CharacterCreateRaceButton"..i]
|
||||
if button then
|
||||
button:ClearAllPoints()
|
||||
|
||||
|
||||
if i == 6 then
|
||||
button:SetPoint("TOP", HordeLogoFrame, "BOTTOM", horizontalOffset, verticalStart)
|
||||
else
|
||||
@@ -607,12 +607,12 @@ end
|
||||
|
||||
function CharacterCreate_PositionClassButtons()
|
||||
local buttonSpacing = 80
|
||||
|
||||
|
||||
for i = 1, MAX_CLASSES_PER_RACE do
|
||||
local button = _G["CharacterCreateClassButton"..i]
|
||||
if button then
|
||||
button:ClearAllPoints()
|
||||
|
||||
|
||||
if i == 1 then
|
||||
button:SetPoint("CENTER", CharacterCreateFrame, "BOTTOM", -360, 80)
|
||||
else
|
||||
@@ -624,16 +624,16 @@ function CharacterCreate_PositionClassButtons()
|
||||
end
|
||||
|
||||
function CharacterCreate_PositionGenderButtons()
|
||||
local genderSpacing = 310
|
||||
|
||||
local genderSpacing = 310
|
||||
|
||||
local maleButton = CharacterCreateGenderButtonMale
|
||||
local femaleButton = CharacterCreateGenderButtonFemale
|
||||
|
||||
|
||||
if maleButton then
|
||||
maleButton:ClearAllPoints()
|
||||
maleButton:SetPoint("CENTER", CharacterCreateFrame, "CENTER", -(genderSpacing/2), -250)
|
||||
end
|
||||
|
||||
|
||||
if femaleButton then
|
||||
femaleButton:ClearAllPoints()
|
||||
femaleButton:SetPoint("CENTER", CharacterCreateFrame, "CENTER", (genderSpacing/2), -250)
|
||||
@@ -676,6 +676,44 @@ function CharacterCreate_UpdateGameModeVisibility()
|
||||
end
|
||||
end
|
||||
|
||||
-- The stock creation scene frames each race at a different height. Projection
|
||||
-- zoom therefore needs a per-race vertical target to keep the face in the same
|
||||
-- part of the screen. Values are indexed by GetSelectedSex(): 2 male, 3 female.
|
||||
local CHARACTER_CREATE_CAMERA_PROFILES = {
|
||||
HUMAN = { [2] = -0.28, [3] = -0.20 },
|
||||
ORC = { [2] = -0.50, [3] = -0.30 },
|
||||
DWARF = { [2] = 0.30, [3] = 0.38 },
|
||||
NIGHTELF = { [2] = -0.65, [3] = -0.65 },
|
||||
SCOURGE = { [2] = 0.18, [3] = 0.27 },
|
||||
TAUREN = { [2] = -0.78, [3] = -0.62 },
|
||||
GNOME = { [2] = 0.64, [3] = 0.72 },
|
||||
TROLL = { [2] = -0.70, [3] = -0.55 },
|
||||
BLOODELF = { [2] = -0.30, [3] = -0.22 },
|
||||
DRAENEI = { [2] = -0.62, [3] = -0.55 },
|
||||
};
|
||||
|
||||
local function CharacterCreate_GetCameraProfile()
|
||||
local _, raceFile = GetNameForRace();
|
||||
local raceProfile = raceFile and CHARACTER_CREATE_CAMERA_PROFILES[strupper(raceFile)];
|
||||
local sex = GetSelectedSex();
|
||||
return 2.0, raceProfile and raceProfile[sex] or -0.20;
|
||||
end
|
||||
|
||||
local function CharacterCreate_UpdateCamera(immediate)
|
||||
local showFace = CharacterCreate.personalizationMode and not CharacterCreate.gameModeSelectionMode;
|
||||
local mode = showFace and 1 or 0;
|
||||
if CharacterCreate.cameraMode == mode and not immediate then
|
||||
return;
|
||||
end
|
||||
|
||||
CharacterCreate.cameraMode = mode;
|
||||
CharacterCreate:SetCamera(0);
|
||||
if MoonWellSetCharacterCreateCamera then
|
||||
local zoom, verticalOffset = CharacterCreate_GetCameraProfile();
|
||||
MoonWellSetCharacterCreateCamera(mode, zoom, verticalOffset, immediate and 0 or 500);
|
||||
end
|
||||
end
|
||||
|
||||
-- The stock creation scene frames each race at a different height. Projection
|
||||
-- zoom therefore needs a per-race vertical target to keep the face in frame.
|
||||
-- Values are indexed by GetSelectedSex(): 2 male, 3 female.
|
||||
@@ -818,7 +856,7 @@ function CharacterCreate_OnLoad(self)
|
||||
self:SetSequence(0)
|
||||
self:SetCamera(0)
|
||||
SetCharCustomizeFrame("CharacterCreate")
|
||||
|
||||
|
||||
CharacterCreate.numRaces = 0
|
||||
CharacterCreate.selectedRace = 0
|
||||
CharacterCreate.numClasses = 0
|
||||
@@ -860,7 +898,7 @@ function CharacterCreate_OnLoad(self)
|
||||
AllianceLogoFrame:SetSize(100, 100)
|
||||
AllianceLogoFrame:SetPoint("TOPLEFT", -16, 16)
|
||||
AllianceLogoFrame:EnableMouse(true)
|
||||
|
||||
|
||||
AllianceLogoFrame:SetScript("OnEnter", function(self)
|
||||
AllianceTooltip.title:SetText(ALLIANCE)
|
||||
AllianceTooltip.description:SetText(FACTION_ALLIANCE_DESCRIPTION)
|
||||
@@ -869,7 +907,7 @@ function CharacterCreate_OnLoad(self)
|
||||
AllianceTooltip:SetPoint("TOPLEFT", self, "BOTTOMLEFT", 90, 60)
|
||||
AllianceTooltip:Show()
|
||||
end)
|
||||
|
||||
|
||||
AllianceLogoFrame:SetScript("OnLeave", function(self)
|
||||
AllianceTooltip:Hide()
|
||||
end)
|
||||
@@ -890,7 +928,7 @@ function CharacterCreate_OnLoad(self)
|
||||
HordeLogoFrame:SetSize(100, 100)
|
||||
HordeLogoFrame:SetPoint("TOPRIGHT", 16, 16)
|
||||
HordeLogoFrame:EnableMouse(true)
|
||||
|
||||
|
||||
HordeLogoFrame:SetScript("OnEnter", function(self)
|
||||
HordeTooltip.title:SetText(HORDE)
|
||||
HordeTooltip.description:SetText(FACTION_HORDE_DESCRIPTION)
|
||||
@@ -899,7 +937,7 @@ function CharacterCreate_OnLoad(self)
|
||||
HordeTooltip:SetPoint("TOPRIGHT", self, "BOTTOMRIGHT", -90, 60)
|
||||
HordeTooltip:Show()
|
||||
end)
|
||||
|
||||
|
||||
HordeLogoFrame:SetScript("OnLeave", function(self)
|
||||
HordeTooltip:Hide()
|
||||
end)
|
||||
@@ -913,7 +951,7 @@ function CharacterCreate_SetupCustomButtons()
|
||||
{button = CharCreateRandomizeButton, width = 36, height = 36},
|
||||
{button = CharacterCreateRandomName, width = 30, height = 30}
|
||||
}
|
||||
|
||||
|
||||
local texturePath = "Interface\\Glues\\CharacterCreate\\charactercreate"
|
||||
|
||||
for _, btnInfo in ipairs(buttons) do
|
||||
@@ -1002,7 +1040,7 @@ end)
|
||||
|
||||
function CharacterCreate_OnShow()
|
||||
CharacterCreate.personalizationMode = false;
|
||||
|
||||
|
||||
for i=1, MAX_CLASSES_PER_RACE, 1 do
|
||||
local button = _G["CharacterCreateClassButton"..i];
|
||||
button:Enable();
|
||||
@@ -1026,7 +1064,7 @@ function CharacterCreate_OnShow()
|
||||
CharacterCreate.personalizationMode = false;
|
||||
CharacterCreate.gameModeSelectionMode = false;
|
||||
CharCreateOkayButton:Hide();
|
||||
|
||||
|
||||
for i=1, NUM_CHAR_CUSTOMIZATIONS do
|
||||
_G["CharacterCustomizationButtonFrame"..i]:Hide();
|
||||
end
|
||||
@@ -1113,11 +1151,11 @@ function CharacterCreateEnumerateRaces(...)
|
||||
local raceID = index
|
||||
local faction = _G.GetFactionForRaceID(raceID)
|
||||
local borderColor
|
||||
|
||||
|
||||
if faction == "Alliance" then
|
||||
borderColor = {0.0, 0.4, 1.0}
|
||||
else
|
||||
borderColor = {1.0, 0.0, 0.0}
|
||||
borderColor = {1.0, 0.0, 0.0}
|
||||
end
|
||||
|
||||
if not button.staticTexture then
|
||||
@@ -1155,10 +1193,10 @@ function CharacterCreateEnumerateRaces(...)
|
||||
if not button.texturesMoved then
|
||||
button:SetScript("OnMouseDown", nil);
|
||||
button:SetScript("OnMouseUp", nil);
|
||||
|
||||
|
||||
button.texturesMoved = true;
|
||||
end
|
||||
|
||||
|
||||
button:Show();
|
||||
if ( select(i+2, ...) == 1 ) then
|
||||
button.enable = true;
|
||||
@@ -1180,7 +1218,7 @@ function CharacterCreateEnumerateRaces(...)
|
||||
tooltip:Hide()
|
||||
end
|
||||
end)
|
||||
|
||||
|
||||
button:SetScript("OnMouseDown", function(self, clickedButton)
|
||||
if self:IsEnabled() and clickedButton == "RightButton" then
|
||||
local tooltip = GetOrCreateRaceTooltip(self)
|
||||
@@ -1246,7 +1284,7 @@ function CharacterCreateEnumerateClasses(...)
|
||||
button.nameFrame = CreateFrame("Frame", nil, button);
|
||||
button.nameFrame:SetSize(112, 40);
|
||||
button.nameFrame:SetPoint("TOP", button, "BOTTOM", 0, -5);
|
||||
|
||||
|
||||
button.nameFrame.text = button.nameFrame:CreateFontString(nil, "OVERLAY");
|
||||
button.nameFrame.text:SetFont("Fonts\\FRIZQT__.TTF", 10, "OUTLINE");
|
||||
button.nameFrame.text:SetPoint("CENTER", 0, 0);
|
||||
@@ -1261,10 +1299,10 @@ function CharacterCreateEnumerateClasses(...)
|
||||
if not button.texturesMoved then
|
||||
button:SetScript("OnMouseDown", nil);
|
||||
button:SetScript("OnMouseUp", nil);
|
||||
|
||||
|
||||
button.texturesMoved = true;
|
||||
end
|
||||
|
||||
|
||||
button:Show();
|
||||
|
||||
local className = select(i, ...);
|
||||
@@ -1311,7 +1349,7 @@ function CharacterCreateEnumerateClasses(...)
|
||||
tooltip:Hide()
|
||||
end
|
||||
end)
|
||||
|
||||
|
||||
button:SetScript("OnMouseDown", function(self, clickedButton)
|
||||
if self:IsEnabled() and clickedButton == "RightButton" then
|
||||
local tooltip = GetOrCreateClassTooltip(self)
|
||||
@@ -1356,14 +1394,14 @@ function CharacterCreate_CreateGenderButtonTextures()
|
||||
maleButton.staticTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorder_F1");
|
||||
maleButton.staticTexture:SetSize(86, 86);
|
||||
maleButton.staticTexture:SetPoint("CENTER", 0, 0);
|
||||
|
||||
|
||||
maleButton.highlightTexture = maleButton:CreateTexture(maleButton:GetName().."HighlightTexture", "HIGHLIGHT");
|
||||
maleButton.highlightTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorder_F1");
|
||||
maleButton.highlightTexture:SetAlpha(0.5);
|
||||
maleButton.highlightTexture:SetBlendMode("ADD");
|
||||
maleButton.highlightTexture:SetSize(86, 86);
|
||||
maleButton.highlightTexture:SetPoint("CENTER", 0, 0);
|
||||
|
||||
|
||||
maleButton.checkedTexture = maleButton:CreateTexture(maleButton:GetName().."CheckedTexture", "OVERLAY");
|
||||
maleButton.checkedTexture:SetDrawLayer("OVERLAY", 7);
|
||||
maleButton.checkedTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorderRace_H");
|
||||
@@ -1382,14 +1420,14 @@ function CharacterCreate_CreateGenderButtonTextures()
|
||||
femaleButton.staticTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorder_F1");
|
||||
femaleButton.staticTexture:SetSize(86, 86);
|
||||
femaleButton.staticTexture:SetPoint("CENTER", 0, 0);
|
||||
|
||||
|
||||
femaleButton.highlightTexture = femaleButton:CreateTexture(femaleButton:GetName().."HighlightTexture", "HIGHLIGHT");
|
||||
femaleButton.highlightTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorder_F1");
|
||||
femaleButton.highlightTexture:SetAlpha(0.5);
|
||||
femaleButton.highlightTexture:SetBlendMode("ADD");
|
||||
femaleButton.highlightTexture:SetSize(86, 86);
|
||||
femaleButton.highlightTexture:SetPoint("CENTER", 0, 0);
|
||||
|
||||
|
||||
femaleButton.checkedTexture = femaleButton:CreateTexture(femaleButton:GetName().."CheckedTexture", "OVERLAY");
|
||||
femaleButton.checkedTexture:SetDrawLayer("OVERLAY", 7);
|
||||
femaleButton.checkedTexture:SetTexture("Interface\\Glues\\CharacterCreate\\IconBorderRace_H");
|
||||
@@ -1480,7 +1518,7 @@ function SetCharacterClass(id)
|
||||
end
|
||||
|
||||
CharacterCreate_UpdateButtonCheckedStates();
|
||||
|
||||
|
||||
local className, classFileName, _, tank, healer, damage = GetSelectedClass();
|
||||
local abilityIndex = 0;
|
||||
local tempText = _G["CLASS_INFO_"..classFileName..abilityIndex];
|
||||
@@ -1493,7 +1531,7 @@ function SetCharacterClass(id)
|
||||
local coords = CLASS_ICON_TCOORDS[classFileName];
|
||||
CharacterCreateClassIcon:SetTexCoord(coords[1], coords[2], coords[3], coords[4]);
|
||||
CharacterCreateClassLabel:SetText(className);
|
||||
CharacterCreateClassRolesText:SetText(abilityText);
|
||||
CharacterCreateClassRolesText:SetText(abilityText);
|
||||
CharacterCreateClassText:SetText(GetFlavorText("CLASS_"..strupper(classFileName), GetSelectedSex()).."|n|n");
|
||||
CharacterCreateClassScrollFrameScrollBar:SetValue(0);
|
||||
end
|
||||
@@ -1529,7 +1567,7 @@ function CharacterCreate_UpdateButtonCheckedStates()
|
||||
|
||||
local maleButton = CharacterCreateGenderButtonMale;
|
||||
local femaleButton = CharacterCreateGenderButtonFemale;
|
||||
|
||||
|
||||
if maleButton and maleButton.checkedTexture then
|
||||
if maleButton:GetChecked() then
|
||||
maleButton.checkedTexture:Show();
|
||||
@@ -1537,7 +1575,7 @@ function CharacterCreate_UpdateButtonCheckedStates()
|
||||
maleButton.checkedTexture:Hide();
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
if femaleButton and femaleButton.checkedTexture then
|
||||
if femaleButton:GetChecked() then
|
||||
femaleButton.checkedTexture:Show();
|
||||
@@ -1641,7 +1679,7 @@ function CharacterRace_OnClick(self, id)
|
||||
SetCharacterClass(classIndex);
|
||||
|
||||
CharacterCreate_UpdateHairCustomization();
|
||||
|
||||
|
||||
CharacterChangeFixup();
|
||||
|
||||
CharacterCreate_UpdateButtonCheckedStates();
|
||||
@@ -1666,7 +1704,7 @@ function SetCharacterGender(sex)
|
||||
CharacterCreateEnumerateRaces(GetAvailableRaces());
|
||||
CharacterCreateEnumerateClasses(GetAvailableClasses());
|
||||
SetCharacterRace(GetSelectedRace());
|
||||
|
||||
|
||||
local _,_,classIndex = GetSelectedClass();
|
||||
if ( PAID_SERVICE_TYPE ) then
|
||||
classIndex = PaidChange_GetCurrentClassIndex();
|
||||
@@ -1680,7 +1718,7 @@ function SetCharacterGender(sex)
|
||||
fileString = strupper(fileString);
|
||||
local coords = RACE_ICON_TCOORDS[fileString.."_"..gender];
|
||||
CharacterCreateRaceIcon:SetTexCoord(coords[1], coords[2], coords[3], coords[4]);
|
||||
|
||||
|
||||
CharacterChangeFixup();
|
||||
|
||||
CharacterCreate_UpdateButtonCheckedStates();
|
||||
@@ -1707,7 +1745,7 @@ function CharacterCreate_ResetState(immediate)
|
||||
CharCreatePersonalizeButton:Show();
|
||||
CharCreateOkayButton:Hide();
|
||||
CharacterCreate_UpdateGameModeVisibility();
|
||||
|
||||
|
||||
for i=1, NUM_CHAR_CUSTOMIZATIONS do
|
||||
_G["CharacterCustomizationButtonFrame"..i]:Hide();
|
||||
end
|
||||
@@ -1753,7 +1791,7 @@ end
|
||||
function CharacterCreate_UpdateHairCustomization()
|
||||
CharacterCustomizationButtonFrame3Text:SetText(_G["HAIR_"..GetHairCustomization().."_STYLE"]);
|
||||
CharacterCustomizationButtonFrame4Text:SetText(_G["HAIR_"..GetHairCustomization().."_COLOR"]);
|
||||
CharacterCustomizationButtonFrame5Text:SetText(_G["FACIAL_HAIR_"..GetFacialHairCustomization()]);
|
||||
CharacterCustomizationButtonFrame5Text:SetText(_G["FACIAL_HAIR_"..GetFacialHairCustomization()]);
|
||||
end
|
||||
|
||||
function SetButtonDesaturated(button, desaturated, r, g, b)
|
||||
@@ -1775,7 +1813,7 @@ function SetButtonDesaturated(button, desaturated, r, g, b)
|
||||
g = 0.5;
|
||||
b = 0.5;
|
||||
end
|
||||
|
||||
|
||||
icon:SetVertexColor(r, g, b);
|
||||
end
|
||||
|
||||
|
||||
@@ -1139,6 +1139,12 @@ end
|
||||
function CharacterSelect_EnterWorld()
|
||||
PlaySound("gsCharacterSelectionEnterWorld");
|
||||
StopGlueAmbience();
|
||||
-- Persist the server-provided game-mode bit across the GlueXML -> FrameXML
|
||||
-- Lua-state transition. WarcraftXL owns the value for this Wow.exe process.
|
||||
if ( type(MoonWellSetLoginCharacterFlags) == "function" ) then
|
||||
local info = { GetCharacterInfo(CharacterSelect.selectedIndex) };
|
||||
MoonWellSetLoginCharacterFlags(info[11] or 0);
|
||||
end
|
||||
EnterWorld();
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
-- The MoonWell Achievement.dbc intentionally exposes both faction variants.
|
||||
-- Restore the 3.3.5a client-side faction filter and make traitors use the
|
||||
-- opposite faction. IDs come from the stock ruRU build 12340 DBC.
|
||||
|
||||
local achievementFaction = {}
|
||||
|
||||
local function AddFaction(ids, faction)
|
||||
for id in string.gmatch(ids, "%d+") do
|
||||
achievementFaction[tonumber(id)] = faction
|
||||
end
|
||||
end
|
||||
|
||||
AddFaction([[
|
||||
33 34 35 37 41 58 202 203 206 220 225 230 246 388 433 434 435 436 437 438
|
||||
439 440 441 442 470 471 472 473 604 610 611 612 613 614 701 707 709 711 713
|
||||
764 899 907 908 942 948 963 966 969 970 1012 1022 1023 1024 1028 1029 1030
|
||||
1034 1035 1038 1040 1151 1167 1169 1172 1184 1189 1191 1192 1255 1262 1279
|
||||
1466 1563 1656 1676 1678 1681 1684 1686 1692 1697 1707 1737 1752 1757 1762
|
||||
1782 2016 2144 2194 2419 2421 2536 2760 2761 2762 2763 2764 2770 2777 2778
|
||||
2779 2780 2781 2782 2797 2817 3356 3478 3556 3576 3580 3596 3676 3846 3851
|
||||
3856 3857 4156 4296 4298 4436 4784 4786
|
||||
]], "Alliance")
|
||||
|
||||
AddFaction([[
|
||||
224 259 443 444 445 446 447 448 449 450 451 452 453 454 468 469 593 603 615
|
||||
616 617 618 619 700 706 708 710 712 714 762 763 873 901 909 926 943 965 967
|
||||
968 971 1005 1006 1011 1025 1026 1027 1031 1032 1033 1036 1037 1039 1041
|
||||
1164 1168 1170 1173 1175 1203 1251 1252 1271 1272 1273 1274 1280 1356 1357
|
||||
1358 1359 1360 1502 1657 1677 1680 1682 1683 1685 1691 1693 1698 1783 1784
|
||||
2017 2145 2192 2195 2200 2420 2476 2497 2537 2765 2766 2767 2768 2769 2771
|
||||
2776 2783 2784 2785 2786 2787 2788 2798 2816 3357 3557 3577 3581 3597 3656
|
||||
3677 3778 3957 4079 4176 4177 4256 4297 4437 4785 4790
|
||||
]], "Horde")
|
||||
|
||||
local NativeGetAchievementInfo = GetAchievementInfo
|
||||
local NativeGetCategoryNumAchievements = GetCategoryNumAchievements
|
||||
local NativeGetLatestCompletedAchievements = GetLatestCompletedAchievements
|
||||
local NativeGetTrackedAchievements = GetTrackedAchievements
|
||||
local NativeGetNumCompletedAchievements = GetNumCompletedAchievements
|
||||
local NativeGetTotalAchievementPoints = GetTotalAchievementPoints
|
||||
|
||||
local categoryCache = {}
|
||||
local summaryCache = {}
|
||||
|
||||
local function GetDisplayFaction()
|
||||
local faction = UnitFactionGroup("player")
|
||||
if MoonWell_IsTraitor then
|
||||
if faction == "Alliance" then
|
||||
return "Horde"
|
||||
elseif faction == "Horde" then
|
||||
return "Alliance"
|
||||
end
|
||||
end
|
||||
return faction
|
||||
end
|
||||
|
||||
local function IsAchievementVisible(id, faction)
|
||||
local requiredFaction = achievementFaction[id]
|
||||
return not requiredFaction or not faction or requiredFaction == faction
|
||||
end
|
||||
|
||||
local function BuildCategory(categoryID, showAll)
|
||||
local faction = GetDisplayFaction()
|
||||
local key = tostring(categoryID) .. ":" .. tostring(showAll and 1 or 0)
|
||||
.. ":" .. tostring(faction) .. ":" .. tostring(MoonWell_IsTraitor)
|
||||
local cached = categoryCache[key]
|
||||
if cached then
|
||||
return cached
|
||||
end
|
||||
|
||||
local nativeCount = NativeGetCategoryNumAchievements(categoryID, showAll)
|
||||
local indices = {}
|
||||
local completed = 0
|
||||
|
||||
for nativeIndex = 1, nativeCount do
|
||||
local id, _, _, isCompleted = NativeGetAchievementInfo(categoryID, nativeIndex)
|
||||
if id and IsAchievementVisible(id, faction) then
|
||||
indices[#indices + 1] = nativeIndex
|
||||
if isCompleted then
|
||||
completed = completed + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
cached = { indices = indices, completed = completed }
|
||||
categoryCache[key] = cached
|
||||
return cached
|
||||
end
|
||||
|
||||
function GetCategoryNumAchievements(categoryID, showAll)
|
||||
local category = BuildCategory(categoryID, showAll)
|
||||
return #category.indices, category.completed
|
||||
end
|
||||
|
||||
function GetAchievementInfo(idOrCategory, achievementIndex)
|
||||
if achievementIndex == nil then
|
||||
return NativeGetAchievementInfo(idOrCategory)
|
||||
end
|
||||
|
||||
local category = BuildCategory(idOrCategory, false)
|
||||
local nativeIndex = category.indices[achievementIndex]
|
||||
if not nativeIndex then
|
||||
return nil
|
||||
end
|
||||
return NativeGetAchievementInfo(idOrCategory, nativeIndex)
|
||||
end
|
||||
|
||||
local function BuildSummary()
|
||||
local faction = GetDisplayFaction()
|
||||
local key = tostring(faction) .. ":" .. tostring(MoonWell_IsTraitor)
|
||||
local cached = summaryCache[key]
|
||||
if cached then
|
||||
return cached
|
||||
end
|
||||
|
||||
local total, completed = NativeGetNumCompletedAchievements()
|
||||
local points = NativeGetTotalAchievementPoints()
|
||||
local statisticFlag = ACHIEVEMENT_FLAGS_STATISTIC or 0x00000001
|
||||
local hiddenFlag = ACHIEVEMENT_FLAGS_HIDDEN or 0x00000002
|
||||
|
||||
for id, requiredFaction in pairs(achievementFaction) do
|
||||
if faction and requiredFaction ~= faction then
|
||||
local _, _, achievementPoints, isCompleted, _, _, _, _, flags =
|
||||
NativeGetAchievementInfo(id)
|
||||
if flags and bit.band(flags, statisticFlag) == 0 then
|
||||
local isHidden = bit.band(flags, hiddenFlag) ~= 0
|
||||
if not isHidden or isCompleted then
|
||||
total = total - 1
|
||||
end
|
||||
if isCompleted then
|
||||
completed = completed - 1
|
||||
points = points - (achievementPoints or 0)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
cached = { total = total, completed = completed, points = points }
|
||||
summaryCache[key] = cached
|
||||
return cached
|
||||
end
|
||||
|
||||
function GetNumCompletedAchievements()
|
||||
local summary = BuildSummary()
|
||||
return summary.total, summary.completed
|
||||
end
|
||||
|
||||
function GetTotalAchievementPoints()
|
||||
return BuildSummary().points
|
||||
end
|
||||
|
||||
local function FilterAchievementIDs(getter)
|
||||
local faction = GetDisplayFaction()
|
||||
local source = { getter() }
|
||||
local filtered = {}
|
||||
for i = 1, #source do
|
||||
if IsAchievementVisible(source[i], faction) then
|
||||
filtered[#filtered + 1] = source[i]
|
||||
end
|
||||
end
|
||||
return unpack(filtered)
|
||||
end
|
||||
|
||||
if NativeGetLatestCompletedAchievements then
|
||||
function GetLatestCompletedAchievements()
|
||||
return FilterAchievementIDs(NativeGetLatestCompletedAchievements)
|
||||
end
|
||||
end
|
||||
|
||||
if NativeGetTrackedAchievements then
|
||||
function GetTrackedAchievements()
|
||||
return FilterAchievementIDs(NativeGetTrackedAchievements)
|
||||
end
|
||||
end
|
||||
|
||||
function MoonWellAchievementFaction_Refresh()
|
||||
categoryCache = {}
|
||||
summaryCache = {}
|
||||
if AchievementFrame and AchievementFrame:IsShown() then
|
||||
if AchievementFrameCategories_Update then
|
||||
AchievementFrameCategories_Update()
|
||||
end
|
||||
if AchievementFrameAchievements_ForceUpdate then
|
||||
AchievementFrameAchievements_ForceUpdate()
|
||||
elseif AchievementFrameAchievements_Update then
|
||||
AchievementFrameAchievements_Update()
|
||||
end
|
||||
if AchievementFrameSummary_Update then
|
||||
AchievementFrameSummary_Update()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local events = CreateFrame("Frame")
|
||||
events:RegisterEvent("ACHIEVEMENT_EARNED")
|
||||
events:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
events:RegisterEvent("UNIT_FACTION")
|
||||
events:SetScript("OnEvent", function(self, event, unit)
|
||||
if event ~= "UNIT_FACTION" or unit == "player" then
|
||||
MoonWellAchievementFaction_Refresh()
|
||||
end
|
||||
end)
|
||||
+93
-2
@@ -353,6 +353,80 @@ EnsureColorMethods(HIGHLIGHT_FONT_COLOR)
|
||||
EnsureColorMethods(RED_FONT_COLOR)
|
||||
EnsureColorMethods(GREEN_FONT_COLOR)
|
||||
EnsureColorMethods(DISABLED_FONT_COLOR)
|
||||
|
||||
-- The imported Encounter Journal UI uses tooltip helpers introduced after
|
||||
-- 3.3.5. Keep their retail signatures, but implement them with the tooltip
|
||||
-- methods available in the 3.3.5 client.
|
||||
local function TooltipColorRGB(color, fallbackR, fallbackG, fallbackB)
|
||||
if type(color) == "table" then
|
||||
if color.GetRGB then
|
||||
return color:GetRGB()
|
||||
end
|
||||
|
||||
return color.r or fallbackR, color.g or fallbackG, color.b or fallbackB
|
||||
end
|
||||
|
||||
return fallbackR, fallbackG, fallbackB
|
||||
end
|
||||
|
||||
if not GameTooltip_AddColoredLine then
|
||||
function GameTooltip_AddColoredLine(tooltip, text, color, wrap)
|
||||
if not tooltip or not tooltip.AddLine then
|
||||
return
|
||||
end
|
||||
|
||||
local r, g, b = TooltipColorRGB(color, 1, 1, 1)
|
||||
tooltip:AddLine(text or "", r, g, b, wrap and true or false)
|
||||
end
|
||||
end
|
||||
|
||||
if not GameTooltip_AddNormalLine then
|
||||
function GameTooltip_AddNormalLine(tooltip, text, wrap)
|
||||
GameTooltip_AddColoredLine(tooltip, text, NORMAL_FONT_COLOR, wrap)
|
||||
end
|
||||
end
|
||||
|
||||
if not GameTooltip_AddHighlightLine then
|
||||
function GameTooltip_AddHighlightLine(tooltip, text, wrap)
|
||||
GameTooltip_AddColoredLine(tooltip, text, HIGHLIGHT_FONT_COLOR, wrap)
|
||||
end
|
||||
end
|
||||
|
||||
if not GameTooltip_AddBlankLineToTooltip then
|
||||
function GameTooltip_AddBlankLineToTooltip(tooltip)
|
||||
if tooltip and tooltip.AddLine then
|
||||
tooltip:AddLine(" ")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not GameTooltip_SetTitle then
|
||||
function GameTooltip_SetTitle(tooltip, text, color)
|
||||
if not tooltip then
|
||||
return
|
||||
end
|
||||
|
||||
local r, g, b = TooltipColorRGB(color or NORMAL_FONT_COLOR, 1, 0.82, 0)
|
||||
if tooltip.SetText then
|
||||
tooltip:SetText(text or "", r, g, b)
|
||||
elseif tooltip.AddLine then
|
||||
tooltip:AddLine(text or "", r, g, b)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not GameTooltip_ShowDisabledTooltip then
|
||||
function GameTooltip_ShowDisabledTooltip(tooltip, owner, text, anchor, offsetX, offsetY)
|
||||
if not tooltip or not owner then
|
||||
return
|
||||
end
|
||||
|
||||
tooltip:SetOwner(owner, anchor or "ANCHOR_RIGHT", offsetX or 0, offsetY or 0)
|
||||
GameTooltip_AddNormalLine(tooltip, text, true)
|
||||
tooltip:Show()
|
||||
end
|
||||
end
|
||||
|
||||
LOOTJOURNAL_SOURCE_TOOLTIP_HEAD = LOOTJOURNAL_SOURCE_TOOLTIP_HEAD or "Способ получения:"
|
||||
RETURN_TO_DEFAULT = RETURN_TO_DEFAULT or "Настройки по умолчанию"
|
||||
GUIDEBOOK = GUIDEBOOK or "Путеводитель"
|
||||
@@ -557,6 +631,23 @@ local function EnsureItemsCache()
|
||||
end
|
||||
|
||||
local itemsCache = type(_G.ItemsCache) == "table" and _G.ItemsCache or nil
|
||||
local compactItemsCache = _G.MoonWellEncounterJournalItemCache
|
||||
if type(compactItemsCache) == "table" then
|
||||
itemsCache = itemsCache or {}
|
||||
for itemID, compactData in pairs(compactItemsCache) do
|
||||
if type(itemID) == "number" and type(compactData) == "table" and not itemsCache[itemID] then
|
||||
itemsCache[itemID] = {
|
||||
[ITEM_CACHE_FIELD.NAME_ENGB] = compactData[1],
|
||||
[ITEM_CACHE_FIELD.NAME_RURU] = compactData[2],
|
||||
[ITEM_CACHE_FIELD.RARITY] = compactData[3],
|
||||
[ITEM_CACHE_FIELD.TEXTURE] = compactData[4],
|
||||
}
|
||||
end
|
||||
end
|
||||
_G.ItemsCache = itemsCache
|
||||
_G.MoonWellEncounterJournalItemCache = nil
|
||||
end
|
||||
|
||||
local fileIndex = 1
|
||||
local itemCacheTable = _G["ItemsCache" .. fileIndex]
|
||||
|
||||
@@ -711,8 +802,8 @@ local function EnsureItemCacheFrame()
|
||||
end
|
||||
|
||||
C_Item.GetItemInfo = function(item, skipClientCache, callback, ...)
|
||||
local name, link, quality, itemLevel, reqLevel, itemType, itemSubType, maxStack, equipSlot, icon, vendorPrice
|
||||
if ExistingCItemGetItemInfo then
|
||||
local name, link, quality, itemLevel, reqLevel, itemType, itemSubType, maxStack, equipSlot, icon, vendorPrice = C_Item.GetItemInfoCache(item)
|
||||
if not name and ExistingCItemGetItemInfo then
|
||||
name, link, quality, itemLevel, reqLevel, itemType, itemSubType, maxStack, equipSlot, icon, vendorPrice = ExistingCItemGetItemInfo(item, skipClientCache, nil, ...)
|
||||
end
|
||||
if not name then
|
||||
|
||||
+81
@@ -2,11 +2,92 @@
|
||||
-- The static fallback is loaded from Generated_EncounterJournal.lua, so the journal works
|
||||
-- without ever calling ApplyEncounterJournalData.
|
||||
|
||||
-- These entries belong to the Sirus-specific content pack that was mixed into
|
||||
-- the production export. They are not MoonWell content and must never reach
|
||||
-- the client, including when the journal is refreshed through AIO.
|
||||
local BLOCKED_INSTANCE_IDS = {
|
||||
[773] = true, -- Bronze Sanctum
|
||||
[774] = true, -- Tol'Garod Prison
|
||||
[776] = true, -- Sirus categories
|
||||
[777] = true, -- Sirus world bosses (Norigorn)
|
||||
}
|
||||
|
||||
local function RemoveBlockedInstances(data)
|
||||
local instances = data.JOURNALINSTANCE or data.instances
|
||||
local encounters = data.JOURNALENCOUNTER or data.encounters
|
||||
local creatures = data.JOURNALENCOUNTERCREATURE or data.creatures
|
||||
local items = data.JOURNALENCOUNTERITEM or data.items
|
||||
local sections = data.JOURNALENCOUNTERSECTION or data.sections
|
||||
local tierInstances = data.JOURNALTIERXINSTANCE or data.tierInstances
|
||||
local blockedEncounterIDs = {}
|
||||
|
||||
if type(encounters) == "table" then
|
||||
for instanceID in pairs(BLOCKED_INSTANCE_IDS) do
|
||||
local encounterList = encounters[instanceID]
|
||||
if type(encounterList) == "table" then
|
||||
for _, encounterInfo in ipairs(encounterList) do
|
||||
if type(encounterInfo) == "table" and type(encounterInfo[1]) == "number" then
|
||||
blockedEncounterIDs[encounterInfo[1]] = true
|
||||
end
|
||||
end
|
||||
end
|
||||
encounters[instanceID] = nil
|
||||
end
|
||||
end
|
||||
|
||||
if type(instances) == "table" then
|
||||
for instanceID in pairs(BLOCKED_INSTANCE_IDS) do
|
||||
instances[instanceID] = nil
|
||||
end
|
||||
end
|
||||
|
||||
for encounterID in pairs(blockedEncounterIDs) do
|
||||
if type(creatures) == "table" then
|
||||
creatures[encounterID] = nil
|
||||
end
|
||||
if type(items) == "table" then
|
||||
items[encounterID] = nil
|
||||
end
|
||||
end
|
||||
|
||||
if type(sections) == "table" then
|
||||
for sectionID, sectionInfo in pairs(sections) do
|
||||
if type(sectionInfo) == "table" and blockedEncounterIDs[sectionInfo[7]] then
|
||||
sections[sectionID] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if type(tierInstances) == "table" then
|
||||
for instanceID in pairs(BLOCKED_INSTANCE_IDS) do
|
||||
tierInstances[instanceID] = nil
|
||||
end
|
||||
|
||||
for _, instanceList in pairs(tierInstances) do
|
||||
if type(instanceList) == "table" then
|
||||
local writeIndex = 1
|
||||
for readIndex = 1, #instanceList do
|
||||
local instanceID = instanceList[readIndex]
|
||||
if not BLOCKED_INSTANCE_IDS[instanceID] then
|
||||
instanceList[writeIndex] = instanceID
|
||||
writeIndex = writeIndex + 1
|
||||
end
|
||||
end
|
||||
for index = writeIndex, #instanceList do
|
||||
instanceList[index] = nil
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function ApplyEncounterJournalData(data)
|
||||
if type(data) ~= "table" then
|
||||
return
|
||||
end
|
||||
|
||||
RemoveBlockedInstances(data)
|
||||
|
||||
local function NormalizeTierInstances(tiers, tierInstances)
|
||||
if type(tiers) ~= "table" or type(tierInstances) ~= "table" then
|
||||
return tierInstances
|
||||
|
||||
+10104
File diff suppressed because it is too large
Load Diff
+8390
-7
File diff suppressed because it is too large
Load Diff
@@ -1,62 +1,85 @@
|
||||
-- ============================================================================
|
||||
-- MoonWell Client
|
||||
-- Server integration: game modes, traitor faction display
|
||||
-- ============================================================================
|
||||
-- MoonWell client game-mode integration.
|
||||
-- WarcraftXL retains the selected character's server-provided charFlags while
|
||||
-- WoW replaces the GlueXML Lua state with the in-world FrameXML state.
|
||||
|
||||
-- Global flag read by FrameXML files to decide which faction icon to show.
|
||||
-- true = show opposite faction icon (traitor)
|
||||
-- false = show real faction icon (normal)
|
||||
MoonWell_IsTraitor = false
|
||||
|
||||
-- ============================================================================
|
||||
-- AIO handler — called by server on login
|
||||
-- Server: AIO:Handle(player, "MoonWellClient", "Init", gameMode)
|
||||
-- gameMode: 0 = normal, 1 = traitor
|
||||
-- ============================================================================
|
||||
|
||||
if not AIO then
|
||||
return
|
||||
end
|
||||
|
||||
local Handler = AIO.AddHandlers("MoonWellClient", {})
|
||||
|
||||
local function MoonWell_RefreshFactionIcons()
|
||||
-- Portrait PvP icon
|
||||
local function RefreshFactionIcons()
|
||||
if PlayerFrame_UpdatePvPStatus then
|
||||
PlayerFrame_UpdatePvPStatus()
|
||||
end
|
||||
-- DragonUI PvP micro button: SetupPVPButton() runs on load before AIO responds,
|
||||
-- so we refresh its DragonUIPVPIcon texture directly after Init.
|
||||
|
||||
if PVPFrame and PVPFrame:IsShown() and PVPFrame_Update then
|
||||
PVPFrame_Update()
|
||||
end
|
||||
if BattlefieldFrame and BattlefieldFrame:IsShown() and BattlefieldFrame_Update then
|
||||
BattlefieldFrame_Update()
|
||||
end
|
||||
|
||||
if PVPMicroButton and PVPMicroButton.DragonUIPVPIcon then
|
||||
local factionGroup = UnitFactionGroup("player")
|
||||
if factionGroup then
|
||||
local displayFaction = MoonWell_IsTraitor
|
||||
and (factionGroup == "Alliance" and "Horde" or "Alliance")
|
||||
or factionGroup
|
||||
local microTexture = "Interface\\AddOns\\DragonUI\\Textures\\Micromenu\\micropvp"
|
||||
local texture = "Interface\\AddOns\\DragonUI\\Textures\\Micromenu\\micropvp"
|
||||
local icon = PVPMicroButton.DragonUIPVPIcon
|
||||
local highlight = PVPMicroButton:GetHighlightTexture()
|
||||
|
||||
if displayFaction == "Alliance" then
|
||||
icon:SetTexture(microTexture)
|
||||
icon:SetTexture(texture)
|
||||
icon:SetTexCoord(0, 118/256, 0, 151/256)
|
||||
if highlight then
|
||||
highlight:SetTexture(texture)
|
||||
highlight:SetTexCoord(0, 118/256, 0, 151/256)
|
||||
end
|
||||
elseif displayFaction == "Horde" then
|
||||
icon:SetTexture(microTexture)
|
||||
icon:SetTexture(texture)
|
||||
icon:SetTexCoord(118/256, 236/256, 0, 151/256)
|
||||
end
|
||||
local hl = PVPMicroButton:GetHighlightTexture()
|
||||
if hl then
|
||||
if displayFaction == "Alliance" then
|
||||
hl:SetTexture(microTexture)
|
||||
hl:SetTexCoord(0, 118/256, 0, 151/256)
|
||||
elseif displayFaction == "Horde" then
|
||||
hl:SetTexture(microTexture)
|
||||
hl:SetTexCoord(118/256, 236/256, 0, 151/256)
|
||||
if highlight then
|
||||
highlight:SetTexture(texture)
|
||||
highlight:SetTexCoord(118/256, 236/256, 0, 151/256)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
function Handler.Init(_, mode)
|
||||
MoonWell_IsTraitor = (tonumber(mode) == 1)
|
||||
MoonWell_RefreshFactionIcons()
|
||||
local function ReadNativeGameMode()
|
||||
if type(MoonWellIsTraitor) ~= "function" then
|
||||
return false
|
||||
end
|
||||
|
||||
local wasTraitor = MoonWell_IsTraitor
|
||||
MoonWell_IsTraitor = MoonWellIsTraitor() and true or false
|
||||
if wasTraitor ~= MoonWell_IsTraitor and MoonWellAchievementFaction_Refresh then
|
||||
MoonWellAchievementFaction_Refresh()
|
||||
end
|
||||
RefreshFactionIcons()
|
||||
return true
|
||||
end
|
||||
|
||||
local events = CreateFrame("Frame")
|
||||
events:RegisterEvent("PLAYER_LOGIN")
|
||||
events:RegisterEvent("PLAYER_ENTERING_WORLD")
|
||||
events:RegisterEvent("UNIT_FACTION")
|
||||
events:SetScript("OnEvent", function(self, event, unit)
|
||||
if event ~= "UNIT_FACTION" or unit == "player" then
|
||||
ReadNativeGameMode()
|
||||
end
|
||||
end)
|
||||
|
||||
-- WarcraftXL may publish its Lua globals shortly after addons are loaded.
|
||||
-- Lua 5.1/WotLK has no C_Timer, so retry cheaply until the native API appears.
|
||||
local elapsedTotal = 0
|
||||
events:SetScript("OnUpdate", function(self, elapsed)
|
||||
elapsedTotal = elapsedTotal + elapsed
|
||||
if elapsedTotal < 0.25 then
|
||||
return
|
||||
end
|
||||
elapsedTotal = 0
|
||||
|
||||
if ReadNativeGameMode() then
|
||||
self:SetScript("OnUpdate", nil)
|
||||
end
|
||||
end)
|
||||
|
||||
@@ -1,14 +1,16 @@
|
||||
## Interface: 30300
|
||||
## Title: MoonWell Client
|
||||
## Notes: MoonWell server integration — game modes, traitor faction display
|
||||
## Version: 1.0.0
|
||||
## Notes: Native WarcraftXL game-mode and traitor faction display
|
||||
## Version: 2.0.0
|
||||
## Author: MoonWell
|
||||
## SavedVariables: MoonWellClientDB
|
||||
## Dependencies: AIO_Client
|
||||
## DefaultState: Enabled
|
||||
## X-Hidden: 1
|
||||
|
||||
AchievementFactions.lua
|
||||
MoonWellClient.lua
|
||||
SoloLFGCompat.lua
|
||||
EncounterJournal\EncounterJournalItemCache.lua
|
||||
EncounterJournal\EncounterJournalCompat.lua
|
||||
EncounterJournal\SharedXML\SoundKitConstants.lua
|
||||
EncounterJournal\Generated_EncounterJournalData.lua
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
local SOLO_RANDOM_DUNGEONS = {
|
||||
[1258] = true,
|
||||
[1259] = true,
|
||||
[1260] = true,
|
||||
[1261] = true,
|
||||
[1262] = true,
|
||||
}
|
||||
|
||||
local GetStockTexCoordsForRole = GetTexCoordsForRole
|
||||
|
||||
function GetTexCoordsForRole(role)
|
||||
if role == "UNKNOWN" then
|
||||
local proposalExists, _, dungeonID = GetLFGProposal()
|
||||
if proposalExists and SOLO_RANDOM_DUNGEONS[dungeonID] then
|
||||
local _, tank, healer = GetLFGRoles()
|
||||
if tank then
|
||||
role = "TANK"
|
||||
elseif healer then
|
||||
role = "HEALER"
|
||||
else
|
||||
role = "DAMAGER"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
return GetStockTexCoordsForRole(role)
|
||||
end
|
||||
Generated
+3
@@ -915,6 +915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1059,6 +1060,8 @@ dependencies = [
|
||||
name = "tool"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"walkdir",
|
||||
"wow-mpq",
|
||||
]
|
||||
|
||||
@@ -6,3 +6,5 @@ edition = "2024"
|
||||
[dependencies]
|
||||
walkdir = "2.5.0"
|
||||
wow-mpq = "0.6.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MODEL_FIELDS: u32 = 28;
|
||||
const MODEL_RECORD_SIZE: u32 = 112;
|
||||
const DISPLAY_FIELDS: u32 = 16;
|
||||
const DISPLAY_RECORD_SIZE: u32 = 64;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Registry {
|
||||
pub base_dbc_dir: String,
|
||||
#[serde(default = "default_model_id")]
|
||||
pub model_data_id_start: u32,
|
||||
#[serde(default = "default_display_id")]
|
||||
pub display_info_id_start: u32,
|
||||
#[serde(default = "default_creature_entry")]
|
||||
pub creature_entry_id_start: u32,
|
||||
#[serde(default = "default_summon_spell_id")]
|
||||
pub summon_spell_id_start: u32,
|
||||
#[serde(default = "default_skill_line_ability_id")]
|
||||
pub skill_line_ability_id_start: u32,
|
||||
pub defaults: Defaults,
|
||||
#[serde(default)]
|
||||
pub mounts: Vec<EntrySpec>,
|
||||
#[serde(default)]
|
||||
pub pets: Vec<EntrySpec>,
|
||||
}
|
||||
|
||||
fn default_model_id() -> u32 {
|
||||
50_000
|
||||
}
|
||||
fn default_display_id() -> u32 {
|
||||
50_000
|
||||
}
|
||||
fn default_creature_entry() -> u32 {
|
||||
5_000_000
|
||||
}
|
||||
fn default_summon_spell_id() -> u32 {
|
||||
110_000
|
||||
}
|
||||
fn default_skill_line_ability_id() -> u32 {
|
||||
30_000
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Defaults {
|
||||
pub mount_template_display_id: u32,
|
||||
pub pet_template_display_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum EntrySpec {
|
||||
Folder(String),
|
||||
Detailed(EntryOptions),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct EntryOptions {
|
||||
pub folder: String,
|
||||
pub key: Option<String>,
|
||||
pub archive: Option<String>,
|
||||
pub model_file: Option<String>,
|
||||
pub template_display_id: Option<u32>,
|
||||
pub display_scale: Option<f32>,
|
||||
pub model_scale: Option<f32>,
|
||||
pub collision_width: Option<f32>,
|
||||
pub collision_height: Option<f32>,
|
||||
pub mount_height: Option<f32>,
|
||||
pub flags: Option<u32>,
|
||||
pub name: Option<String>,
|
||||
pub name_ru: Option<String>,
|
||||
}
|
||||
|
||||
impl EntrySpec {
|
||||
fn options(&self) -> EntryOptions {
|
||||
match self {
|
||||
EntrySpec::Folder(folder) => EntryOptions {
|
||||
folder: folder.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
EntrySpec::Detailed(options) => EntryOptions {
|
||||
folder: options.folder.clone(),
|
||||
key: options.key.clone(),
|
||||
archive: options.archive.clone(),
|
||||
model_file: options.model_file.clone(),
|
||||
template_display_id: options.template_display_id,
|
||||
display_scale: options.display_scale,
|
||||
model_scale: options.model_scale,
|
||||
collision_width: options.collision_width,
|
||||
collision_height: options.collision_height,
|
||||
mount_height: options.mount_height,
|
||||
flags: options.flags,
|
||||
name: options.name.clone(),
|
||||
name_ru: options.name_ru.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
struct LockFile {
|
||||
#[serde(default)]
|
||||
models: BTreeMap<String, LockedIds>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct LockedIds {
|
||||
model_data_id: u32,
|
||||
display_info_id: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
creature_entry: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
summon_spell_id: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
skill_line_ability_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedEntry {
|
||||
key: String,
|
||||
folder: PathBuf,
|
||||
archive: String,
|
||||
model_file: String,
|
||||
file_data: Vec<(u32, String)>,
|
||||
assets: Vec<(PathBuf, String)>,
|
||||
template_display_id: u32,
|
||||
ids: LockedIds,
|
||||
options: EntryOptions,
|
||||
summon: bool,
|
||||
name: String,
|
||||
name_ru: String,
|
||||
}
|
||||
|
||||
pub struct GeneratedCreatures {
|
||||
pub assets: Vec<(PathBuf, String)>,
|
||||
pub file_data_csv: Vec<u8>,
|
||||
pub creature_model_data: Vec<u8>,
|
||||
pub creature_display_info: Vec<u8>,
|
||||
pub spell_overrides: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Wdbc {
|
||||
field_count: u32,
|
||||
record_size: u32,
|
||||
records: Vec<Vec<u8>>,
|
||||
strings: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Wdbc {
|
||||
fn read(path: &Path, fields: u32, record_size: u32) -> Result<Self, Box<dyn Error>> {
|
||||
let bytes = fs::read(path)?;
|
||||
if bytes.len() < 20 || &bytes[0..4] != b"WDBC" {
|
||||
return Err(format!("{} is not a WDBC file", path.display()).into());
|
||||
}
|
||||
let count = read_u32(&bytes, 4)? as usize;
|
||||
let actual_fields = read_u32(&bytes, 8)?;
|
||||
let actual_size = read_u32(&bytes, 12)?;
|
||||
let string_size = read_u32(&bytes, 16)? as usize;
|
||||
if actual_fields != fields || actual_size != record_size {
|
||||
return Err(format!(
|
||||
"{} has unexpected layout: fields={}, record_size={}",
|
||||
path.display(),
|
||||
actual_fields,
|
||||
actual_size
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let records_end = 20usize
|
||||
.checked_add(
|
||||
count
|
||||
.checked_mul(record_size as usize)
|
||||
.ok_or("DBC size overflow")?,
|
||||
)
|
||||
.ok_or("DBC size overflow")?;
|
||||
if records_end
|
||||
.checked_add(string_size)
|
||||
.ok_or("DBC size overflow")?
|
||||
!= bytes.len()
|
||||
{
|
||||
return Err(format!("{} has an invalid header size", path.display()).into());
|
||||
}
|
||||
let mut records = Vec::with_capacity(count + 16);
|
||||
for record in bytes[20..records_end].chunks_exact(record_size as usize) {
|
||||
records.push(record.to_vec());
|
||||
}
|
||||
Ok(Self {
|
||||
field_count: fields,
|
||||
record_size,
|
||||
records,
|
||||
strings: bytes[records_end..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn record(&self, id: u32) -> Option<&Vec<u8>> {
|
||||
self.records
|
||||
.iter()
|
||||
.find(|row| read_u32(row, 0).ok() == Some(id))
|
||||
}
|
||||
|
||||
fn add_string(&mut self, value: &str) -> u32 {
|
||||
if value.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let offset = self.strings.len() as u32;
|
||||
self.strings.extend_from_slice(value.as_bytes());
|
||||
self.strings.push(0);
|
||||
offset
|
||||
}
|
||||
|
||||
fn build(mut self) -> Vec<u8> {
|
||||
self.records
|
||||
.sort_by_key(|row| read_u32(row, 0).unwrap_or(0));
|
||||
let mut bytes = Vec::with_capacity(
|
||||
20 + self.records.len() * self.record_size as usize + self.strings.len(),
|
||||
);
|
||||
bytes.extend_from_slice(b"WDBC");
|
||||
bytes.extend_from_slice(&(self.records.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&self.field_count.to_le_bytes());
|
||||
bytes.extend_from_slice(&self.record_size.to_le_bytes());
|
||||
bytes.extend_from_slice(&(self.strings.len() as u32).to_le_bytes());
|
||||
for row in self.records {
|
||||
bytes.extend_from_slice(&row);
|
||||
}
|
||||
bytes.extend_from_slice(&self.strings);
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, Box<dyn Error>> {
|
||||
let value = bytes
|
||||
.get(offset..offset + 4)
|
||||
.ok_or("record field is out of bounds")?;
|
||||
Ok(u32::from_le_bytes(value.try_into()?))
|
||||
}
|
||||
|
||||
fn write_u32(bytes: &mut [u8], field: usize, value: u32) -> Result<(), Box<dyn Error>> {
|
||||
let offset = field.checked_mul(4).ok_or("record field overflow")?;
|
||||
bytes
|
||||
.get_mut(offset..offset + 4)
|
||||
.ok_or("record field is out of bounds")?
|
||||
.copy_from_slice(&value.to_le_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_f32(bytes: &mut [u8], field: usize, value: f32) -> Result<(), Box<dyn Error>> {
|
||||
write_u32(bytes, field, value.to_bits())
|
||||
}
|
||||
|
||||
fn normalized_archive(value: &str) -> String {
|
||||
value.trim_matches(['/', '\\']).replace('\\', "/")
|
||||
}
|
||||
|
||||
fn resolve_path(project_root: &Path, value: &str) -> PathBuf {
|
||||
let path = PathBuf::from(value);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
project_root.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_key(folder: &Path) -> Result<String, Box<dyn Error>> {
|
||||
Ok(folder
|
||||
.file_name()
|
||||
.ok_or("creature folder has no name")?
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn default_archive(folder: &Path) -> Result<String, Box<dyn Error>> {
|
||||
Ok(format!(
|
||||
"Creature/{}",
|
||||
folder
|
||||
.file_name()
|
||||
.ok_or("creature folder has no name")?
|
||||
.to_string_lossy()
|
||||
))
|
||||
}
|
||||
|
||||
fn find_manifest(folder: &Path) -> Result<PathBuf, Box<dyn Error>> {
|
||||
let manifests: Vec<PathBuf> = fs::read_dir(folder)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().ends_with(".manifest.json"))
|
||||
})
|
||||
.collect();
|
||||
match manifests.as_slice() {
|
||||
[manifest] => Ok(manifest.clone()),
|
||||
[] => Err(format!("no *.manifest.json in {}", folder.display()).into()),
|
||||
_ => Err(format!("more than one *.manifest.json in {}", folder.display()).into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_model(folder: &Path, explicit: Option<&str>) -> Result<String, Box<dyn Error>> {
|
||||
if let Some(model) = explicit {
|
||||
return Ok(model.to_string());
|
||||
}
|
||||
let models: Vec<String> = fs::read_dir(folder)?
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("m2"))
|
||||
})
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
match models.as_slice() {
|
||||
[model] => Ok(model.clone()),
|
||||
[] => Err(format!("no .m2 file in {}", folder.display()).into()),
|
||||
_ => Err(format!(
|
||||
"more than one .m2 file in {}; set model_file",
|
||||
folder.display()
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_manifest_files(value: &Value, out: &mut Vec<(u32, String)>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let (Some(id), Some(file)) = (
|
||||
object.get("fileDataID").and_then(Value::as_u64),
|
||||
object.get("file").and_then(Value::as_str),
|
||||
) && id <= u32::MAX as u64
|
||||
{
|
||||
out.push((id as u32, file.to_string()));
|
||||
}
|
||||
for child in object.values() {
|
||||
collect_manifest_files(child, out);
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for child in array {
|
||||
collect_manifest_files(child, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_free(start: u32, used: &HashSet<u32>) -> u32 {
|
||||
let mut value = start;
|
||||
while used.contains(&value) {
|
||||
value = value.checked_add(1).expect("custom ID range exhausted");
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn allocate_ids(lock: &mut LockFile, key: &str, registry: &Registry, summon: bool) -> LockedIds {
|
||||
let used_models: HashSet<u32> = lock.models.values().map(|ids| ids.model_data_id).collect();
|
||||
let used_displays: HashSet<u32> = lock
|
||||
.models
|
||||
.values()
|
||||
.map(|ids| ids.display_info_id)
|
||||
.collect();
|
||||
let used_creatures: HashSet<u32> = lock
|
||||
.models
|
||||
.values()
|
||||
.filter_map(|ids| ids.creature_entry)
|
||||
.collect();
|
||||
let used_spells: HashSet<u32> = lock
|
||||
.models
|
||||
.values()
|
||||
.filter_map(|ids| ids.summon_spell_id)
|
||||
.collect();
|
||||
let used_skill_rows: HashSet<u32> = lock
|
||||
.models
|
||||
.values()
|
||||
.filter_map(|ids| ids.skill_line_ability_id)
|
||||
.collect();
|
||||
let mut ids = lock.models.get(key).cloned().unwrap_or(LockedIds {
|
||||
model_data_id: next_free(registry.model_data_id_start, &used_models),
|
||||
display_info_id: next_free(registry.display_info_id_start, &used_displays),
|
||||
creature_entry: None,
|
||||
summon_spell_id: None,
|
||||
skill_line_ability_id: None,
|
||||
});
|
||||
if summon {
|
||||
if ids.creature_entry.is_none() {
|
||||
ids.creature_entry = Some(next_free(registry.creature_entry_id_start, &used_creatures));
|
||||
}
|
||||
if ids.summon_spell_id.is_none() {
|
||||
ids.summon_spell_id = Some(next_free(registry.summon_spell_id_start, &used_spells));
|
||||
}
|
||||
if ids.skill_line_ability_id.is_none() {
|
||||
ids.skill_line_ability_id = Some(next_free(
|
||||
registry.skill_line_ability_id_start,
|
||||
&used_skill_rows,
|
||||
));
|
||||
}
|
||||
}
|
||||
lock.models.insert(key.to_string(), ids.clone());
|
||||
ids
|
||||
}
|
||||
|
||||
fn validate_text(value: &str, field: &str, key: &str) -> Result<(), Box<dyn Error>> {
|
||||
if value.contains(['\t', '\r', '\n']) {
|
||||
return Err(format!("{} for '{}' contains a tab or newline", field, key).into());
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_entry(
|
||||
project_root: &Path,
|
||||
spec: &EntrySpec,
|
||||
template_default: u32,
|
||||
summon: bool,
|
||||
lock: &mut LockFile,
|
||||
registry: &Registry,
|
||||
) -> Result<PreparedEntry, Box<dyn Error>> {
|
||||
let options = spec.options();
|
||||
let folder = resolve_path(project_root, &options.folder).canonicalize()?;
|
||||
if !folder.is_dir() {
|
||||
return Err(format!("creature folder not found: {}", folder.display()).into());
|
||||
}
|
||||
let key = options.key.clone().unwrap_or(default_key(&folder)?);
|
||||
let name = options.name.clone().unwrap_or_else(|| key.clone());
|
||||
let name_ru = options.name_ru.clone().unwrap_or_else(|| name.clone());
|
||||
validate_text(&name, "name", &key)?;
|
||||
validate_text(&name_ru, "name_ru", &key)?;
|
||||
let archive = normalized_archive(
|
||||
options
|
||||
.archive
|
||||
.as_deref()
|
||||
.unwrap_or(&default_archive(&folder)?),
|
||||
);
|
||||
let model_file = find_model(&folder, options.model_file.as_deref())?;
|
||||
let manifest_path = find_manifest(&folder)?;
|
||||
let manifest: Value = serde_json::from_slice(&fs::read(&manifest_path)?)?;
|
||||
let main_id = manifest
|
||||
.get("fileDataID")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| format!("{} has no root fileDataID", manifest_path.display()))?;
|
||||
if main_id > u32::MAX as u64 {
|
||||
return Err("model FileDataID is too large".into());
|
||||
}
|
||||
|
||||
let mut dependencies = Vec::new();
|
||||
collect_manifest_files(&manifest, &mut dependencies);
|
||||
let mut file_data = vec![(main_id as u32, format!("{archive}/{model_file}"))];
|
||||
let mut assets = vec![(folder.join(&model_file), format!("{archive}/{model_file}"))];
|
||||
for (file_data_id, file) in dependencies {
|
||||
let source = folder.join(&file);
|
||||
if !source.is_file() {
|
||||
return Err(format!("manifest dependency not found: {}", source.display()).into());
|
||||
}
|
||||
file_data.push((file_data_id, format!("{archive}/{file}")));
|
||||
assets.push((source, format!("{archive}/{file}")));
|
||||
}
|
||||
let ids = allocate_ids(lock, &key, registry, summon);
|
||||
Ok(PreparedEntry {
|
||||
key,
|
||||
folder,
|
||||
archive,
|
||||
model_file,
|
||||
file_data,
|
||||
assets,
|
||||
template_display_id: options.template_display_id.unwrap_or(template_default),
|
||||
ids,
|
||||
options,
|
||||
summon,
|
||||
name,
|
||||
name_ru,
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_dbc(
|
||||
base_dir: &Path,
|
||||
entries: &[PreparedEntry],
|
||||
) -> Result<(Vec<u8>, Vec<u8>), Box<dyn Error>> {
|
||||
let mut models = Wdbc::read(
|
||||
&base_dir.join("CreatureModelData.dbc"),
|
||||
MODEL_FIELDS,
|
||||
MODEL_RECORD_SIZE,
|
||||
)?;
|
||||
let mut displays = Wdbc::read(
|
||||
&base_dir.join("CreatureDisplayInfo.dbc"),
|
||||
DISPLAY_FIELDS,
|
||||
DISPLAY_RECORD_SIZE,
|
||||
)?;
|
||||
let base_models = models.clone();
|
||||
let base_displays = displays.clone();
|
||||
|
||||
for entry in entries {
|
||||
if base_models.record(entry.ids.model_data_id).is_some()
|
||||
|| base_displays.record(entry.ids.display_info_id).is_some()
|
||||
{
|
||||
return Err(format!("custom IDs for '{}' collide with base DBC", entry.key).into());
|
||||
}
|
||||
let template_display = base_displays
|
||||
.record(entry.template_display_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"template display {} not found for '{}'",
|
||||
entry.template_display_id, entry.key
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let template_model_id = read_u32(&template_display, 4)?;
|
||||
let mut model = base_models
|
||||
.record(template_model_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"model {} for template display {} not found",
|
||||
template_model_id, entry.template_display_id
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let mut display = template_display;
|
||||
|
||||
let model_stem = Path::new(&entry.model_file)
|
||||
.file_stem()
|
||||
.ok_or("model file has no stem")?
|
||||
.to_string_lossy();
|
||||
let dbc_model_path = format!("{}\\{}.mdx", entry.archive.replace('/', "\\"), model_stem);
|
||||
let string_offset = models.add_string(&dbc_model_path);
|
||||
write_u32(&mut model, 0, entry.ids.model_data_id)?;
|
||||
write_u32(&mut model, 2, string_offset)?;
|
||||
if let Some(value) = entry.options.flags {
|
||||
write_u32(&mut model, 1, value)?;
|
||||
}
|
||||
if let Some(value) = entry.options.model_scale {
|
||||
write_f32(&mut model, 4, value)?;
|
||||
}
|
||||
if let Some(value) = entry.options.collision_width {
|
||||
write_f32(&mut model, 14, value)?;
|
||||
}
|
||||
if let Some(value) = entry.options.collision_height {
|
||||
write_f32(&mut model, 15, value)?;
|
||||
}
|
||||
if let Some(value) = entry.options.mount_height {
|
||||
write_f32(&mut model, 16, value)?;
|
||||
}
|
||||
|
||||
write_u32(&mut display, 0, entry.ids.display_info_id)?;
|
||||
write_u32(&mut display, 1, entry.ids.model_data_id)?;
|
||||
if let Some(value) = entry.options.display_scale {
|
||||
write_f32(&mut display, 4, value)?;
|
||||
}
|
||||
// Modern models carry their textures through TXID/FileDataID. Template skin names must not override them.
|
||||
for field in 6..=9 {
|
||||
write_u32(&mut display, field, 0)?;
|
||||
}
|
||||
|
||||
models.records.push(model);
|
||||
displays.records.push(display);
|
||||
}
|
||||
Ok((models.build(), displays.build()))
|
||||
}
|
||||
|
||||
fn sql_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn generate_server_sql(entries: &[PreparedEntry]) -> Result<String, Box<dyn Error>> {
|
||||
let mut sql = String::from(
|
||||
"-- Generated by MoonWell custom creature builder.\n\
|
||||
-- Apply to acore_world, then restart ac-worldserver.\n\
|
||||
START TRANSACTION;\n\n",
|
||||
);
|
||||
for entry in entries.iter().filter(|entry| entry.summon) {
|
||||
let creature = entry
|
||||
.ids
|
||||
.creature_entry
|
||||
.ok_or("pet has no creature entry")?;
|
||||
let spell = entry
|
||||
.ids
|
||||
.summon_spell_id
|
||||
.ok_or("pet has no summon spell ID")?;
|
||||
let skill_row = entry
|
||||
.ids
|
||||
.skill_line_ability_id
|
||||
.ok_or("pet has no skill-line row ID")?;
|
||||
let name_en = sql_string(&entry.name);
|
||||
let name_ru = sql_string(&entry.name_ru);
|
||||
let description_en = sql_string(&format!(
|
||||
"Summons or dismisses your companion: {}.",
|
||||
entry.name
|
||||
));
|
||||
let description_ru = sql_string(&format!(
|
||||
"Призывает или отпускает спутника: {}.",
|
||||
entry.name_ru
|
||||
));
|
||||
|
||||
sql.push_str(&format!(
|
||||
"-- {}\n\
|
||||
INSERT INTO creature_template\n\
|
||||
(entry,name,minlevel,maxlevel,faction,speed_walk,speed_run,scale,unit_class,unit_flags,unit_flags2,type,MovementType,RegenHealth,VerifiedBuild)\n\
|
||||
VALUES ({creature},{name_en},1,1,188,1,1.14286,1,1,0,2048,12,0,1,12340)\n\
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name),faction=VALUES(faction),type=VALUES(type),unit_flags2=VALUES(unit_flags2);\n\
|
||||
INSERT INTO creature_model_info\n\
|
||||
(DisplayID,BoundingRadius,CombatReach,Gender,DisplayID_Other_Gender,VerifiedBuild)\n\
|
||||
VALUES ({},0.25,0.5,2,0,12340)\n\
|
||||
ON DUPLICATE KEY UPDATE BoundingRadius=VALUES(BoundingRadius),CombatReach=VALUES(CombatReach),Gender=VALUES(Gender),DisplayID_Other_Gender=VALUES(DisplayID_Other_Gender),VerifiedBuild=VALUES(VerifiedBuild);\n\
|
||||
INSERT INTO creature_template_model\n\
|
||||
(CreatureID,Idx,CreatureDisplayID,DisplayScale,Probability,VerifiedBuild)\n\
|
||||
VALUES ({creature},0,{},1,1,12340)\n\
|
||||
ON DUPLICATE KEY UPDATE CreatureDisplayID=VALUES(CreatureDisplayID),DisplayScale=VALUES(DisplayScale),Probability=VALUES(Probability);\n\n",
|
||||
entry.key, entry.ids.display_info_id, entry.ids.display_info_id
|
||||
));
|
||||
sql.push_str(&format!(
|
||||
"INSERT INTO spell_dbc\n\
|
||||
(ID,Attributes,AttributesEx3,CastingTimeIndex,InterruptFlags,ProcChance,DurationIndex,RangeIndex,EquippedItemClass,Effect_1,EffectDieSides_1,EffectBasePoints_1,ImplicitTargetA_1,EffectMultipleValue_1,EffectMiscValue_1,EffectMiscValueB_1,SpellVisualID_1,SpellIconID,Name_Lang_enUS,Name_Lang_ruRU,Description_Lang_enUS,Description_Lang_ruRU,StartRecoveryCategory,StartRecoveryTime,EffectChainAmplitude_1,EffectChainAmplitude_2,EffectChainAmplitude_3,SchoolMask)\n\
|
||||
VALUES ({spell},262416,536870912,1,31,101,21,1,-1,28,1,1,32,1000,{creature},41,353,2686,{name_en},{name_ru},{description_en},{description_ru},133,1500,1,1,1,1)\n\
|
||||
ON DUPLICATE KEY UPDATE EffectMiscValue_1=VALUES(EffectMiscValue_1),Name_Lang_enUS=VALUES(Name_Lang_enUS),Name_Lang_ruRU=VALUES(Name_Lang_ruRU),Description_Lang_enUS=VALUES(Description_Lang_enUS),Description_Lang_ruRU=VALUES(Description_Lang_ruRU);\n\
|
||||
INSERT INTO skilllineability_dbc\n\
|
||||
(ID,SkillLine,Spell,RaceMask,ClassMask,ExcludeRace,ExcludeClass,MinSkillLineRank,SupercededBySpell,AcquireMethod,TrivialSkillLineRankHigh,TrivialSkillLineRankLow,CharacterPoints_1,CharacterPoints_2)\n\
|
||||
VALUES ({skill_row},778,{spell},0,0,0,0,1,0,0,0,0,0,0)\n\
|
||||
ON DUPLICATE KEY UPDATE SkillLine=VALUES(SkillLine),Spell=VALUES(Spell),MinSkillLineRank=VALUES(MinSkillLineRank);\n\n"
|
||||
));
|
||||
}
|
||||
sql.push_str("COMMIT;\n");
|
||||
Ok(sql)
|
||||
}
|
||||
|
||||
pub fn prepare(project_root: &Path) -> Result<Option<GeneratedCreatures>, Box<dyn Error>> {
|
||||
let registry_path = project_root.join("custom-creatures.json");
|
||||
if !registry_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let registry: Registry = serde_json::from_slice(&fs::read(®istry_path)?)?;
|
||||
let lock_path = project_root.join("custom-creatures.lock.json");
|
||||
let mut lock: LockFile = if lock_path.is_file() {
|
||||
serde_json::from_slice(&fs::read(&lock_path)?)?
|
||||
} else {
|
||||
LockFile::default()
|
||||
};
|
||||
let original_lock = serde_json::to_vec(&lock)?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut keys = HashSet::new();
|
||||
for spec in ®istry.mounts {
|
||||
let entry = prepare_entry(
|
||||
project_root,
|
||||
spec,
|
||||
registry.defaults.mount_template_display_id,
|
||||
false,
|
||||
&mut lock,
|
||||
®istry,
|
||||
)?;
|
||||
if !keys.insert(entry.key.clone()) {
|
||||
return Err(format!("duplicate creature key '{}'", entry.key).into());
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
for spec in ®istry.pets {
|
||||
let entry = prepare_entry(
|
||||
project_root,
|
||||
spec,
|
||||
registry.defaults.pet_template_display_id,
|
||||
true,
|
||||
&mut lock,
|
||||
®istry,
|
||||
)?;
|
||||
if !keys.insert(entry.key.clone()) {
|
||||
return Err(format!("duplicate creature key '{}'", entry.key).into());
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
let updated_lock = serde_json::to_vec(&lock)?;
|
||||
if updated_lock != original_lock || !lock_path.exists() {
|
||||
let mut pretty = serde_json::to_vec_pretty(&lock)?;
|
||||
pretty.push(b'\n');
|
||||
fs::write(&lock_path, pretty)?;
|
||||
println!("Updated creature ID lock: {}", lock_path.display());
|
||||
}
|
||||
|
||||
let mut all_ids = BTreeMap::new();
|
||||
let mut active_model_ids = HashSet::new();
|
||||
let mut active_display_ids = HashSet::new();
|
||||
let mut active_creature_entries = HashSet::new();
|
||||
let mut active_spell_ids = HashSet::new();
|
||||
let mut active_skill_rows = HashSet::new();
|
||||
let mut assets = Vec::new();
|
||||
for entry in &entries {
|
||||
if !active_model_ids.insert(entry.ids.model_data_id) {
|
||||
return Err(format!(
|
||||
"duplicate modelDataID {} in creature lock",
|
||||
entry.ids.model_data_id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !active_display_ids.insert(entry.ids.display_info_id) {
|
||||
return Err(format!(
|
||||
"duplicate displayInfoID {} in creature lock",
|
||||
entry.ids.display_info_id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
for (value, used, label) in [
|
||||
(
|
||||
entry.ids.creature_entry,
|
||||
&mut active_creature_entries,
|
||||
"creature entry",
|
||||
),
|
||||
(
|
||||
entry.ids.summon_spell_id,
|
||||
&mut active_spell_ids,
|
||||
"summon spell ID",
|
||||
),
|
||||
(
|
||||
entry.ids.skill_line_ability_id,
|
||||
&mut active_skill_rows,
|
||||
"skill-line row ID",
|
||||
),
|
||||
] {
|
||||
if let Some(value) = value
|
||||
&& !used.insert(value)
|
||||
{
|
||||
return Err(format!("duplicate {label} {value} in creature lock").into());
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"Creature {}: modelDataID={}, displayInfoID={}, source={}",
|
||||
entry.key,
|
||||
entry.ids.model_data_id,
|
||||
entry.ids.display_info_id,
|
||||
entry.folder.display()
|
||||
);
|
||||
for (id, path) in &entry.file_data {
|
||||
if let Some(existing) = all_ids.get(id) {
|
||||
if existing != path {
|
||||
return Err(format!(
|
||||
"FileDataID {} maps to both '{}' and '{}'",
|
||||
id, existing, path
|
||||
)
|
||||
.into());
|
||||
}
|
||||
} else {
|
||||
all_ids.insert(*id, path.clone());
|
||||
}
|
||||
}
|
||||
assets.extend(entry.assets.clone());
|
||||
}
|
||||
let mut csv = String::from("# FileDataID,archive path\n");
|
||||
for (id, path) in all_ids {
|
||||
csv.push_str(&format!("{id},{}\n", path.replace('/', "\\")));
|
||||
}
|
||||
let mut spell_overrides =
|
||||
String::from("# spellId\ttemplateSpellId\tcreatureEntry\tskillLineAbilityId\tname\n");
|
||||
for entry in entries.iter().filter(|entry| entry.summon) {
|
||||
spell_overrides.push_str(&format!(
|
||||
"{}\t70613\t{}\t{}\t{}\n",
|
||||
entry
|
||||
.ids
|
||||
.summon_spell_id
|
||||
.ok_or("pet has no summon spell ID")?,
|
||||
entry
|
||||
.ids
|
||||
.creature_entry
|
||||
.ok_or("pet has no creature entry")?,
|
||||
entry
|
||||
.ids
|
||||
.skill_line_ability_id
|
||||
.ok_or("pet has no skill-line row ID")?,
|
||||
entry.name_ru
|
||||
));
|
||||
}
|
||||
|
||||
let base_dir = resolve_path(project_root, ®istry.base_dbc_dir);
|
||||
let (model_dbc, display_dbc) = generate_dbc(&base_dir, &entries)?;
|
||||
// Keep server-only artifacts outside the client package directory. Both run.ps1 and deploy.ps1
|
||||
// mirror the complete package directory into the client and launcher manifest.
|
||||
let server_dir = project_root.join("build").join("server-dbc");
|
||||
fs::create_dir_all(&server_dir)?;
|
||||
fs::write(server_dir.join("CreatureModelData.dbc"), &model_dbc)?;
|
||||
fs::write(server_dir.join("CreatureDisplayInfo.dbc"), &display_dbc)?;
|
||||
fs::write(
|
||||
server_dir.join("custom-creatures.sql"),
|
||||
generate_server_sql(&entries)?,
|
||||
)?;
|
||||
let mut id_map = String::from(
|
||||
"key,type,modelDataID,displayInfoID,creatureEntry,summonSpellID,skillLineAbilityID\n",
|
||||
);
|
||||
for (kind, specs) in [("mount", ®istry.mounts), ("pet", ®istry.pets)] {
|
||||
for spec in specs {
|
||||
let options = spec.options();
|
||||
let folder = resolve_path(project_root, &options.folder).canonicalize()?;
|
||||
let key = options.key.unwrap_or(default_key(&folder)?);
|
||||
let ids = lock.models.get(&key).ok_or("lock entry disappeared")?;
|
||||
id_map.push_str(&format!(
|
||||
"{key},{kind},{},{},{},{},{}\n",
|
||||
ids.model_data_id,
|
||||
ids.display_info_id,
|
||||
ids.creature_entry
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
ids.summon_spell_id
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
ids.skill_line_ability_id
|
||||
.map_or_else(String::new, |value| value.to_string())
|
||||
));
|
||||
}
|
||||
}
|
||||
fs::write(server_dir.join("custom-creatures.csv"), id_map)?;
|
||||
|
||||
Ok(Some(GeneratedCreatures {
|
||||
assets,
|
||||
file_data_csv: csv.into_bytes(),
|
||||
creature_model_data: model_dbc,
|
||||
creature_display_info: display_dbc,
|
||||
spell_overrides: spell_overrides.into_bytes(),
|
||||
}))
|
||||
}
|
||||
+78
-3
@@ -7,6 +7,9 @@ use walkdir::WalkDir;
|
||||
use wow_mpq::compression::flags;
|
||||
use wow_mpq::{ArchiveBuilder, FormatVersion, ListfileOption};
|
||||
|
||||
mod creatures;
|
||||
use creatures::GeneratedCreatures;
|
||||
|
||||
/// Checks if a directory name looks like a WoW locale folder (e.g. ruRU, enUS, deDE).
|
||||
fn is_locale_dir(name: &str) -> bool {
|
||||
if name.len() != 4 {
|
||||
@@ -19,7 +22,11 @@ fn is_locale_dir(name: &str) -> bool {
|
||||
&& b[3].is_ascii_uppercase()
|
||||
}
|
||||
|
||||
fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>> {
|
||||
fn build_mpq(
|
||||
input_dir: &Path,
|
||||
output_path: &Path,
|
||||
creatures: Option<&GeneratedCreatures>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let mut output = output_path.to_path_buf();
|
||||
output.set_extension("MPQ");
|
||||
|
||||
@@ -36,12 +43,21 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>>
|
||||
.default_compression(flags::ZLIB)
|
||||
.listfile_option(ListfileOption::Generate);
|
||||
|
||||
let loose_sources: std::collections::HashSet<PathBuf> = creatures
|
||||
.into_iter()
|
||||
.flat_map(|generated| generated.assets.iter())
|
||||
.filter_map(|(source, _)| source.canonicalize().ok())
|
||||
.collect();
|
||||
|
||||
for entry in WalkDir::new(&base)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
let full_path = entry.path().canonicalize()?;
|
||||
if loose_sources.contains(&full_path) {
|
||||
continue;
|
||||
}
|
||||
let rel_path = match full_path.strip_prefix(&base) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
@@ -50,15 +66,59 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>>
|
||||
}
|
||||
};
|
||||
let archive_path = rel_path.to_string_lossy().replace('\\', "/");
|
||||
if archive_path.ends_with(".manifest.json") || archive_path == "WXLFileData.csv" {
|
||||
continue;
|
||||
}
|
||||
println!(" Adding: {}", archive_path);
|
||||
builder = builder.add_file(&full_path, &archive_path);
|
||||
}
|
||||
|
||||
if let Some(generated) = creatures {
|
||||
println!(" Adding generated: WXLFileData.csv");
|
||||
builder = builder.add_file_data(generated.file_data_csv.clone(), "WXLFileData.csv");
|
||||
println!(" Adding generated: WXLSpellOverrides.tsv");
|
||||
builder = builder.add_file_data(generated.spell_overrides.clone(), "WXLSpellOverrides.tsv");
|
||||
println!(" Adding generated: DBFilesClient/CreatureModelData.dbc");
|
||||
builder = builder.add_file_data(
|
||||
generated.creature_model_data.clone(),
|
||||
"DBFilesClient/CreatureModelData.dbc",
|
||||
);
|
||||
println!(" Adding generated: DBFilesClient/CreatureDisplayInfo.dbc");
|
||||
builder = builder.add_file_data(
|
||||
generated.creature_display_info.clone(),
|
||||
"DBFilesClient/CreatureDisplayInfo.dbc",
|
||||
);
|
||||
}
|
||||
|
||||
builder.build(&output)?;
|
||||
println!("Created: {}", output.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stage_loose_assets(
|
||||
output_dir: &Path,
|
||||
creatures: &GeneratedCreatures,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let loose_root = output_dir.join("Data").join("Patch-WXL.MPQ");
|
||||
for (source, archive_path) in &creatures.assets {
|
||||
let destination = loose_root.join(archive_path.replace('/', "\\"));
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(source, &destination)?;
|
||||
println!("Staging loose modern asset: {}", archive_path);
|
||||
if source
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("m2"))
|
||||
{
|
||||
let mdx_alias = destination.with_extension("mdx");
|
||||
fs::copy(source, &mdx_alias)?;
|
||||
println!("Staging WoW 3.3.5 model alias: {}", mdx_alias.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
@@ -72,6 +132,16 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
let src_dir = PathBuf::from(&args[1]);
|
||||
let output_dir = PathBuf::from(&args[2]);
|
||||
let project_root = src_dir
|
||||
.canonicalize()?
|
||||
.parent()
|
||||
.ok_or("src_dir has no parent")?
|
||||
.to_path_buf();
|
||||
let generated_creatures = creatures::prepare(&project_root)?;
|
||||
|
||||
if let Some(generated) = generated_creatures.as_ref() {
|
||||
stage_loose_assets(&output_dir, generated)?;
|
||||
}
|
||||
|
||||
let data_dir = src_dir.join("Data");
|
||||
if !data_dir.exists() {
|
||||
@@ -97,12 +167,17 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
let sub_name = sub.file_name().to_string_lossy().to_string();
|
||||
let output_mpq = output_dir.join("Data").join(&name).join(&sub_name);
|
||||
build_mpq(&sub_path, &output_mpq)?;
|
||||
build_mpq(&sub_path, &output_mpq, None)?;
|
||||
}
|
||||
} else {
|
||||
// Regular patch dir, e.g. patch-4/ or patch-Z/
|
||||
let output_mpq = output_dir.join("Data").join(&name);
|
||||
build_mpq(&path, &output_mpq)?;
|
||||
let creature_files = if name.eq_ignore_ascii_case("patch-Z") {
|
||||
generated_creatures.as_ref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
build_mpq(&path, &output_mpq, creature_files)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+75
-4
@@ -1,17 +1,48 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import boto3
|
||||
from botocore.config import Config
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
S3_PREFIX = "World of Warcraft"
|
||||
MANIFEST_S3_KEY = "manifest.json"
|
||||
|
||||
|
||||
def compute_build_hash(files: list[dict]) -> str:
|
||||
lines = [
|
||||
f'{item["path"]}:{item["size"]}:{item["sha256"]}'
|
||||
for item in sorted(files, key=lambda item: item["path"])
|
||||
]
|
||||
return hashlib.sha256("\n".join(lines).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def normalized_path_key(path: str) -> str:
|
||||
"""Match client paths using Windows filesystem semantics."""
|
||||
return path.replace("\\", "/").casefold()
|
||||
|
||||
|
||||
def merge_manifests(base_manifest: dict, staging_manifest: dict) -> dict:
|
||||
files_by_path = {
|
||||
normalized_path_key(item["path"]): item
|
||||
for item in base_manifest.get("files", [])
|
||||
}
|
||||
for item in staging_manifest.get("files", []):
|
||||
files_by_path[normalized_path_key(item["path"])] = item
|
||||
|
||||
files = sorted(files_by_path.values(), key=lambda item: item["path"].casefold())
|
||||
return {
|
||||
"buildHash": compute_build_hash(files),
|
||||
"files": files,
|
||||
}
|
||||
|
||||
|
||||
def load_dotenv(env_path: Path) -> dict[str, str]:
|
||||
values: dict[str, str] = {}
|
||||
if not env_path.exists():
|
||||
@@ -61,6 +92,38 @@ def main() -> int:
|
||||
config=Config(s3={"addressing_style": "path"}) if use_path_style else None,
|
||||
)
|
||||
|
||||
manifest_path = project_root / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
print("ERROR: manifest.json not found", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
staging_manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as error:
|
||||
print(f"ERROR: unable to read local manifest.json: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
response = s3.get_object(Bucket=bucket, Key=MANIFEST_S3_KEY)
|
||||
production_manifest = json.loads(response["Body"].read())
|
||||
except ClientError as error:
|
||||
error_code = error.response.get("Error", {}).get("Code")
|
||||
if error_code not in {"NoSuchKey", "404"}:
|
||||
print(f"ERROR: unable to read production manifest: {error}", file=sys.stderr)
|
||||
return 1
|
||||
production_manifest = {"files": []}
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as error:
|
||||
print(f"ERROR: invalid production manifest: {error}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
merged_manifest = merge_manifests(production_manifest, staging_manifest)
|
||||
print(
|
||||
"Manifest merge: "
|
||||
f"production={len(production_manifest.get('files', []))}, "
|
||||
f"staging={len(staging_manifest.get('files', []))}, "
|
||||
f"result={len(merged_manifest['files'])}"
|
||||
)
|
||||
|
||||
dist_dir = project_root / "dist"
|
||||
if not dist_dir.exists():
|
||||
print(f"ERROR: dist/ directory not found. Run build first.", file=sys.stderr)
|
||||
@@ -87,18 +150,26 @@ def main() -> int:
|
||||
errors = True
|
||||
|
||||
# Загрузка манифеста
|
||||
manifest_path = project_root / "manifest.json"
|
||||
if not manifest_path.exists():
|
||||
print(f"ERROR: manifest.json not found", file=sys.stderr)
|
||||
if errors:
|
||||
print("\nFinished with errors; production manifest was not changed.")
|
||||
return 1
|
||||
|
||||
manifest_path.write_text(
|
||||
json.dumps(merged_manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
print(f"Uploading manifest.json -> s3://{bucket}/{MANIFEST_S3_KEY}")
|
||||
try:
|
||||
s3.upload_file(
|
||||
str(manifest_path),
|
||||
bucket,
|
||||
MANIFEST_S3_KEY,
|
||||
ExtraArgs={"ContentType": "application/json"},
|
||||
ExtraArgs={
|
||||
"ContentType": "application/json",
|
||||
"CacheControl": "no-cache, no-store, must-revalidate",
|
||||
},
|
||||
)
|
||||
print(f" OK")
|
||||
except Exception as e:
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/warcraftxl updated: 9d981125e1...ee9d8a92a3
Reference in New Issue
Block a user