20 Commits

Author SHA1 Message Date
sindoring c1c2b4e8bb добавлен упущенный laa 2026-08-05 01:58:29 +04:00
sindoring 83f5e08689 фикс манифеста 2026-08-04 20:15:00 +04:00
sindoring 76ca0cc851 fix 2026-08-04 08:49:51 +04:00
sindoring 398245d27d fix dbc 2026-07-27 00:31:44 +04:00
sindoring 887ab7879b одиночные подземелья 2026-07-26 22:48:10 +04:00
sindoring e3b8d2649e Update WarcraftXL storage fallback for loose models 2026-07-23 09:51:05 +04:00
sindoring e916cdea19 Add MDX aliases for modern creature models 2026-07-23 01:41:39 +04:00
sindoring befdaa456d Stage modern creature assets through WarcraftXL host 2026-07-23 01:36:59 +04:00
sindoring 7291fff664 Add server model metadata for custom pets 2026-07-23 01:25:12 +04:00
sindoring 790506fc7a Generate companion summon spells 2026-07-23 01:14:05 +04:00
sindoring 87329f2633 Automate custom mount and pet DBC generation 2026-07-23 00:37:42 +04:00
sindoring 8375b95f7b Add retail catslime model support 2026-07-23 00:19:46 +04:00
sindoring 01f476ca1b fix case-insensitive manifest merging 2026-07-19 16:19:36 +04:00
sindoring 63c9af7846 fix production manifest publishing 2026-07-19 16:09:51 +04:00
sindoring f02f4d73f0 fix ej 2026-07-19 15:39:52 +04:00
sindoring 721833bb50 Merge branch 'encounter-journal' 2026-07-19 12:50:06 +04:00
sindoring 85f80445d1 план 2026-07-18 17:05:18 +04:00
sindoring 9fdb7ed718 фикс ачивок 2026-07-18 16:54:34 +04:00
sindoring 698de84762 зум при создании персонажа 2026-07-18 16:24:02 +04:00
sindoring cddaf2c515 получение режима внтури игры 2026-07-18 16:08:08 +04:00
52 changed files with 21641 additions and 160 deletions
+5
View File
@@ -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
+3
View File
@@ -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()
+788
View File
@@ -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.
+83
View File
@@ -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
View File
@@ -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
}
}
+1
View File
@@ -20,6 +20,7 @@ IGNORED_TOP_LEVEL_DIRS = {
IGNORED_DIRS_ANYWHERE = {
".git",
".moonwell_launcher",
".vscode"
}
IGNORED_TOP_LEVEL_FILES = {
"Wow.moonwell-patched.backup.exe",
+21
View File
@@ -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
}
]
}
+11
View File
@@ -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
View File
@@ -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
View File
@@ -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);
+67
View File
@@ -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.
@@ -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)
@@ -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
@@ -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
@@ -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
+3
View File
@@ -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",
]
+2
View File
@@ -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"
+813
View File
@@ -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(&registry_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 &registry.mounts {
let entry = prepare_entry(
project_root,
spec,
registry.defaults.mount_template_display_id,
false,
&mut lock,
&registry,
)?;
if !keys.insert(entry.key.clone()) {
return Err(format!("duplicate creature key '{}'", entry.key).into());
}
entries.push(entry);
}
for spec in &registry.pets {
let entry = prepare_entry(
project_root,
spec,
registry.defaults.pet_template_display_id,
true,
&mut lock,
&registry,
)?;
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, &registry.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", &registry.mounts), ("pet", &registry.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
View File
@@ -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
View File
@@ -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: