10 Commits

Author SHA1 Message Date
sindoring 068d4782a9 обновление warcraftxl 2026-09-04 21:58:47 +04:00
sindoring 66eb505af9 fix 2026-08-30 02:55:15 +04:00
sindoring e53b3c1784 авторитарный логин через лаунчер 2026-08-30 02:18:27 +04:00
sindoring 23b3a3b864 Merge branch 'master' into launcher-authoritive-auth 2026-08-29 14:42:21 +04:00
sindoring 597f4030a7 dvc 2026-08-22 19:10:11 +04:00
gasaichandesu ebd76275a1 fix(transmog): remove embed.xml from Transmogrification.toc because this
file does not exist anymore
2026-08-19 20:28:53 +04:00
gasaichandesu 6addd9bb51 feat(premium): add summon squire spell to Spell.dbc 2026-08-19 16:59:32 +04:00
sindoring abde71ceb0 wip 2026-08-17 20:24:00 +04:00
sindoring a984e705c3 обновление сборщика 2026-08-16 16:56:34 +04:00
sindoring 1a80ca8ad8 new customization 2026-08-13 00:55:19 +04:00
70 changed files with 3226 additions and 1045 deletions
+3
View File
@@ -0,0 +1,3 @@
/config.local
/tmp
/cache
+8
View File
@@ -0,0 +1,8 @@
[cache]
type = "hardlink,copy"
[core]
remote = customization
['remote "customization"']
url = s3://warcraft-client/dvc/moonwell-client-sources/customization
endpointurl = https://storage.yandexcloud.net
region = ru-central1
+3
View File
@@ -0,0 +1,3 @@
# Add patterns of files dvc should ignore, which could improve
# the performance. Learn more at
# https://dvc.org/doc/user-guide/dvcignore
+4
View File
@@ -2,6 +2,7 @@ WOW_HOME=C:\Games\World of Warcraft 3.3.5
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_SESSION_TOKEN=
AWS_DEFAULT_REGION=
AWS_BUCKET=
AWS_ENDPOINT=
@@ -10,3 +11,6 @@ AWS_USE_PATH_STYLE_ENDPOINT=
PRODUCTION_REALMLIST=
PTR_REALMLIST=
LOCAL_REALMLIST=
SSH_USER=root
SSH_HOST=strictlypragmatic.ru
+1 -1
View File
@@ -5,8 +5,8 @@ manifest.json
.vscode
dist
build/
/new customization mpqs/*.mpq
Wow*.exe
*.backup.exe
Logs/
local-assets/
!Wow_Original.exe
+2 -1
View File
@@ -15,7 +15,8 @@
url = https://github.com/WarcraftXL/wxl-db2.git
[submodule "vendor/modules/wxl-modern-m2"]
path = vendor/modules/wxl-modern-m2
url = https://github.com/WarcraftXL/wxl-modern-m2.git
url = https://github.com/sindoring/wxl-modern-m2.git
branch = agent/guard-oversized-m2-batches
[submodule "vendor/modules/wxl-modern-wmo"]
path = vendor/modules/wxl-modern-wmo
url = https://github.com/WarcraftXL/wxl-modern-wmo.git
+3 -2
View File
@@ -66,8 +66,9 @@ wxl_add_external_extension(wxl-unit-outline "${WXL_EXTERNAL_MODULES_DIR}/wxl-uni
# MoonWell-owned extensions. wxl-fdid-moonwell sorts after wxl-db2 and before
# the modern asset extensions, so it can layer custom CSV mappings over wxl.fdid.
wxl_add_external_extension(MoonWell "${WXL_LOCAL_MODULES_DIR}/moonwell")
wxl_add_external_extension(MoonwellCustomization
"${WXL_LOCAL_MODULES_DIR}/moonwell-customization")
if(TARGET MoonWell)
target_link_libraries(MoonWell PRIVATE shell32)
endif()
wxl_add_external_extension(wxl-fdid-moonwell "${WXL_LOCAL_MODULES_DIR}/moonwell-fdid")
wxl_add_external_extension(wxl-moonwell-storage-fallback
"${WXL_LOCAL_MODULES_DIR}/moonwell-storage-fallback")
+355
View File
@@ -0,0 +1,355 @@
# Совместимость CVPatch и расширенной кастомизации
Документ описывает фактический набор графических MPQ MoonWell для клиента
**WoW 3.3.5a, build 12340**, их порядок загрузки, конфликты DBC, сборку слоя
совместимости и серверные требования парикмахерской.
## Исходные данные и происхождение
Старые архивы `patch-ruRU-A..W` относятся к **CVPatch (Collection Visual
Patch)** от Vary/vvladoon. Найденная тема оригинального проекта:
<https://forum.uwow.biz/threads/cvpatch-collection-visual-patch-lich-king-3-3-5.296264/>
Источник новых архивов утрачен. Единственная сохранённая подпись находится в
`new customization mpqs/readme.txt`:
- `patch-a-001.mpq``Model and Textures NPC`;
- `patch-b-002.mpq``Characters HD The War Within`;
- `patch-k.mpq``Interface`.
Новые архивы нельзя считать официальным или полностью автономным комплектом
для 3.3.5a: в них отсутствовал совместимый `BarberShopStyle.dbc`, а часть DBC
содержит данные более новых версий игры.
## Установленная часть CVPatch
| Патч | Назначение | Зависимости и пересечения |
|---|---|---|
| A | Основные модели существ, гуманоидов и транспорта | Основа для старого B |
| B | Альтернативные модели существ, гуманоидов и транспорта | Требует A |
| E | Текстуры экипировки в повышенном разрешении | Независимый |
| H | HD-модели игровых персонажей Leeviathan | Перекрывается новыми X/Y на одинаковых путях |
| I | Новые модели и текстуры брони и оружия | Изначально требовал H; его item-поля сохраняются при слиянии |
| M | Карты классических подземелий и пещер | Независимый |
| S | Эффекты заклинаний и способностей | Содержит собственные spell DBC |
| T | Текстуры зданий и зон | Независимый |
| U | Окружение Низины Арати и Ущелья Песни Войны | Независимый |
| W | Вода | Независимый |
Старые буквенные имена не меняются. X, Y и Z специально выбраны после W,
чтобы новый комплект и исправления имели приоритет без переименования CVPatch.
## Новые исходные архивы
### `patch-a-001.mpq` → `patch-ruRU-X.MPQ`
Размер исходника — около 2,49 GiB, `(listfile)` содержит 29 495 файлов:
- 16 023 запечённых NPC-текстуры в `Textures\bakednpctextures`;
- 13 465 файлов моделей, skin, анимаций и текстур в `Character`;
- модели стандартных рас и ряда NPC-рас: Vrykul, Naga, Taunka, Forest Troll и
других;
- 28 120 BLP, 1 245 ANIM, 94 SKIN и 29 M2;
- 7 DBC.
DBC внутри X:
| DBC | Строк | Роль |
|---|---:|---|
| `CharacterFacialHairStyles.dbc` | 272 | Штатный объём лицевой кастомизации |
| `CharHairGeosets.dbc` | 370 | Штатный объём причёсок |
| `CharSections.dbc` | 10 060 | Секции текстур персонажей |
| `CreatureDisplayInfoExtra.dbc` | 15 477 | Дополнительные параметры отображения существ |
| `EmotesTextSound.dbc` | 782 | Звуки текстовых эмоций |
| `HelmetGeosetVisData.dbc` | 21 | Скрытие geoset под шлемами |
| `SpellVisualKitModelAttach.dbc` | 594 | Крепление моделей визуальных эффектов |
X нужен текущему комбинированному комплекту для NPC-моделей и запечённых
текстур. Для новых причёсок главным является Y, который загружается позже и
перекрывает одноимённые таблицы X.
### `patch-b-002.mpq` → `patch-ruRU-Y.MPQ`
Размер исходника — около 2,80 GiB, `(listfile)` содержит 36 191 файл:
- 36 180 файлов в `Character`;
- HD-модели и текстуры десяти стандартных игровых рас WotLK;
- 34 934 BLP, 1 021 ANIM, 125 BONE, 80 SKIN и 20 M2;
- 11 DBC с существенно расширенной кастомизацией.
DBC внутри Y:
| DBC | Строк | Роль |
|---|---:|---|
| `CharacterFacialHairStyles.dbc` | 3 616 | Бороды, серьги, рога и другие лицевые варианты |
| `CharHairGeosets.dbc` | 854 | Geoset и номера причёсок |
| `CharHairTextures.dbc` | 177 | Текстуры волос |
| `CharSections.dbc` | 614 212 | Расширенные секции кожи, лица, волос и их сочетаний |
| `CharVariations.dbc` | 62 | Наборы вариаций персонажей |
| `ChrRaces.dbc` | 41 | Описания рас, включая данные за пределами штатного WotLK |
| `CreatureDisplayInfo.dbc` | 24 282 | Display ID моделей |
| `CreatureDisplayInfoExtra.dbc` | 15 477 | Раса, пол, внешность и предметные поля display ID |
| `CreatureFamily.dbc` | 40 | Семейства существ |
| `CreatureModelData.dbc` | 1 353 | Пути и параметры моделей |
| `HelmetGeosetVisData.dbc` | 65 | Расширенные правила отображения под шлемами |
Y обязателен для расширенной кастомизации. Z не содержит копии всех этих
таблиц и не заменяет Y.
Наличие современных race ID в DBC не добавляет новые игровые расы в ядро или
интерфейс 3.3.5a. Текущая интеграция поддерживает расширение внешности десяти
штатных рас. Новые игровые расы потребуют отдельной доработки клиента, UI,
сетевого протокола и сервера.
### `patch-k.mpq` — не устанавливается
Размер — около 53 MiB, `(listfile)` содержит 673 файла только в `Interface`:
- 547 BLP;
- 68 SKIN и 24 M2 для экранов входа и выбора персонажа;
- 15 Lua, 12 XML, TOC и вспомогательные файлы.
Архив меняет `Interface\Glues`, `Interface\GlueXML`, модели экранов входа и
создания персонажа. Он намеренно исключён из сборки, потому что перекрывает
кастомный интерфейс MoonWell. Устанавливать его вручную поверх текущего клиента
нельзя без отдельного разбора и слияния UI.
## Итоговый порядок загрузки
В локализованной цепочке `Data\ruRU` используется следующий порядок:
1. штатные архивы клиента, включая `patch-ruRU-3.MPQ`;
2. старые CVPatch `patch-ruRU-A..W`;
3. `patch-ruRU-X.MPQ` — неизменённая копия `patch-a-001.mpq`;
4. `patch-ruRU-Y.MPQ` — неизменённая копия `patch-b-002.mpq`;
5. `patch-ruRU-Z.MPQ` — сгенерированный слой совместимости MoonWell.
Z является последней инстанцией для конфликтующих DBC. Нельзя переносить его
раньше X/Y или устанавливать другой locale MPQ после Z, который содержит те же
таблицы.
## Содержимое `patch-ruRU-Z.MPQ`
В текущей сборке Z содержит:
- `BarberShopStyle.dbc` — расширенная таблица парикмахерской;
- `CreatureDisplayInfoExtra.dbc` — результат слияния новых display ID и
предметных полей CVPatch I;
- `SpellVisualKitModelAttach.dbc` — более полная версия из CVPatch S;
- `CreatureModelData.dbc` — сгенерированное слияние штатных, CVPatch и новых
моделей;
- `CreatureDisplayInfo.dbc` — сгенерированное слияние display ID;
- `WXLFileData.csv` и `WXLSpellOverrides.tsv` для WarcraftXL.
Последние четыре файла добавляются упаковщиком автоматически; поэтому состав
папки `src\Data\ruRU\patch-ruRU-Z` и готового MPQ не обязан совпадать буквально.
### Правила слияния DBC
`tool/scripts/build-customization-compat.ps1` выполняет следующие операции:
- берёт штатную базу из `patch-ruRU-3.MPQ`;
- сохраняет уникальные изменения `CreatureModelData` из CVPatch A;
- сохраняет уникальные изменения `CreatureDisplayInfo` из CVPatch B;
- отдаёт приоритет Y при реальном конфликте одной и той же строки;
- переносит изменённые item-поля `CreatureDisplayInfoExtra` из CVPatch I в
новые строки Y;
- восстанавливает `SpellVisualKitModelAttach.dbc` из CVPatch S;
- генерирует расширенный `BarberShopStyle.dbc`;
- обновляет входные `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc` для
генератора MoonWell creatures.
Так Z не позволяет Y случайно уничтожить старые изменения существ, экипировки
и эффектов, а генерация MoonWell creatures не уничтожает новые display ID.
## Расширенная парикмахерская
### Причина исходного падения
Y расширил:
- `CharHairGeosets.dbc` с 370 до 854 строк;
- `CharacterFacialHairStyles.dbc` с 272 до 3 616 строк.
При этом исходные новые MPQ не содержали `BarberShopStyle.dbc`. Клиент продолжал
использовать штатную таблицу на 491 строку. При выборе нового варианта функция
`GetBarberShopTotalCost` не находила запись, после чего WoW падал с
`ERROR #132 / ACCESS_VIOLATION`. В зафиксированном крэше вызов шёл от
`BarberShopFrameSelector1Next`.
### Как сформирована новая таблица
Генератор:
- сохраняет все 491 штатную строку и исходную строковую таблицу байт-в-байт;
- добавляет отсутствующие причёски из `CharHairGeosets.dbc` Y;
- добавляет отсутствующую растительность и украшения из
`CharacterFacialHairStyles.dbc` Y;
- работает только с сочетаниями расы, пола и типа, уже существующими в штатном
`BarberShopStyle.dbc`, то есть с поддерживаемыми расами WotLK;
- сохраняет штатные строки типа 3 для особой кастомизации рыцарей смерти;
- назначает новые уникальные ID после максимального штатного ID;
- проверяет отсутствие дубликатов и полноту покрытия.
Результат:
- 3 765 строк всего;
- 152 новые причёски;
- 3 122 новых варианта растительности, серёг и других особенностей;
- максимальный номер лицевого варианта — 255, что помещается в поле внешности
персонажа 3.3.5a.
В исходном комплекте нет названий новых стилей. Поэтому сейчас они отображаются
как `New hairstyle N` и `New appearance N`. Это только подписи: на выбор модели
и сохранение внешности они не влияют.
## Что требуется клиенту и серверу
| Компонент | Клиент | Сервер |
|---|---|---|
| `patch-ruRU-X.MPQ` | Да, для текущего комплекта NPC/персонажей | Нет |
| `patch-ruRU-Y.MPQ` | Да, для моделей, текстур и основных customization DBC | Нет |
| `patch-ruRU-Z.MPQ` | Да, для совместимости и исправления парикмахерской | Нет как MPQ |
| Сгенерированный `BarberShopStyle.dbc` | Уже находится внутри Z | Да, отдельным DBC |
| Остальные клиентские DBC из X/Y | Загружаются из MPQ | Для текущих десяти рас не копируются |
Серверную копию нужно положить в каталог DBC worldserver, обычно:
```text
<server>/data/dbc/BarberShopStyle.dbc
```
После замены требуется полный перезапуск worldserver: DBC загружаются при
старте. Клиентская и серверная копии `BarberShopStyle.dbc` должны быть одним и
тем же сгенерированным файлом. Без серверной копии клиент больше не должен
падать при переключении, но сервер может отклонить применение нового стиля или
неверно рассчитать его стоимость.
Готовая серверная копия после запуска генератора находится в:
```text
build/customization-compat/server-dbc/BarberShopStyle.dbc
```
Тот же отслеживаемый исходник для Z находится в:
```text
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc
```
Не следует копировать на сервер `ChrRaces.dbc`, `CharSections.dbc` или весь
набор DBC из Y без отдельной задачи и проверки ядра.
## Сборка
### Предварительные условия
В `.env` должен быть задан корректный `WOW_HOME`. В клиенте должны находиться:
- штатный `Data\ruRU\patch-ruRU-3.MPQ`;
- CVPatch A, B, I и S, используемые генератором как источники слияния;
- остальные CVPatch, если они должны остаться в итоговой установке.
В репозитории должны оставаться:
- `new customization mpqs\patch-a-001.mpq`;
- `new customization mpqs\patch-b-002.mpq`;
- `tool\target\release\mpqread.exe`.
### Пересоздание DBC совместимости
```powershell
.\tool\scripts\build-customization-compat.ps1
```
Скрипт обновляет:
```text
assets/dbc/3.3.5a/CreatureModelData.dbc
assets/dbc/3.3.5a/CreatureDisplayInfo.dbc
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/CreatureDisplayInfoExtra.dbc
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/SpellVisualKitModelAttach.dbc
src/Data/ruRU/patch-ruRU-Z/DBFilesClient/BarberShopStyle.dbc
build/customization-compat/server-dbc/BarberShopStyle.dbc
```
### Упаковка MPQ
```powershell
.\tool\target\release\tool.exe .\src .\dist
```
Упаковщик:
- собирает Z из исходников и сгенерированных MoonWell DBC;
- не копирует внешние графические X/Y в `dist` и не включает их в managed manifest;
- не добавляет `patch-k.mpq`.
Для полной локальной сборки и синхронизации клиента можно использовать:
```powershell
.\run.ps1 -Env local
```
Этот сценарий также собирает WarcraftXL, останавливает блокирующие процессы и
синхронизирует весь `dist` в `WOW_HOME`. Для проверки только изменения Z можно
собрать архив упаковщиком и скопировать лишь
`dist\Data\ruRU\patch-ruRU-Z.MPQ`.
Сборка этих архивов не должна автоматически изменять `manifest.json`. Манифест
обновляется владельцем проекта отдельно после локальной проверки.
## Исходники MPQ и установленные копии
Файлы в `new customization mpqs` используются только как локальные источники
генератора таблиц совместимости. В готовом клиенте X и Y управляются отдельно от
репозиторных патчей.
Списки репозиторных и внешних графических патчей находятся в
`patch-layout.json`. `deploy.ps1`, `run.ps1`, генератор манифеста и S3 uploader
синхронизируют только список `repositoryPatches`. Пути из `graphicsPatches`
сохраняются без копирования и удаления. Если один путь указан в обоих списках,
`repositoryPatches` имеет приоритет.
Z не консолидирует многогигабайтные модели и текстуры X/Y. Удаление X или Y из
клиента не освобождается наличием текущих DBC в Z.
## Проверка после сборки
Минимальный сценарий локальной проверки:
1. Полностью закрыть WoW перед заменой MPQ.
2. Проверить наличие X, Y и Z в `Data\ruRU`.
3. Запустить клиент и открыть создание персонажа.
4. Проверить оба пола и основные расы, пролистать причёски и лицевые варианты.
5. Создать тестового персонажа, войти в мир и проверить модель после повторного
входа.
6. Открыть парикмахерскую и несколько раз нажать вперёд/назад у каждого
селектора.
7. Проверить отображение стоимости и отсутствие `ERROR #132`.
8. Применить новый стиль, выйти из мира и войти снова.
9. Проверить старую броню, NPC-модели и эффекты заклинаний, которые могли быть
затронуты конфликтующими DBC.
Для шага 8 сервер уже должен использовать сгенерированный
`BarberShopStyle.dbc`.
## Диагностика
| Симптом | Вероятная причина |
|---|---|
| Новых причёсок нет | Отсутствует Y или более поздний MPQ перекрыл его `CharHairGeosets`/`CharSections` |
| Модель отсутствует или отображается неверно | X/Y не скопированы полностью либо повреждены многогигабайтные архивы |
| Падение при переключении в парикмахерской | Z отсутствует, загружен раньше Y или его `BarberShopStyle.dbc` перекрыт |
| Вариант выбирается, но не применяется | На сервере остался штатный `BarberShopStyle.dbc` или worldserver не перезапущен |
| Исчезли старые NPC/display ID | Z не пересобран после изменения источников CVPatch или Y |
| Проблемы со старой экипировкой | Потеряны item-поля CVPatch I в `CreatureDisplayInfoExtra.dbc` |
| Изменился экран входа/создания персонажа | `patch-k.mpq` был установлен вручную |
Перед заменой рабочего Z рекомендуется сохранять предыдущий архив. Последняя
локальная резервная копия, созданная во время исправления парикмахерской:
```text
build/customization-compat/client-backup/patch-ruRU-Z.before-barbershop.MPQ
```
+87
View File
@@ -0,0 +1,87 @@
# Авторизация MoonWell через лаунчер
Клиентская часть не принимает логин и пароль в Release-сборке. Лаунчер передаёт игре
короткоживущую учётную сессию через environment block дочернего процесса, а GlueXML сразу
запускает стандартный SRP-вход и не показывает форму логина.
## Контракт запуска клиента
Перед `CreateProcess` лаунчер должен добавить в environment block только запускаемого `Wow.exe`:
```text
MOONWELL_LAUNCH_ACCOUNT=<account name>
MOONWELL_LAUNCH_TICKET=<single-use SRP password>
```
Обе переменные обязательны. Нативный модуль читает и удаляет их из окружения процесса до
загрузки GlueXML. Значения не должны передаваться в аргументах командной строки, писаться в
файл, лог или реестр.
Ограничения значений:
- account: печатный ASCII без пробелов, не более 320 байт;
- ticket: ровно 16 символов из `A-Z0-9` (около 82 бит при равномерной генерации);
- ticket должен быть криптографически случайным, одноразовым и жить не более 60 секунд;
- выпуск нового ticket должен отзывать предыдущий активный ticket этого аккаунта.
Пример Win32-последовательности лаунчера:
1. Пользователь авторизуется в лаунчере.
2. Лаунчер запрашивает у backend одноразовый game ticket.
3. Лаунчер создаёт отдельный environment block с двумя переменными выше.
4. Лаунчер запускает `Wow.exe` через `CreateProcessW`.
5. Лаунчер сразу затирает ticket в своей памяти.
Кнопка «Авторизоваться» сначала ищет `MoonWell.exe` или `MoonWellLauncher.exe` рядом с `Wow.exe`. Если файла нет,
она открывает URI `moonwell://authorize?source=client`. Установщик лаунчера должен зарегистрировать
схему `moonwell` для текущего пользователя.
## Обязательное изменение auth-сервера
Скрытие полей в GlueXML не является серверной защитой: пользователь контролирует свой клиент.
Чтобы вход действительно был возможен только через лаунчер, auth-сервер обязан для обычных
аккаунтов отказаться от постоянного password verifier и принимать только активный launcher ticket.
Для совместимости с неизменённым SRP-клиентом ticket используется как временный пароль:
1. Backend хранит для аккаунта один активный ticket либо подготовленные из него временные
SRP `salt` и `verifier`.
2. На `CMD_AUTH_LOGON_CHALLENGE` auth-сервер ищет активный непросроченный ticket аккаунта.
Если ticket отсутствует, сервер отклоняет вход до выполнения обычной password-проверки.
3. SRP challenge строится по временному verifier.
4. После успешного `CMD_AUTH_LOGON_PROOF` ticket атомарно помечается использованным.
5. Повторный proof, повторный запуск и истёкший ticket отклоняются.
Поскольку challenge содержит аккаунт, но не сам ticket, одновременно должен существовать не более
одного активного ticket на аккаунт.
Рекомендуется дополнительно привязать ticket к версии клиента и идентификатору launcher-сессии,
ограничить частоту выпуска и не возвращать его ни в какие аналитические события.
## Режим разработчика
Ручной логин компилируется только в `Debug`-варианте `MoonWell.dll`. Для локального запуска:
```powershell
.\run.ps1 -Env local -DeveloperLogin
```
Authserver должен независимо разрешить тот же аккаунт записью в `launcher_dev_account`
с точным `allowed_ip` и `enabled = 1`. Один только Debug-режим клиента никогда не обходит
серверную проверку.
Скрипт собирает Debug runtime и передаёт `MOONWELL_DEV_LOGIN=1` только запускаемому процессу.
Переменная одноразово удаляется нативным модулем. В Release-сборке она игнорируется.
Auth-сервер также должен отдельно разрешать постоянный SRP verifier только dev-аккаунтам — лучше
по allowlist аккаунтов вместе с VPN/IP-ограничением. Нельзя считать Debug-флаг клиента границей
безопасности и нельзя распространять Debug DLL игрокам.
## Поведение интерфейса
- валидная launcher-сессия: `AccountLoginUI` скрывается и сразу вызывается
`DefaultServerLogin(account, ticket)`;
- прямой Release-запуск: поля логина, пароля и параметры сохранения скрыты, основная кнопка
открывает лаунчер;
- Debug + `MOONWELL_DEV_LOGIN=1`: показывается прежняя форма ручного входа;
- сохранённые ранее логин и пароль удаляются при любом не-dev запуске.
+18
View File
@@ -47,6 +47,8 @@ git submodule update --remote vendor/warcraftxl vendor/modules/*
- Visual Studio 2022 с C++ toolchain для Win32;
- CMake 3.20+;
- Rust/Cargo для существующего MPQ-сборщика;
- Python 3 и DVC с поддержкой S3 для содержимого внешних графических патчей
(`py -m pip install -r requirements-dvc.txt`);
- локальный оригинальный `Wow_Original.exe` с SHA-256
`AA63A5750D60EF16746C686B3D5E26876D98953EAB08B1C026CD0FAF78E88CB8`.
@@ -111,6 +113,12 @@ $env:WOW_HOME = 'C:\Program Files (x86)\World of Warcraft'
Диагностика запуска находится в `Logs\wxl-core.log` и `Logs\d3d9proxy.log` внутри клиента.
## Авторизация через лаунчер
Release-клиент принимает только одноразовую launcher-сессию и при прямом запуске показывает
кнопку открытия лаунчера вместо полей логина и пароля. Контракт переменных запуска, требования
к auth-серверу и Debug-режим описаны в [LAUNCHER_AUTH.md](LAUNCHER_AUTH.md).
## MPQ-пакеты
Распакованные изменяемые слои уже находятся в `src/Data/`; полный набор базовых MPQ для сборки
@@ -123,6 +131,16 @@ WarcraftXL не требуется и в объектное хранилище
- `patch-ruRU-5` — русская локализация и интерфейс;
- `patch-Z` — Mythic+ ресурсы и клиентские данные.
Управляемые репозиторием MPQ и внешние графические патчи перечислены отдельно в
`patch-layout.json`. Сборка и deploy синхронизируют только `repositoryPatches` и
не изменяют `graphicsPatches`. При пересечении списков репозиторный патч имеет
приоритет.
Распакованное содержимое больших патчей кастомизации хранится через DVC в
`assets/graphics/`, а бинарные объекты — в настроенном S3. Инструкции по первичной
загрузке, изменению и импорту MPQ находятся в
[`assets/graphics/README.md`](assets/graphics/README.md).
## Лицензирование
WarcraftXL распространяется по GPL-3.0 и подключён как отдельный неизменённый upstream submodule.
Binary file not shown.
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
/patch-X
+6
View File
@@ -0,0 +1,6 @@
outs:
- md5: 012ce72d8dfdf089b20a28f6e6158b58.dir
size: 1195310
nfiles: 4
hash: md5
path: patch-X
+14
View File
@@ -0,0 +1,14 @@
/patch-ruRU-X
/patch-ruRU-Y
/backup-ruRU
/patch-ruRU-6
/patch-ruRU-A
/patch-ruRU-B
/patch-ruRU-E
/patch-ruRU-H
/patch-ruRU-I
/patch-ruRU-M
/patch-ruRU-S
/patch-ruRU-T
/patch-ruRU-U
/patch-ruRU-W
@@ -0,0 +1,6 @@
outs:
- md5: b120587dfcd255f78392ca52705a00ff.dir
size: 38876467
nfiles: 161
hash: md5
path: backup-ruRU
@@ -0,0 +1,6 @@
outs:
- md5: 9ca2ee746d80e8a0c951c5ef476ae654.dir
size: 1064210
nfiles: 3
hash: md5
path: patch-ruRU-6
@@ -0,0 +1,6 @@
outs:
- md5: 97232ae859eaa1a16b137efd1a2e9122.dir
size: 2748988973
nfiles: 8817
hash: md5
path: patch-ruRU-A
@@ -0,0 +1,6 @@
outs:
- md5: bb686aebf7c9cae2c02433abde9a9f19.dir
size: 771051083
nfiles: 2911
hash: md5
path: patch-ruRU-B
@@ -0,0 +1,6 @@
outs:
- md5: d6b2fe0e3f23c47dc944ca4a8f82d97a.dir
size: 9275755440
nfiles: 21977
hash: md5
path: patch-ruRU-E
@@ -0,0 +1,6 @@
outs:
- md5: 5e95536d2c6f6ff792f05b800105bedc.dir
size: 3727395837
nfiles: 40296
hash: md5
path: patch-ruRU-H
@@ -0,0 +1,6 @@
outs:
- md5: 5db07768b0814c970d84a99b6072fd9f.dir
size: 31453961
nfiles: 186
hash: md5
path: patch-ruRU-I
@@ -0,0 +1,6 @@
outs:
- md5: e927ae98c9971f378a82feb3299fdcb8.dir
size: 69521651
nfiles: 1552
hash: md5
path: patch-ruRU-M
@@ -0,0 +1,6 @@
outs:
- md5: 449383bbe05ebebb6bad2c37da7793c4.dir
size: 142167464
nfiles: 1751
hash: md5
path: patch-ruRU-S
@@ -0,0 +1,6 @@
outs:
- md5: fc19500b5ce0fd312a415a3118afd58d.dir
size: 656858260
nfiles: 1810
hash: md5
path: patch-ruRU-T
@@ -0,0 +1,6 @@
outs:
- md5: 783909a6c2ebed5d4f43516fe1ea68bb.dir
size: 460642379
nfiles: 1837
hash: md5
path: patch-ruRU-U
@@ -0,0 +1,6 @@
outs:
- md5: e2a1448eb0e443797acd577fc4b67249.dir
size: 44597910
nfiles: 484
hash: md5
path: patch-ruRU-W
@@ -0,0 +1,6 @@
outs:
- md5: 3d322d290959d1ead612f3b76860a1ea.dir
size: 3984134962
nfiles: 29495
hash: md5
path: patch-ruRU-X
@@ -0,0 +1,6 @@
outs:
- md5: 42f3a8ac35674e33c0a4420c84cadcb8.dir
size: 11053212781
nfiles: 36191
hash: md5
path: patch-ruRU-Y
+66
View File
@@ -0,0 +1,66 @@
# Графические MPQ-патчи
В этой директории через DVC версионируется распакованное содержимое всех 15 патчей из
`graphicsPatches` в `patch-layout.json`. Git хранит небольшие файлы `*.dvc`, а сами ресурсы —
в S3 remote `customization`. Хэшированные пути в S3 являются внутренней адресацией DVC:
исходное дерево с обычными именами восстанавливается командой `dvc pull`.
## Первичная настройка
```powershell
py -m pip install -r requirements-dvc.txt
Copy-Item .env.example .env # если локального .env ещё нет
.\configure-dvc.ps1
py -m dvc pull
```
Можно скачать один патч, передав его указатель явно:
```powershell
py -m dvc pull assets/graphics/Data/ruRU/patch-ruRU-X.dvc
```
Секреты записываются только в `.dvc/config.local`; этот файл исключён из Git. Вместо ключей
из `.env` можно использовать AWS profile: `.\configure-dvc.ps1 -Profile moonwell`.
## Изменение ресурсов
DVC-кэш использует hardlink на NTFS. Перед ручным изменением дерева отвяжите его от кэша:
```powershell
py -m dvc unprotect assets/graphics/Data/ruRU/patch-ruRU-X
# изменить, добавить или удалить файлы
py -m dvc add assets/graphics/Data/ruRU/patch-ruRU-X
py -m dvc push
git add assets/graphics/Data/ruRU/patch-ruRU-X.dvc
```
Сначала выполняйте `dvc push`, затем коммитьте Git-указатель, чтобы коммит не ссылался на ещё
не загруженные объекты.
## Импорт MPQ
`mpqextract` принимает встроенный `(listfile)` и любое количество внешних listfile. Он извлекает
все известные пути, а блоки с неизвестными именами сохраняет в `__mpqmeta__/unresolved/`.
Манифест `__mpqmeta__/unresolved.json` связывает каждый blob с MPQ-хэшами и индексом блока,
поэтому содержимое архива не теряется даже при неполном listfile.
```powershell
cargo run --release --manifest-path tool/Cargo.toml --bin mpqextract -- `
'patch.mpq' 'assets/graphics/Data/ruRU/patch-ruRU-N' 'master-list.txt'
```
В `patch-ruRU-A`, `B`, `S`, `T`, `U` и `W` есть такие безымянные блоки. Для их безопасной
пересборки нужен авторитетный listfile с исходными путями; пока он не найден, исходные MPQ в
дистрибуционном S3 остаются каноническими архивами. `mpqpack` намеренно отказывается собирать
дерево с `__mpqmeta__`, поскольку подмена путей хэшами изменила бы семантику патча.
Полностью разрешённое дерево собирается так:
```powershell
cargo run --release --manifest-path tool/Cargo.toml --bin mpqpack -- `
'assets/graphics/Data/ruRU/patch-ruRU-X' 'build/graphics/patch-ruRU-X.MPQ'
```
Сводка исходных объектов, DVC-хэшей и количества разрешённых блоков находится в
`assets/graphics/sources.json`.
+157
View File
@@ -0,0 +1,157 @@
{
"sourcePrefix": "s3://warcraft-client/World of Warcraft/",
"patches": [
{
"archive": "Data/patch-X.MPQ",
"archiveBytes": 496584,
"dvcPointer": "assets/graphics/Data/patch-X.dvc",
"dvcMd5": "012ce72d8dfdf089b20a28f6e6158b58.dir",
"extractedBytes": 1195310,
"trackedFiles": 4,
"resolvedFiles": 4,
"unresolvedBlocks": 0
},
{
"archive": "Data/ruRU/backup-ruRU.MPQ",
"archiveBytes": 23981093,
"dvcPointer": "assets/graphics/Data/ruRU/backup-ruRU.dvc",
"dvcMd5": "b120587dfcd255f78392ca52705a00ff.dir",
"extractedBytes": 38876467,
"trackedFiles": 161,
"resolvedFiles": 161,
"unresolvedBlocks": 0
},
{
"archive": "Data/ruRU/patch-ruRU-6.MPQ",
"archiveBytes": 375062,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-6.dvc",
"dvcMd5": "9ca2ee746d80e8a0c951c5ef476ae654.dir",
"extractedBytes": 1064210,
"trackedFiles": 3,
"resolvedFiles": 3,
"unresolvedBlocks": 0
},
{
"archive": "Data/ruRU/patch-ruRU-A.mpq",
"archiveBytes": 1451338841,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-A.dvc",
"dvcMd5": "97232ae859eaa1a16b137efd1a2e9122.dir",
"extractedBytes": 2748988973,
"trackedFiles": 8817,
"resolvedFiles": 5649,
"unresolvedBlocks": 3167
},
{
"archive": "Data/ruRU/patch-ruRU-B.mpq",
"archiveBytes": 384938550,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-B.dvc",
"dvcMd5": "bb686aebf7c9cae2c02433abde9a9f19.dir",
"extractedBytes": 771051083,
"trackedFiles": 2911,
"resolvedFiles": 1679,
"unresolvedBlocks": 1231
},
{
"archive": "Data/ruRU/patch-ruRU-E.mpq",
"archiveBytes": 1862937450,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-E.dvc",
"dvcMd5": "d6b2fe0e3f23c47dc944ca4a8f82d97a.dir",
"extractedBytes": 9275755440,
"trackedFiles": 21977,
"resolvedFiles": 21977,
"unresolvedBlocks": 0
},
{
"archive": "Data/ruRU/patch-ruRU-H.MPQ",
"archiveBytes": 2154531136,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-H.dvc",
"dvcMd5": "5e95536d2c6f6ff792f05b800105bedc.dir",
"extractedBytes": 3727395837,
"trackedFiles": 40296,
"resolvedFiles": 40296,
"unresolvedBlocks": 0
},
{
"archive": "Data/ruRU/patch-ruRU-I.mpq",
"archiveBytes": 14891516,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-I.dvc",
"dvcMd5": "5db07768b0814c970d84a99b6072fd9f.dir",
"extractedBytes": 31453961,
"trackedFiles": 186,
"resolvedFiles": 186,
"unresolvedBlocks": 0
},
{
"archive": "Data/ruRU/patch-ruRU-M.MPQ",
"archiveBytes": 48772900,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-M.dvc",
"dvcMd5": "e927ae98c9971f378a82feb3299fdcb8.dir",
"extractedBytes": 69521651,
"trackedFiles": 1552,
"resolvedFiles": 1552,
"unresolvedBlocks": 0
},
{
"archive": "Data/ruRU/patch-ruRU-S.mpq",
"archiveBytes": 80492633,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-S.dvc",
"dvcMd5": "449383bbe05ebebb6bad2c37da7793c4.dir",
"extractedBytes": 142167464,
"trackedFiles": 1751,
"resolvedFiles": 1432,
"unresolvedBlocks": 318
},
{
"archive": "Data/ruRU/patch-ruRU-T.mpq",
"archiveBytes": 418595462,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-T.dvc",
"dvcMd5": "fc19500b5ce0fd312a415a3118afd58d.dir",
"extractedBytes": 656858260,
"trackedFiles": 1810,
"resolvedFiles": 1731,
"unresolvedBlocks": 78
},
{
"archive": "Data/ruRU/patch-ruRU-U.mpq",
"archiveBytes": 233678341,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-U.dvc",
"dvcMd5": "783909a6c2ebed5d4f43516fe1ea68bb.dir",
"extractedBytes": 460642379,
"trackedFiles": 1837,
"resolvedFiles": 1372,
"unresolvedBlocks": 464
},
{
"archive": "Data/ruRU/patch-ruRU-W.mpq",
"archiveBytes": 29433979,
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-W.dvc",
"dvcMd5": "e2a1448eb0e443797acd577fc4b67249.dir",
"extractedBytes": 44597910,
"trackedFiles": 484,
"resolvedFiles": 381,
"unresolvedBlocks": 102
},
{
"archive": "Data/ruRU/patch-ruRU-X.MPQ",
"archiveBytes": 2673854694,
"archiveSha256": "CD8CED6FA6C863D509DAE458E6DD44C3986B8B57731B6DC6192FE94125F8C1FD",
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-X.dvc",
"dvcMd5": "3d322d290959d1ead612f3b76860a1ea.dir",
"extractedBytes": 3984134962,
"trackedFiles": 29495,
"resolvedFiles": 29495,
"unresolvedBlocks": 0
},
{
"archive": "Data/ruRU/patch-ruRU-Y.MPQ",
"archiveBytes": 3001665276,
"archiveSha256": "534D496C1A1EE023B910ABC80B727BA88BB09CD2F5767100FC18EE41F4306B90",
"dvcPointer": "assets/graphics/Data/ruRU/patch-ruRU-Y.dvc",
"dvcMd5": "42f3a8ac35674e33c0a4420c84cadcb8.dir",
"extractedBytes": 11053212781,
"trackedFiles": 36191,
"resolvedFiles": 36191,
"unresolvedBlocks": 0
}
]
}
+53 -21
View File
@@ -75,8 +75,53 @@ function Enable-LargeAddressAware {
}
}
function Initialize-MissingSubmodules {
param(
[Parameter(Mandatory)]
[string]$RepositoryPath
)
$modulesFile = Join-Path $RepositoryPath '.gitmodules'
if (-not (Test-Path -LiteralPath $modulesFile -PathType Leaf)) {
return
}
$configuredModules = @(
& git -C $RepositoryPath config --file .gitmodules --get-regexp '^submodule\..*\.path$'
)
if ($LASTEXITCODE -gt 1) {
throw "Failed to read submodules from $modulesFile"
}
foreach ($configuredModule in $configuredModules) {
$fields = $configuredModule -split '\s+', 2
if ($fields.Count -ne 2) {
continue
}
$relativePath = $fields[1].Trim()
$modulePath = Join-Path $RepositoryPath $relativePath
$gitMarker = Join-Path $modulePath '.git'
# Updating an initialized submodule checks out the SHA recorded by its parent and detaches
# any active development branch. Only initialize genuinely missing worktrees.
if (-not (Test-Path -LiteralPath $gitMarker)) {
Write-Host "Initializing missing submodule: $modulePath"
& git -C $RepositoryPath submodule update --init -- $relativePath
if ($LASTEXITCODE -ne 0) {
throw "Failed to initialize submodule: $modulePath"
}
}
if (Test-Path -LiteralPath $modulePath -PathType Container) {
Initialize-MissingSubmodules -RepositoryPath $modulePath
}
}
}
$ErrorActionPreference = 'Stop'
$repoRoot = $PSScriptRoot
. (Join-Path $repoRoot 'pipeline-layout.ps1')
$cmake = 'C:\Program Files\Microsoft Visual Studio\2022\Community\Common7\IDE\CommonExtensions\Microsoft\CMake\CMake\bin\cmake.exe'
if (-not (Test-Path -LiteralPath $cmake)) {
$cmakeCommand = Get-Command cmake -ErrorAction SilentlyContinue
@@ -84,8 +129,7 @@ if (-not (Test-Path -LiteralPath $cmake)) {
$cmake = $cmakeCommand.Source
}
& git -C $repoRoot submodule update --init --recursive
if ($LASTEXITCODE -ne 0) { throw 'Failed to initialize WarcraftXL submodules.' }
Initialize-MissingSubmodules -RepositoryPath $repoRoot
$win32BuildDir = Join-Path $repoRoot 'build\warcraftxl-win32'
$stockExe = Join-Path $repoRoot 'Wow_Original.exe'
@@ -110,19 +154,7 @@ $win32ArtifactDir = Join-Path $win32BuildDir "vendor\warcraftxl\$Configuration"
$warcraftXL = Join-Path $win32ArtifactDir 'WarcraftXL.dll'
$selectedProxy = Join-Path $win32ArtifactDir 'd3d9.dll'
$recoveryProxy = Join-Path $win32BuildDir "artifacts\$Configuration\d3d9-native.dll"
$extensionNames = @(
'MoonWell',
'MoonwellCustomization',
'wxl-db2',
'wxl-fdid-moonwell',
'wxl-grasswind',
'wxl-modern-adt',
'wxl-modern-blp',
'wxl-modern-m2',
'wxl-modern-wmo',
'wxl-moonwell-storage-fallback',
'wxl-unit-outline'
)
$extensionNames = Get-MoonWellExtensionNames
if ($NativeRenderer) {
Write-Warning '-NativeRenderer is no longer needed: WarcraftXL 1.1 uses native D3D9 by default.'
@@ -134,6 +166,8 @@ function Install-WarcraftXLArtifacts {
[string]$Destination
)
Remove-MoonWellRuntimeGarbage -Destination $Destination
$utils = Join-Path $Destination 'Utils'
New-Item `
-ItemType Directory `
@@ -141,12 +175,6 @@ function Install-WarcraftXLArtifacts {
-Force |
Out-Null
$legacyHost = Join-Path $utils 'WarcraftXLHost.exe'
if (Test-Path -LiteralPath $legacyHost -PathType Leaf) {
Remove-Item -LiteralPath $legacyHost -Force
Write-Host "Removed obsolete WarcraftXL 1.0 host: $legacyHost"
}
$destinationExe = Join-Path $Destination 'Wow.exe'
Copy-Item `
@@ -209,6 +237,10 @@ if ($Deploy) {
if (-not [string]::IsNullOrWhiteSpace($PackagePath)) {
$PackagePath = [System.IO.Path]::GetFullPath($PackagePath)
Assert-SafePackagePath `
-PackagePath $PackagePath `
-RepositoryPath $repoRoot `
-ClientPath $ClientPath
Install-WarcraftXLArtifacts -Destination $PackagePath
Write-Host "Packaged stock Wow.exe and WarcraftXL artifacts in $PackagePath"
}
+4
View File
@@ -9,6 +9,8 @@ import sys
from pathlib import Path
from typing import Optional
from patch_layout import PATCH_LAYOUT
IGNORED_TOP_LEVEL_DIRS = {
"Cache",
@@ -49,6 +51,8 @@ def is_ignored(rel_path: Path) -> bool:
return True
if len(rel_path.parts) == 1 and rel_path.name in IGNORED_TOP_LEVEL_FILES:
return True
if PATCH_LAYOUT.is_external_graphics_patch(rel_path.as_posix()):
return True
return any(part in IGNORED_DIRS_ANYWHERE for part in rel_path.parts[:-1])
+56
View File
@@ -0,0 +1,56 @@
param(
[string]$EnvFile = (Join-Path $PSScriptRoot '.env'),
[string]$Profile
)
$ErrorActionPreference = 'Stop'
function Get-EnvValue([string]$Path, [string]$Name) {
if (-not (Test-Path -LiteralPath $Path)) {
return $null
}
$line = Get-Content -LiteralPath $Path |
Where-Object { $_ -match "^$([regex]::Escape($Name))=" } |
Select-Object -First 1
if ($line) {
return $line.Substring($Name.Length + 1).Trim()
}
return $null
}
function Invoke-Dvc([string[]]$Arguments) {
& py -m dvc @Arguments
if ($LASTEXITCODE -ne 0) {
throw "DVC command failed: dvc $($Arguments -join ' ')"
}
}
if (-not (Test-Path -LiteralPath (Join-Path $PSScriptRoot '.dvc\config'))) {
throw 'DVC is not initialized in this repository.'
}
& py -c 'import dvc, dvc_s3' *> $null
if ($LASTEXITCODE -ne 0) {
throw 'DVC with S3 support is not installed. Run: py -m pip install -r requirements-dvc.txt'
}
if ($Profile) {
Invoke-Dvc @('remote', 'modify', '--local', 'customization', 'profile', $Profile)
} else {
$accessKey = Get-EnvValue $EnvFile 'AWS_ACCESS_KEY_ID'
$secretKey = Get-EnvValue $EnvFile 'AWS_SECRET_ACCESS_KEY'
$sessionToken = Get-EnvValue $EnvFile 'AWS_SESSION_TOKEN'
if (-not $accessKey -or -not $secretKey) {
throw "AWS credentials are missing in $EnvFile. Pass -Profile or fill AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY."
}
Invoke-Dvc @('remote', 'modify', '--local', 'customization', 'access_key_id', $accessKey)
Invoke-Dvc @('remote', 'modify', '--local', 'customization', 'secret_access_key', $secretKey)
if ($sessionToken) {
Invoke-Dvc @('remote', 'modify', '--local', 'customization', 'session_token', $sessionToken)
}
}
Write-Host 'Local DVC credentials configured. They are stored in ignored .dvc/config.local.'
+31 -6
View File
@@ -13,6 +13,7 @@ param(
$ErrorActionPreference = 'Stop'
$repoRoot = $PSScriptRoot
. (Join-Path $repoRoot 'pipeline-layout.ps1')
function Import-ProjectEnvironment {
$envFile = Join-Path $repoRoot '.env'
@@ -46,6 +47,10 @@ $PackagePath = [System.IO.Path]::GetFullPath($PackagePath)
if (-not (Test-Path -LiteralPath $ClientPath -PathType Container)) {
throw "Client directory was not found: $ClientPath"
}
Assert-SafePackagePath `
-PackagePath $PackagePath `
-RepositoryPath $repoRoot `
-ClientPath $ClientPath
$runningClients = @(Get-Process Wow -ErrorAction SilentlyContinue | Where-Object {
$_.Path -and $_.Path.StartsWith($ClientPath, [System.StringComparison]::OrdinalIgnoreCase)
@@ -64,11 +69,21 @@ $env:WOW_HOME = $ClientPath
Write-Host "MoonWell client: $ClientPath"
Write-Host "Package staging: $PackagePath"
Write-Host '[1/5] Initializing WarcraftXL dependencies...'
& git -C $repoRoot submodule update --init --recursive
if ($LASTEXITCODE -ne 0) { throw 'Git submodule initialization failed.' }
Write-Host '[1/5] Preserving initialized WarcraftXL worktrees...'
New-Item -ItemType Directory -Path $PackagePath -Force | Out-Null
$previousPackageFiles = @(
Get-RelativeFilePaths -Root $PackagePath
)
if (-not $SkipDataBuild) {
Write-Host 'Resetting package staging to prevent stale build artifacts...'
Reset-MoonWellPackageStaging `
-PackagePath $PackagePath `
-RepositoryPath $repoRoot `
-ClientPath $ClientPath
} else {
New-Item -ItemType Directory -Path $PackagePath -Force | Out-Null
Remove-MoonWellRuntimeGarbage -Destination $PackagePath
}
if (-not $SkipDataBuild) {
Write-Host '[2/5] Building MPQ patches...'
@@ -99,6 +114,18 @@ if ($NativeRenderer) { $buildArguments.NativeRenderer = $true }
if ($LASTEXITCODE -ne 0) { throw 'WarcraftXL deployment failed.' }
Write-Host '[4/5] Synchronizing package files...'
$python = Get-Command python -ErrorAction SilentlyContinue
if (-not $python) { throw 'Python was not found; package validation cannot run.' }
& $python.Source (Join-Path $repoRoot 'validate_package.py') --dir $PackagePath
if ($LASTEXITCODE -ne 0) { throw 'Package validation failed.' }
$currentPackageFiles = @(Get-RelativeFilePaths -Root $PackagePath)
Remove-StalePackageFilesFromClient `
-ClientPath $ClientPath `
-PreviousFiles $previousPackageFiles `
-CurrentFiles $currentPackageFiles
Remove-MoonWellRuntimeGarbage -Destination $ClientPath
& robocopy $PackagePath $ClientPath /E /R:2 /W:1 /NFL /NDL /NJH /NJS /NP
$robocopyExitCode = $LASTEXITCODE
if ($robocopyExitCode -ge 8) {
@@ -107,8 +134,6 @@ if ($robocopyExitCode -ge 8) {
if (-not $SkipManifest) {
Write-Host '[5/5] Building launcher manifest...'
$python = Get-Command python -ErrorAction SilentlyContinue
if (-not $python) { throw 'Python was not found; launcher manifest was not generated.' }
& $python.Source (Join-Path $repoRoot 'build_manifest.py') `
--dir $PackagePath --output (Join-Path $repoRoot 'manifest.json')
if ($LASTEXITCODE -ne 0) { throw 'Launcher manifest generation failed.' }
-39
View File
@@ -1,39 +0,0 @@
# Asset manifest
## Blood Elf Female
- Retail build: `WOW-68887patch12.0.7_Retail`
- Race ID: `10`
- ChrModelID: `20`
- Character model FileDataID: `1100258`
- Option: Demon Hunter Horns
- Retail OptionID: `126`
- Runtime element type: Geoset in an attached collection M2
- Collection model FileDataID: `7760202`
- Collection compatibility: `RETAIL_WITH_WXL`
- Runtime virtual path: `MoonWell\Customization\BloodElfFemale\7760202_be_f.m2`
- Prototype attachment point: `11` (stock head point). Retail collection point `19` is absent from
the 3.3.5 Blood Elf model and cannot safely be force-attached.
| Prototype choice | Retail ChoiceID | Retail ElementID | GeosetID | Compatibility |
|---|---:|---:|---:|---|
| None | 1850 | — | — | RETAIL_WITH_WXL |
| Betrayer | 1851 | 2051 | 2401 | RETAIL_WITH_WXL |
| Beast | 1852 | 2052 | 2402 | RETAIL_WITH_WXL |
| Dreadlord | 1853 | 2053 | 2403 | RETAIL_WITH_WXL |
The source catalog reports two collection textures:
| FileDataID | Internal path | Type | Purpose |
|---:|---|---|---|
| 1277002 | `item\objectcomponents\collections\bloodelf_female_dh_belt.blp` | BLP | Collection material dependency |
| 2764488 | `item\objectcomponents\collections\bloodelffemale_dh_horns.blp` | BLP | Collection material dependency |
Skin files exported with the model are FileDataID `7766030` (base), `7766031`, `7766032`, and
`7766033` (LODs). The MVP requests the base sibling `7760202_be_f00.skin`.
No fallback asset is committed or silently substituted. Classification is `RETAIL_WITH_WXL`, not
`RETAIL_NATIVE`: stock 3.3.5 cannot parse this modern collection without the WarcraftXL modern-M2
extension. If it fails in a real client session, the documented next fallback is a small WotLK M2
mounted at the same virtual path; such a placeholder would be classified `WOTLK_FALLBACK` and must be
recorded here before use.
-12
View File
@@ -1,12 +0,0 @@
# Glue integration follow-up
The runtime prototype intentionally does not alter character creation. WarcraftXL v1.1 exposes typed
Glue/Lua bindings in `game/Glue.hpp` and `game/Script.hpp`, while the existing MoonWell extension shows
the validated `Lua.RegisterFunction` / `Lua.ValidateFunctionPointer` bridge used by build 12340.
A safe follow-up is to register
`ModelFrame:SetMoonwellCustomization(optionID, choiceID)` through that existing bridge, resolve the
Glue model's `CharModelObject`, and pass it to the same `CustomizationState` and renderer used here.
Cleanup must follow Glue model destruction rather than `OnWorldLeave`. This should be implemented only
after the runtime collection attachment has passed interactive visual/lifetime testing; no extra client
offset or duplicate renderer is justified for the current vertical slice.
-92
View File
@@ -1,92 +0,0 @@
# MoonwellCustomization vertical slice
## Scope
`MoonwellCustomization.dll` is a runtime-only WarcraftXL 1.1 extension for WoW 3.3.5a build
12340. It changes only an active Blood Elf Female player. State is kept in memory and is deliberately
separate from rendering; there are no server packets, database changes, AzerothCore changes, or new
Wow.exe offsets.
The data flow is:
`CustomizationOption (126) -> CustomizationChoice -> CustomizationElement (Geoset) -> collection M2`
The explicit prototype state is None plus three retail choices. The renderer owns one independent
collection render context at a time. Reapplying the same choice is a no-op; replacing a choice releases
the old context first. The overlay copies the active character's placement and bone palette without
joining the stock attachment readiness chain. `OnWorldLeave` always releases it and drops all borrowed
player pointers.
## Verified WarcraftXL API
The repository pins `vendor/warcraftxl` to `v1.1.220` (`4895cef`) on the `v1.1` branch. Source code,
not README assumptions, establishes the following behavior:
- `WXL_Query`/`WXL_Load` are the extension entry points; the runtime loads DLLs found below
`Extensions/<name>/`.
- `WXL_Api::UiAddPanel` registers an immediate-mode overlay panel. WarcraftXL v1.1 hard-codes `F9`
as its overlay toggle; press it and select **MoonWell Customization**.
- `world::ActivePlayerGuid` and `world::ResolveObject(..., kTypeMaskPlayer)` find the active player;
`unit::Model` returns its body-model/scene-node binding.
- The upstream typed `SceneNode` and `CharModelObject` views provide owner, race, gender, and root
scene node. The extension adds no offsets.
- `m2::GetRenderCtx` always creates a new scene model; `AttachToScene(..., slot 19, true)` attaches a
collection M2. Core explicitly documents 19 as the collection-M2 attachment point.
- `OnM2SkinFinalize` is the safe pre-finalize window to filter collection geosets. Runtime changes
call `FinalizeSkin` once; a recursion guard prevents its event from re-entering the filter.
- `OnBuildBonePalette` is post-engine and therefore the safe point to copy the parent character bone
palette into the collection model immediately before upload.
- `OnItemSlotChange` and `OnItemSlotClear` schedule a replacement on the next logic tick, after native
equipment work. Native equipment is never overridden.
- `OnWorldLeave` is the cleanup event. Events and UI run on WarcraftXL's main/render paths; no worker
thread calls client model bindings.
## Build
From a Visual Studio 2022 developer environment or PowerShell:
```powershell
.\build-warcraftxl.ps1 -Configuration Release
```
The required artifact is:
`build\warcraftxl-win32\Release\MoonwellCustomization.dll`
The target is Win32. The top-level CMake build compiles the extension together with the existing
WarcraftXL SDK sources; there is no second CMake project.
## Install and run
1. Install the asset files described in `docs/wow-export-assets.md` into a client MPQ.
2. Copy the DLL to
`Extensions\MoonwellCustomization\MoonwellCustomization.dll` under the client directory. The build
script does this automatically when invoked with `-Deploy -ClientPath <client>`.
3. Start the stock build-12340 client through the installed WarcraftXL `d3d9.dll` proxy.
4. Press `F9` to open the WarcraftXL overlay and select **MoonWell Customization**.
5. On Blood Elf Female, press None / Betrayer / Beast / Dreadlord. None must remove the collection;
repeated selections must not stack it. Equip and remove a helmet, then log out and back in.
6. On every other race or gender, the panel reports that the prototype is unsupported and exposes no
choice controls.
Expected log lines are tagged `[MoonwellCustomization]`: `loaded`, `player detected`, race/gender,
apply, model load, attach, detach, unsupported player, and actionable failure text.
## Known limits
- The repository intentionally excludes Blizzard assets. Without the collection M2 and sibling skin,
the client loader can fall back to its placeholder model; that is an asset-install failure, not a
successful visual test.
- Retail collection model 7760202 is a modern MD21 asset. It requires the existing `wxl-modern-m2`,
`wxl-modern-blp`, `wxl-db2`, and FDID extensions in this repository.
- Bone copying assumes the retail Blood Elf Female collection uses the same ordered skeleton as its
parent model. The code clamps to the smaller palette, but a retail build changing bone order would
need an explicit bone map.
- `FinalizeSkin` in WXL 1.1 has small internal re-finalize leaks. The implementation therefore performs
it only on an actual choice change/equipment refresh, never per frame.
- This source-level build proves compilation and packaging. A successful visual result and crash-free
logout still require an interactive client session with the external assets installed.
The next minimal step toward a broader retail-like system is to move the static option table to an
external, versioned catalog and add renderers for Texture and independent M2Attachment elements while
keeping the same `optionID -> choiceID` state boundary.
-42
View File
@@ -1,42 +0,0 @@
# Exporting the Blood Elf Female prototype assets
The local wow.export checkout has no documented headless CLI, so this document does not invent one.
Its MoonWell exporter generated the inspected catalog at:
`moonwell-customization/wotlk-races/catalog.json`
The exact source is retail `WOW-68887patch12.0.7_Retail`. Use wow.export's UI to select that product,
then export the WotLK races customization catalog/asset set. From the generated output, take:
| FileDataID | Exported file / internal path | Type | Why |
|---:|---|---|---|
| 7760202 | `assets/collections/7760202/7760202_be_f.m2` | M2 | Blood Elf Female collection containing Demon Hunter horn geosets |
| 7766030 | `assets/collections/7760202/7760202_be_f00.skin` | SKIN | Base geometry/index profile required by the M2 |
| 1277002 | `item/objectcomponents/collections/bloodelf_female_dh_belt.blp` | BLP | Declared collection texture dependency |
| 2764488 | `item/objectcomponents/collections/bloodelffemale_dh_horns.blp` | BLP | Declared collection texture dependency |
Do not add these files to git. Stage them under the ignored `local-assets/` directory, then put them in
a local MPQ with these virtual names:
```text
MoonWell\Customization\BloodElfFemale\7760202_be_f.m2
MoonWell\Customization\BloodElfFemale\7760202_be_f00.skin
item\objectcomponents\collections\bloodelf_female_dh_belt.blp
item\objectcomponents\collections\bloodelffemale_dh_horns.blp
```
Install that MPQ as a high-priority `Data\patch-*.MPQ` beside the existing MoonWell patches. Keep the
three LOD skins (`7766031``7766033`) locally for later testing, but the current MVP does not request
them explicitly.
To reproduce the DB result without changing wow.export, run the repository's read-only catalog helper:
```powershell
node tools/find_customization_assets.js `
C:\Users\sindo\wow.export\moonwell-customization\wotlk-races\catalog.json `
"Blood Elf" female Horns
```
It must report OptionID `126`, then ChoiceIDs `1851`, `1852`, `1853`. Their skinned-model elements
map geoset type `24` and IDs `1`, `2`, `3` to skin sections `2401`, `2402`, `2403`. The helper reads
already-exported JSON only; it does not access CASC and is not a runtime dependency.
@@ -1,70 +0,0 @@
#pragma once
#include <array>
#include <cstdint>
namespace moonwell::customization
{
enum class ElementType : uint8_t
{
None,
M2Attachment,
Geoset,
Texture,
};
struct CustomizationElement
{
ElementType type;
uint32_t geosetId;
const char* modelPath;
};
struct CustomizationChoice
{
uint32_t id;
const char* name;
std::array<CustomizationElement, 1> elements;
};
struct CustomizationOption
{
uint32_t id;
const char* name;
std::array<CustomizationChoice, 4> choices;
};
inline constexpr uint32_t kBloodElfRace = 10;
inline constexpr uint32_t kFemaleGender = 1;
inline constexpr uint32_t kCollectionFileDataId = 7760202;
// Stock 3.3.5 Blood Elf models have no retail collection point 19.
// Use the native head attachment point; forcing absent point 19 makes
// the parent's drawable gate fail and hides the whole character.
inline constexpr uint32_t kCollectionAttachmentSlot = 11;
inline constexpr char kCollectionPath[] =
"MoonWell\\Customization\\BloodElfFemale\\7760202_be_f.m2";
// WOTLK_FALLBACK: native 3.3.5 head components for Blood Elf Female.
// The retail collection needs a real bone map to the differently ordered
// 3.3.5 skeleton, so the working vertical slice uses native components.
inline constexpr CustomizationOption kHornsOption = {
126,
"Head Decorations (WotLK fallback)",
{{
{1850, "None", {{{ElementType::None, 0, nullptr}}}},
{1851, "Blindfold", {{{ElementType::M2Attachment, 0,
"ITEM\\OBJECTCOMPONENTS\\HEAD\\Helm_Blindfold_A_01_BeF.m2"}}}},
{1852, "Circlet I", {{{ElementType::M2Attachment, 0,
"ITEM\\OBJECTCOMPONENTS\\HEAD\\Helm_Circlet_A_01_BeF.m2"}}}},
{1853, "Circlet II", {{{ElementType::M2Attachment, 0,
"ITEM\\OBJECTCOMPONENTS\\HEAD\\Helm_Circlet_B_01_BeF.m2"}}}},
}},
};
inline const CustomizationChoice* FindChoice(uint32_t id)
{
for (const auto& choice : kHornsOption.choices)
if (choice.id == id) return &choice;
return nullptr;
}
}
@@ -1,436 +0,0 @@
#include "CustomizationRenderer.hpp"
#include <wxl/PluginApi.h>
#include "engine/events/Event.hpp"
#include "game/M2.hpp"
#include "game/World.hpp"
#include <algorithm>
#include <cstdint>
#include <cstring>
#include <limits>
#include <windows.h>
namespace moonwell::customization
{
namespace m2 = wxl::game::m2;
namespace world = wxl::game::world;
namespace sm2 = wxl::structure::m2;
namespace
{
bool IsReadableRange(const void* address, size_t size)
{
if (!address || !size) return false;
const uintptr_t begin = reinterpret_cast<uintptr_t>(address);
if (size > std::numeric_limits<uintptr_t>::max() - begin) return false;
const uintptr_t end = begin + size;
uintptr_t cursor = begin;
while (cursor < end)
{
MEMORY_BASIC_INFORMATION memory{};
if (!VirtualQuery(reinterpret_cast<const void*>(cursor), &memory, sizeof memory))
return false;
if (memory.State != MEM_COMMIT ||
(memory.Protect & (PAGE_NOACCESS | PAGE_GUARD)) != 0)
return false;
const uintptr_t regionEnd = reinterpret_cast<uintptr_t>(memory.BaseAddress) +
memory.RegionSize;
if (regionEnd <= cursor) return false;
cursor = std::min(regionEnd, end);
}
return true;
}
}
void CustomizationRenderer::SetError(const char* message)
{
lastError_ = message;
api_->Log(WXL_LOG_ERROR, "MoonwellCustomization", "failed: %s", message);
}
bool CustomizationRenderer::DetectPlayer()
{
playerDetected_ = false;
supported_ = false;
charModelObject_ = nullptr;
parentRenderContext_ = nullptr;
race_ = gender_ = 0;
const auto guid = world::ActivePlayerGuid();
void* player = guid ? world::ResolveObject(guid, world::kTypeMaskPlayer) : nullptr;
if (!player)
{
lastError_ = "Waiting for active player object";
return false;
}
// The native equipment events are the public WXL seam that supplies
// the actual CharModelObject. Unit::Model is not useful for identity:
// it exposes a different model layer. Match the root instance's world
// transform to the active player's authoritative world position so an
// event for another visible character cannot be adopted accidentally.
auto* cmo = static_cast<m2::off::CharModelObject*>(observedCharModelObject_);
if (!cmo || !cmo->sceneNode)
{
lastError_ = "Waiting for active player CharModelObject event";
return false;
}
auto* root = static_cast<m2::off::M2Instance*>(cmo->sceneNode);
float playerPosition[3]{};
world::UnitPosition(player, playerPosition);
const float dxRow = root->placement[12] - playerPosition[0];
const float dyRow = root->placement[13] - playerPosition[1];
const float dzRow = root->placement[14] - playerPosition[2];
const float rowDistanceSq = dxRow * dxRow + dyRow * dyRow + dzRow * dzRow;
const float dxColumn = root->placement[3] - playerPosition[0];
const float dyColumn = root->placement[7] - playerPosition[1];
const float dzColumn = root->placement[11] - playerPosition[2];
const float columnDistanceSq = dxColumn * dxColumn + dyColumn * dyColumn + dzColumn * dzColumn;
if (std::min(rowDistanceSq, columnDistanceSq) > 9.0f)
{
lastError_ = "Waiting for matching active player CharModelObject";
return false;
}
if (cmo->raceId == 0 ||
cmo->raceId > 11 || cmo->genderId > 1)
{
lastError_ = "Active player CharModelObject is not ready";
return false;
}
charModelObject_ = cmo;
parentRenderContext_ = cmo->sceneNode;
race_ = cmo->raceId;
gender_ = cmo->genderId;
playerDetected_ = true;
supported_ = race_ == kBloodElfRace && gender_ == kFemaleGender;
lastError_ = supported_ ? "Ready" : "Prototype supports Blood Elf Female only.";
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "player detected");
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "race=%u gender=%u", race_, gender_);
if (!supported_)
api_->Log(WXL_LOG_WARN, "MoonwellCustomization", "unsupported player race");
return true;
}
void CustomizationRenderer::OnWorldEnter()
{
inWorld_ = true;
detectDelay_ = 0.0f;
DetectPlayer();
}
void CustomizationRenderer::OnWorldLeave()
{
inWorld_ = false;
Detach();
observedCharModelObject_ = nullptr;
charModelObject_ = nullptr;
parentRenderContext_ = nullptr;
playerDetected_ = supported_ = false;
race_ = gender_ = 0;
detectDelay_ = 0.0f;
refreshRequested_ = false;
geosetApplied_ = false;
attached_ = false;
finalizeInProgress_ = false;
lastError_ = "Outside world";
}
void CustomizationRenderer::OnUpdate(float dt)
{
// OnWorldEnter is an edge notification and can be missed when the
// extension is loaded after a world transition has already begun.
// Reconcile against the authoritative active-player GUID so the
// renderer also recovers after late loads and unusual glue flows.
const auto activePlayerGuid = world::ActivePlayerGuid();
if (!activePlayerGuid)
{
if (inWorld_) OnWorldLeave();
return;
}
if (!inWorld_)
{
inWorld_ = true;
detectDelay_ = 0.0f;
lastError_ = "Waiting for active player model";
}
if (!playerDetected_)
{
detectDelay_ -= dt;
if (detectDelay_ <= 0.0f)
{
detectDelay_ = 0.25f;
DetectPlayer();
}
return;
}
if (refreshRequested_)
{
refreshRequested_ = false;
const uint32_t choiceId = CurrentChoice();
if (choiceId != 0 && choiceId != kHornsOption.choices[0].id)
{
Detach();
if (const auto* choice = FindChoice(choiceId)) Attach(*choice);
}
}
if (attachmentRenderContext_ && attachmentModel_ && !geosetApplied_)
{
const auto* choice = FindChoice(CurrentChoice());
if (choice && choice->elements[0].type == ElementType::Geoset)
FilterGeoset(attachmentModel_, choice->elements[0].geosetId, true);
}
// CreateSceneModel loads asynchronously. Attaching it before the stock
// readiness gate succeeds makes the parent character non-drawable too.
// Keep it unattached while loading, then join the stock head point only after the
// child itself reports drawable.
if (attachmentRenderContext_ && parentRenderContext_)
{
auto* child = static_cast<m2::off::M2Instance*>(attachmentRenderContext_);
auto* parent = static_cast<m2::off::M2Instance*>(parentRenderContext_);
const auto* choice = FindChoice(CurrentChoice());
const bool collectionGeoset = choice &&
choice->elements[0].type == ElementType::Geoset;
if (collectionGeoset && !attached_)
{
std::memcpy(child->placement, parent->placement, sizeof child->placement);
child->viewDistSq = parent->viewDistSq;
child->alphaBase = parent->alphaBase;
}
if (geosetApplied_ && !attached_)
{
const int drawable = wxl::game::Native<m2::off::M2_IsDrawableFn>(
m2::off::kIsDrawable)(child, nullptr, 0, 0);
if (drawable)
{
m2::AttachToScene(parentRenderContext_, child,
kCollectionAttachmentSlot, false);
if (reinterpret_cast<void*>(static_cast<uintptr_t>(child->parent)) ==
parentRenderContext_)
{
attached_ = true;
lastError_ = "Ready";
api_->Log(WXL_LOG_INFO, "MoonwellCustomization",
"attached ready overlay slot=%u", kCollectionAttachmentSlot);
}
else
{
lastError_ = "Native head attachment was rejected";
}
}
else
{
lastError_ = "Collection loaded; waiting for drawable readiness";
}
}
}
}
void CustomizationRenderer::OnEquipmentChanged(void* charModelObject)
{
if (!playerDetected_ && charModelObject)
{
observedCharModelObject_ = charModelObject;
DetectPlayer();
}
// Item events are emitted around native equipment maintenance. Rebuild on
// the next logic tick so native slot work is complete before slot 19 is touched.
if (charModelObject == charModelObject_)
{
const auto* choice = FindChoice(CurrentChoice());
if (choice && choice->elements[0].type != ElementType::None)
refreshRequested_ = true;
}
}
void CustomizationRenderer::OnSkinFinalize(void* model)
{
if (!model || model != attachmentModel_ || finalizeInProgress_) return;
const auto* choice = FindChoice(CurrentChoice());
if (choice && choice->elements[0].type == ElementType::Geoset)
FilterGeoset(model, choice->elements[0].geosetId, false);
}
void CustomizationRenderer::OnBuildBonePalette(void* renderContext)
{
if (!renderContext || renderContext != attachmentRenderContext_) return;
const auto* choice = FindChoice(CurrentChoice());
if (!choice || choice->elements[0].type != ElementType::Geoset) return;
auto* child = static_cast<m2::off::M2Instance*>(renderContext);
auto* parent = static_cast<m2::off::M2Instance*>(parentRenderContext_);
if (!parent || !child->bonePalettePtr || !parent->bonePalettePtr)
return;
m2::M2Model childModel(reinterpret_cast<void*>(child->model));
m2::M2Model parentModel(reinterpret_cast<void*>(parent->model));
if (!childModel || !parentModel || !childModel.GetHeader() || !parentModel.GetHeader()) return;
const uint32_t count = std::min(childModel.GetHeader()->bones.count,
parentModel.GetHeader()->bones.count);
if (count)
std::memcpy(reinterpret_cast<void*>(child->bonePalettePtr),
reinterpret_cast<const void*>(parent->bonePalettePtr),
static_cast<size_t>(count) * m2::off::kBonePaletteStride);
}
bool CustomizationRenderer::FilterGeoset(void* rawModel, uint32_t geosetId, bool finalize)
{
m2::M2Model model(rawModel);
auto* skin = model ? model.GetSkin() : nullptr;
if (!IsReadableRange(skin, sizeof *skin) || !skin->indices ||
!skin->submeshes || !skin->indexCount || !skin->submeshCount)
return false;
// A newly created async scene model can expose the skin object before
// its offset-valued arrays have been relocated to pointers. Do not
// touch those arrays until both complete ranges are readable.
constexpr uint32_t kMaxIndexCount = 10u * 1024u * 1024u;
constexpr uint32_t kMaxSubmeshCount = 100u * 1024u;
if (skin->indexCount > kMaxIndexCount || skin->submeshCount > kMaxSubmeshCount ||
!IsReadableRange(skin->indices,
static_cast<size_t>(skin->indexCount) * sizeof(uint16_t)) ||
!IsReadableRange(skin->submeshes,
static_cast<size_t>(skin->submeshCount) * sizeof(sm2::M2SkinSection)))
return false;
bool found = false;
for (uint32_t i = 0; i < skin->submeshCount; ++i)
{
const sm2::M2SkinSection& section = skin->submeshes[i];
const uint32_t start = (uint32_t(section.level) << 16) | section.indexStart;
const uint32_t count = section.indexCount;
if (start > skin->indexCount || count > skin->indexCount - start) continue;
if (section.skinSectionId == geosetId)
{
found = true;
continue;
}
std::fill_n(skin->indices + start, count, uint16_t(0));
}
if (!found)
{
SetError("requested customization geoset is absent from collection skin");
return false;
}
if (finalize)
{
finalizeInProgress_ = true;
model.FinalizeSkin();
finalizeInProgress_ = false;
}
attachmentModel_ = rawModel;
geosetApplied_ = true;
return true;
}
bool CustomizationRenderer::Attach(const CustomizationChoice& choice)
{
if (!supported_ || !charModelObject_ || !parentRenderContext_)
{
SetError("supported player model is unavailable");
return false;
}
const auto& element = choice.elements[0];
if (!element.modelPath)
{
SetError("customization model path is absent");
return false;
}
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "loading model %s", element.modelPath);
auto* parent = static_cast<m2::off::M2Instance*>(parentRenderContext_);
void* scene = reinterpret_cast<void*>(static_cast<uintptr_t>(parent->scene));
if (!scene)
{
SetError("active player scene is unavailable");
return false;
}
// CreateSceneModel's native receiver is the owning scene, not the
// CharModelObject. Passing the CMO makes the loader interpret cmo+4
// as the scene's model table and faults inside 0x0081C4F0.
void* child = m2::GetRenderCtx(scene, const_cast<char*>(element.modelPath));
if (!child)
{
SetError("CreateSceneModel returned null");
return false;
}
attachmentRenderContext_ = child;
auto* instance = static_cast<m2::off::M2Instance*>(child);
attachmentModel_ = reinterpret_cast<void*>(instance->model);
geosetApplied_ = element.type != ElementType::Geoset;
attached_ = false;
// Native WotLK head components must retain their own local transform;
// AttachToScene composes it with the parent's head point. Collection
// geosets use a full-character coordinate system and are seeded below.
if (element.type == ElementType::Geoset)
{
auto* parentInstance = static_cast<m2::off::M2Instance*>(parentRenderContext_);
std::memcpy(instance->placement, parentInstance->placement, sizeof instance->placement);
instance->viewDistSq = parentInstance->viewDistSq;
instance->alphaBase = parentInstance->alphaBase;
}
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "created overlay geoset=%u",
choice.elements[0].geosetId);
lastError_ = "Loading collection model";
return true;
}
void CustomizationRenderer::Detach()
{
if (!attachmentRenderContext_) return;
if (attached_ && parentRenderContext_)
m2::DetachSlot(parentRenderContext_, kCollectionAttachmentSlot);
else
m2::ReleaseRenderCtx(attachmentRenderContext_);
attachmentRenderContext_ = nullptr;
attachmentModel_ = nullptr;
geosetApplied_ = false;
attached_ = false;
finalizeInProgress_ = false;
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "detached");
}
bool CustomizationRenderer::Apply(uint32_t optionId, uint32_t choiceId)
{
const auto* choice = FindChoice(choiceId);
if (!choice || optionId != kHornsOption.id) return false;
if (!supported_)
{
lastError_ = "Prototype supports Blood Elf Female only.";
return false;
}
const bool isNone = choice->elements[0].type == ElementType::None;
if (CurrentChoice() == choiceId && (isNone || attachmentRenderContext_)) return true;
api_->Log(WXL_LOG_INFO, "MoonwellCustomization", "apply option=%u choice=%u",
optionId, choiceId);
Detach();
state_.SetChoice(optionId, choiceId);
if (isNone) return true;
return Attach(*choice);
}
}
@@ -1,57 +0,0 @@
#pragma once
#include "CustomizationState.hpp"
#include <cstdint>
struct WXL_Api;
namespace moonwell::customization
{
class CustomizationRenderer
{
public:
explicit CustomizationRenderer(const WXL_Api* api) : api_(api) {}
void OnWorldEnter();
void OnWorldLeave();
void OnUpdate(float dt);
void OnEquipmentChanged(void* charModelObject);
void OnSkinFinalize(void* model);
void OnBuildBonePalette(void* renderContext);
bool Apply(uint32_t optionId, uint32_t choiceId);
bool PlayerDetected() const { return playerDetected_; }
bool Supported() const { return supported_; }
uint32_t Race() const { return race_; }
uint32_t Gender() const { return gender_; }
uint32_t CurrentChoice() const { return state_.Choice(kHornsOption.id); }
const char* LastError() const { return lastError_; }
private:
bool DetectPlayer();
bool Attach(const CustomizationChoice& choice);
void Detach();
bool FilterGeoset(void* model, uint32_t geosetId, bool finalize);
void SetError(const char* message);
const WXL_Api* api_ = nullptr;
CustomizationState state_;
void* observedCharModelObject_ = nullptr;
void* charModelObject_ = nullptr;
void* parentRenderContext_ = nullptr;
void* attachmentRenderContext_ = nullptr;
void* attachmentModel_ = nullptr;
uint32_t race_ = 0;
uint32_t gender_ = 0;
float detectDelay_ = 0.0f;
bool inWorld_ = false;
bool playerDetected_ = false;
bool supported_ = false;
bool refreshRequested_ = false;
bool geosetApplied_ = false;
bool attached_ = false;
bool finalizeInProgress_ = false;
const char* lastError_ = "Waiting for player";
};
}
@@ -1,29 +0,0 @@
#pragma once
#include "CustomizationData.hpp"
#include <cstdint>
namespace moonwell::customization
{
class CustomizationState
{
public:
uint32_t Choice(uint32_t optionId) const
{
return optionId == kHornsOption.id ? hornsChoice_ : 0;
}
bool SetChoice(uint32_t optionId, uint32_t choiceId)
{
if (optionId != kHornsOption.id || !FindChoice(choiceId)) return false;
hornsChoice_ = choiceId;
return true;
}
void Clear() { hornsChoice_ = 0; }
private:
uint32_t hornsChoice_ = 0;
};
}
@@ -1,115 +0,0 @@
#include "CustomizationRenderer.hpp"
#include <wxl/PluginApi.h>
#include "engine/events/Event.hpp"
#include <cstdint>
#include <cstdio>
#include <memory>
namespace moonwell::customization
{
namespace ev = wxl::events;
const WXL_Api* g_api = nullptr;
std::unique_ptr<CustomizationRenderer> g_renderer;
void __cdecl OnWorldEnter(void*, const void*) { g_renderer->OnWorldEnter(); }
void __cdecl OnWorldLeave(void*, const void*) { g_renderer->OnWorldLeave(); }
void __cdecl OnUpdate(void*, const void* raw)
{
const auto* args = static_cast<const ev::UpdateArgs*>(raw);
g_renderer->OnUpdate(args ? args->dt : 0.0f);
}
void __cdecl OnItemSlotChange(void*, const void* raw)
{
const auto* args = static_cast<const ev::ItemSlotChangeArgs*>(raw);
if (args) g_renderer->OnEquipmentChanged(args->charModelObj);
}
void __cdecl OnItemSlotClear(void*, const void* raw)
{
const auto* args = static_cast<const ev::ItemSlotClearArgs*>(raw);
if (args) g_renderer->OnEquipmentChanged(args->charModelObj);
}
void __cdecl OnSkinFinalize(void*, const void* raw)
{
const auto* args = static_cast<const ev::M2SkinFinalizeArgs*>(raw);
if (args) g_renderer->OnSkinFinalize(args->model);
}
void __cdecl OnBuildBonePalette(void*, const void* raw)
{
const auto* args = static_cast<const ev::BuildBonePaletteArgs*>(raw);
if (args) g_renderer->OnBuildBonePalette(args->renderCtx);
}
void DrawPanel(void*)
{
char line[128]{};
if (!g_renderer->PlayerDetected())
{
g_api->UiText("Player: detecting...");
g_api->UiText(g_renderer->LastError());
return;
}
std::snprintf(line, sizeof(line), "Race ID: %u%s", g_renderer->Race(),
g_renderer->Race() == kBloodElfRace ? " (Blood Elf)" : "");
g_api->UiText(line);
std::snprintf(line, sizeof(line), "Gender ID: %u%s", g_renderer->Gender(),
g_renderer->Gender() == kFemaleGender ? " (Female)" : "");
g_api->UiText(line);
g_api->UiSeparator();
if (!g_renderer->Supported())
{
g_api->UiText("Prototype supports Blood Elf Female only.");
return;
}
g_api->UiText("Option: Head Decorations (WotLK fallback)");
for (const auto& choice : kHornsOption.choices)
{
std::snprintf(line, sizeof(line), "%s%s", choice.name,
g_renderer->CurrentChoice() == choice.id ? " [selected]" : "");
if (g_api->UiButton(line))
g_renderer->Apply(kHornsOption.id, choice.id);
}
g_api->UiSeparator();
g_api->UiText(g_renderer->LastError());
}
}
const WXL_PluginInfo* __cdecl WXL_Query(void)
{
static const WXL_PluginInfo info = {
sizeof(WXL_PluginInfo), WXL_API_VERSION, "MoonwellCustomization", 1, WXL_CLIENT_BUILD,
};
return &info;
}
int __cdecl WXL_Load(const WXL_Api* api)
{
using namespace moonwell::customization;
if (!api || api->apiVersion != WXL_API_VERSION ||
api->structSize < sizeof(WXL_Api) || !api->Subscribe || !api->UiAddPanel)
return 0;
g_api = api;
g_renderer = std::make_unique<CustomizationRenderer>(api);
api->Subscribe(uint32_t(ev::Event::OnWorldEnter), &OnWorldEnter, nullptr);
api->Subscribe(uint32_t(ev::Event::OnWorldLeave), &OnWorldLeave, nullptr);
api->Subscribe(uint32_t(ev::Event::OnUpdate), &OnUpdate, nullptr);
api->Subscribe(uint32_t(ev::Event::OnItemSlotChange), &OnItemSlotChange, nullptr);
api->Subscribe(uint32_t(ev::Event::OnItemSlotClear), &OnItemSlotClear, nullptr);
api->Subscribe(uint32_t(ev::Event::OnM2SkinFinalize), &OnSkinFinalize, nullptr);
api->Subscribe(uint32_t(ev::Event::OnBuildBonePalette), &OnBuildBonePalette, nullptr);
api->UiAddPanel("MoonWell Customization", &DrawPanel, nullptr);
api->Log(WXL_LOG_INFO, "MoonwellCustomization", "loaded");
return 1;
}
+439 -17
View File
@@ -10,6 +10,7 @@
#include "SpellOverrides.hpp"
#include <windows.h>
#include <shellapi.h>
#include <array>
#include <cstddef>
@@ -17,6 +18,7 @@
#include <cstdio>
#include <cstring>
#include <cstdarg>
#include <cwchar>
namespace moonwell
{
@@ -69,6 +71,259 @@ namespace moonwell
constexpr uint32_t kTraitorFlag = 0x40000000u;
bool g_loginCharacterIsTraitor = false;
constexpr char kLaunchAccountVariable[] = "MOONWELL_LAUNCH_ACCOUNT";
constexpr char kLaunchTicketVariable[] = "MOONWELL_LAUNCH_TICKET";
constexpr char kDeveloperLoginVariable[] = "MOONWELL_DEV_LOGIN";
constexpr char kLauncherLoginMarker[] = "__MOONWELL_LAUNCHER__";
constexpr wchar_t kLauncherProtocol[] = L"moonwell://authorize?source=client";
constexpr std::array<const wchar_t*, 2> kLauncherExecutables = {
L"MoonWell.exe", L"MoonWellLauncher.exe",
};
constexpr size_t kMaxLaunchAccountLength = 320;
constexpr size_t kLaunchTicketLength = 16;
std::array<char, kMaxLaunchAccountLength + 1> g_launchAccount{};
std::array<char, kLaunchTicketLength + 1> g_launchTicket{};
volatile LONG g_hasLauncherAuth = 0;
bool g_launchedWithTicket = false;
bool g_developerLogin = false;
volatile LONG g_launcherLoginState = 0; // 0=pending, 1=claimed, 2=submitted
DWORD g_launcherAuthCapturedAt = 0;
DWORD g_clientThreadId = 0;
template <size_t Size>
bool ConsumeEnvironmentVariable(const char* name, std::array<char, Size>& output)
{
SetLastError(ERROR_SUCCESS);
const DWORD required = GetEnvironmentVariableA(name, nullptr, 0);
if (!required)
{
SetEnvironmentVariableA(name, nullptr);
return false;
}
if (required > output.size())
{
SetEnvironmentVariableA(name, nullptr);
WLOG_ERROR("moonwell: rejected oversized launcher environment value %s", name);
return false;
}
const DWORD written = GetEnvironmentVariableA(
name, output.data(), static_cast<DWORD>(output.size()));
SetEnvironmentVariableA(name, nullptr);
return written > 0 && written < output.size();
}
template <size_t Size>
bool IsPrintableAscii(const std::array<char, Size>& value)
{
for (const unsigned char character : value)
{
if (character == 0)
return true;
if (character < 0x21 || character > 0x7e)
return false;
}
return false;
}
bool IsValidLauncherTicket()
{
if (std::strlen(g_launchTicket.data()) != kLaunchTicketLength)
return false;
for (size_t index = 0; index < kLaunchTicketLength; ++index)
{
const char character = g_launchTicket[index];
if (!((character >= 'A' && character <= 'Z')
|| (character >= '0' && character <= '9')))
return false;
}
return true;
}
void ClearLauncherAuth()
{
SecureZeroMemory(g_launchAccount.data(), g_launchAccount.size());
SecureZeroMemory(g_launchTicket.data(), g_launchTicket.size());
InterlockedExchange(&g_hasLauncherAuth, 0);
}
void CaptureLaunchEnvironment()
{
const bool hasAccount = ConsumeEnvironmentVariable(
kLaunchAccountVariable, g_launchAccount);
const bool hasTicket = ConsumeEnvironmentVariable(
kLaunchTicketVariable, g_launchTicket);
g_hasLauncherAuth = hasAccount && hasTicket
&& IsPrintableAscii(g_launchAccount)
&& IsValidLauncherTicket() ? 1 : 0;
InterlockedExchange(&g_launcherLoginState, 0);
g_launchedWithTicket = g_hasLauncherAuth != 0;
g_launcherAuthCapturedAt = g_hasLauncherAuth ? GetTickCount() : 0;
if (!g_hasLauncherAuth)
{
if (hasAccount || hasTicket)
WLOG_ERROR("moonwell: incomplete or invalid launcher authorization data");
ClearLauncherAuth();
}
std::array<char, 8> developerValue{};
const bool developerRequested = ConsumeEnvironmentVariable(
kDeveloperLoginVariable, developerValue);
#if defined(_DEBUG)
g_developerLogin = developerRequested
&& std::strcmp(developerValue.data(), "1") == 0;
#else
g_developerLogin = false;
#endif
SecureZeroMemory(developerValue.data(), developerValue.size());
if (g_hasLauncherAuth)
WLOG_INFO("moonwell: launcher authorization data accepted");
else if (g_developerLogin)
WLOG_INFO("moonwell: developer login enabled for this Debug build");
}
int __cdecl GetLaunchMode(void* state)
{
const char* mode = g_launchedWithTicket
? "launcher"
: (g_developerLogin ? "developer" : "locked");
wxl::game::script::PushString(state, mode);
return 1;
}
int __cdecl ConsumeLauncherAuth(void* state)
{
constexpr uintptr_t kLoginGlueReady = 0x00B6B474;
constexpr uintptr_t kLoginServerHost = 0x00B6AF54;
constexpr uintptr_t kLoginServerPort = 0x00B6AF5C;
constexpr uintptr_t kLoginBusy = 0x00B6AA38;
if (!g_hasLauncherAuth
|| !*reinterpret_cast<volatile uint8_t*>(kLoginGlueReady)
|| !*reinterpret_cast<void* volatile*>(kLoginServerHost)
|| !*reinterpret_cast<void* volatile*>(kLoginServerPort)
|| *reinterpret_cast<volatile uint32_t*>(kLoginBusy))
return 0;
if (InterlockedCompareExchange(&g_launcherLoginState, 1, 0) != 0)
return 0;
if (!g_hasLauncherAuth)
{
InterlockedExchange(&g_launcherLoginState, 0);
return 0;
}
wxl::game::script::PushString(state, g_launchAccount.data());
wxl::game::script::PushString(state, g_launchTicket.data());
ClearLauncherAuth();
InterlockedExchange(&g_launcherLoginState, 2);
WLOG_INFO("moonwell: launcher authorization consumed by Lua bridge");
return 2;
}
bool TryOpenAdjacentLauncher()
{
std::array<wchar_t, MAX_PATH> path{};
const DWORD length = GetModuleFileNameW(nullptr, path.data(),
static_cast<DWORD>(path.size()));
if (!length || length >= path.size())
return false;
wchar_t* slash = std::wcsrchr(path.data(), L'\\');
if (!slash)
return false;
++slash;
const size_t prefixLength = static_cast<size_t>(slash - path.data());
for (const wchar_t* executable : kLauncherExecutables)
{
const size_t launcherLength = std::wcslen(executable);
if (prefixLength + launcherLength >= path.size())
continue;
std::wmemcpy(slash, executable, launcherLength + 1);
if (GetFileAttributesW(path.data()) == INVALID_FILE_ATTRIBUTES)
continue;
if (reinterpret_cast<INT_PTR>(ShellExecuteW(
nullptr, L"open", path.data(), nullptr, nullptr, SW_SHOWNORMAL)) > 32)
return true;
}
return false;
}
int __cdecl OpenLauncher(void* state)
{
bool opened = TryOpenAdjacentLauncher();
if (!opened)
{
opened = reinterpret_cast<INT_PTR>(ShellExecuteW(
nullptr, L"open", kLauncherProtocol, nullptr, nullptr, SW_SHOWNORMAL)) > 32;
}
wxl::game::script::PushBoolean(state, opened);
return 1;
}
using DefaultServerLoginCallbackFn = int(__cdecl*)(void*);
using BeginServerLoginFn = void(__cdecl*)(const char*, const char*);
DefaultServerLoginCallbackFn g_nextDefaultServerLogin = nullptr;
bool SubmitLauncherAuthorization()
{
if (!g_hasLauncherAuth || g_launcherLoginState != 0)
return false;
constexpr uintptr_t kBeginServerLogin = 0x004D8A30;
constexpr uintptr_t kLoginGlueReady = 0x00B6B474;
constexpr uintptr_t kLoginServerHost = 0x00B6AF54;
constexpr uintptr_t kLoginServerPort = 0x00B6AF5C;
constexpr uintptr_t kLoginBusy = 0x00B6AA38;
if (!*reinterpret_cast<volatile uint8_t*>(kLoginGlueReady)
|| !*reinterpret_cast<void* volatile*>(kLoginServerHost)
|| !*reinterpret_cast<void* volatile*>(kLoginServerPort)
|| *reinterpret_cast<volatile uint32_t*>(kLoginBusy))
return false;
if (InterlockedCompareExchange(&g_launcherLoginState, 1, 0) != 0)
return false;
if (!g_hasLauncherAuth)
{
InterlockedExchange(&g_launcherLoginState, 0);
return false;
}
reinterpret_cast<BeginServerLoginFn>(kBeginServerLogin)(
g_launchAccount.data(), g_launchTicket.data());
if (!*reinterpret_cast<volatile uint32_t*>(kLoginBusy))
{
InterlockedExchange(&g_launcherLoginState, 0);
return false;
}
ClearLauncherAuth();
InterlockedExchange(&g_launcherLoginState, 2);
WLOG_INFO("moonwell: launcher authorization submitted to login engine");
return true;
}
int __cdecl DefaultServerLoginHook(void* state)
{
const char* account = wxl::game::script::IsString(state, 1)
? wxl::game::script::ToString(state, 1)
: nullptr;
if (!account || std::strcmp(account, kLauncherLoginMarker) != 0)
return g_nextDefaultServerLogin ? g_nextDefaultServerLogin(state) : 0;
SubmitLauncherAuthorization();
return 0;
}
using GxSetProjectionFn = void(__fastcall*)(void* self, void* edx, const void* projection);
GxSetProjectionFn g_nextSetProjection = nullptr;
@@ -439,55 +694,207 @@ namespace moonwell
using RegisterFunctionFn = void(__cdecl*)(const char*, wxl::game::script::Function);
using ValidateCallbackFn = void(__cdecl*)(uintptr_t);
using GetContextFn = void*(__cdecl*)();
RegisterFunctionFn g_nextRegisterFunction = nullptr;
using ExecuteFn = void(__cdecl*)(const char*, uintptr_t, uintptr_t);
using InitializeLuaFn = int(__cdecl*)(void*);
using FramePumpFn = void(__cdecl*)(float, uint32_t);
using FileOpenFn = int(__stdcall*)(void*, const char*, uint32_t, void**);
using GlueModelRenderFn = void(__cdecl*)(void*);
ValidateCallbackFn g_nextValidateCallback = nullptr;
void* g_registeredState = nullptr;
ExecuteFn g_nextExecute = nullptr;
InitializeLuaFn g_nextInitializeLua = nullptr;
FramePumpFn g_nextFramePump = nullptr;
FileOpenFn g_nextFileOpen = nullptr;
GlueModelRenderFn g_nextGlueModelRender = nullptr;
PVOID volatile g_registeredState = nullptr;
bool g_registeringMoonWell = false;
bool IsMoonWellCallback(uintptr_t callback)
{
return callback == reinterpret_cast<uintptr_t>(&SetLoginCharacterFlags)
|| callback == reinterpret_cast<uintptr_t>(&IsTraitor)
|| callback == reinterpret_cast<uintptr_t>(&SetCharacterCreateCamera);
|| callback == reinterpret_cast<uintptr_t>(&SetCharacterCreateCamera)
|| callback == reinterpret_cast<uintptr_t>(&GetLaunchMode)
|| callback == reinterpret_cast<uintptr_t>(&ConsumeLauncherAuth)
|| callback == reinterpret_cast<uintptr_t>(&OpenLauncher);
}
void RegisterLuaFunctionsForCurrentState();
void __cdecl ValidateCallbackHook(uintptr_t callback)
{
RegisterLuaFunctionsForCurrentState();
if (!IsMoonWellCallback(callback) && g_nextValidateCallback)
g_nextValidateCallback(callback);
}
void RegisterLuaFunctionsForCurrentState()
void RegisterLuaFunctions(void* state)
{
if (!g_nextRegisterFunction || g_registeringMoonWell) return;
constexpr uintptr_t kGetContext = 0x00817DB0;
void* state = reinterpret_cast<GetContextFn>(kGetContext)();
if (g_registeringMoonWell) return;
if (!state || state == g_registeredState) return;
constexpr uintptr_t kRegisterFunction = 0x00817F90;
const auto registrar = reinterpret_cast<RegisterFunctionFn>(kRegisterFunction);
g_registeringMoonWell = true;
g_nextRegisterFunction("MoonWellSetLoginCharacterFlags", &SetLoginCharacterFlags);
g_nextRegisterFunction("MoonWellIsTraitor", &IsTraitor);
g_nextRegisterFunction("MoonWellSetCharacterCreateCamera", &SetCharacterCreateCamera);
registrar("MoonWellSetLoginCharacterFlags", &SetLoginCharacterFlags);
registrar("MoonWellIsTraitor", &IsTraitor);
registrar("MoonWellSetCharacterCreateCamera", &SetCharacterCreateCamera);
registrar("MoonWellGetLaunchMode", &GetLaunchMode);
registrar("MoonWellConsumeLauncherAuth", &ConsumeLauncherAuth);
registrar("MoonWellOpenLauncher", &OpenLauncher);
g_registeringMoonWell = false;
g_registeredState = state;
InterlockedExchangePointer(&g_registeredState, state);
WLOG_INFO("moonwell: Lua functions registered for state %p", state);
}
void __cdecl RegisterFunctionHook(const char* name, wxl::game::script::Function function)
void RegisterLuaFunctionsForCurrentState()
{
constexpr uintptr_t kGetContext = 0x00817DB0;
RegisterLuaFunctions(reinterpret_cast<GetContextFn>(kGetContext)());
}
void RegisterLuaFunctionsBeforeHooksEnable()
{
if (g_nextRegisterFunction) g_nextRegisterFunction(name, function);
RegisterLuaFunctionsForCurrentState();
}
void __cdecl OnClientMessage(void*, const void*)
{
RegisterLuaFunctionsForCurrentState();
}
struct WindowSearch
{
DWORD processId;
HWND window;
};
BOOL CALLBACK FindClientWindow(HWND window, LPARAM parameter)
{
auto* search = reinterpret_cast<WindowSearch*>(parameter);
DWORD processId = 0;
GetWindowThreadProcessId(window, &processId);
if (processId != search->processId || !IsWindowVisible(window))
return TRUE;
search->window = window;
return FALSE;
}
DWORD WINAPI ScheduleLuaBootstrap(LPVOID)
{
HWND clientWindow = nullptr;
WLOG_INFO("moonwell: Lua bootstrap scheduler started");
for (unsigned attempt = 0; attempt < 600; ++attempt)
{
if (!clientWindow)
{
clientWindow = FindWindowW(L"GxWindowClass", nullptr);
DWORD ownerProcessId = 0;
if (clientWindow)
GetWindowThreadProcessId(clientWindow, &ownerProcessId);
if (ownerProcessId != GetCurrentProcessId())
clientWindow = nullptr;
if (!clientWindow)
{
WindowSearch search{GetCurrentProcessId(), nullptr};
EnumWindows(&FindClientWindow, reinterpret_cast<LPARAM>(&search));
clientWindow = search.window;
}
}
if (clientWindow)
{
WLOG_INFO("moonwell: client window %p found for Lua bootstrap", clientWindow);
// BeginServerLogin only snapshots the supplied credentials and starts
// the client's asynchronous login state machine. Waiting here keeps the
// call clear of engine initialization and Glue archive mounting.
for (unsigned attempt = 0;
attempt < 80 && g_hasLauncherAuth;
++attempt)
{
Sleep(250);
SubmitLauncherAuthorization();
}
return g_hasLauncherAuth ? 1 : 0;
}
Sleep(100);
}
WLOG_ERROR("moonwell: timed out waiting for UI-thread Lua bootstrap");
return 1;
}
void __cdecl ExecuteHook(const char* source, uintptr_t argument2, uintptr_t argument3)
{
RegisterLuaFunctionsForCurrentState();
if (g_nextExecute) g_nextExecute(source, argument2, argument3);
}
int __cdecl InitializeLuaHook(void* allocatorContext)
{
const int initialized = g_nextInitializeLua
? g_nextInitializeLua(allocatorContext)
: 0;
if (initialized)
RegisterLuaFunctionsForCurrentState();
return initialized;
}
void __cdecl FramePumpHook(float deltaSeconds, uint32_t frameTimeMs)
{
RegisterLuaFunctionsForCurrentState();
if (g_nextFramePump) g_nextFramePump(deltaSeconds, frameTimeMs);
RegisterLuaFunctionsForCurrentState();
if (g_hasLauncherAuth
&& GetTickCount() - g_launcherAuthCapturedAt >= 3000)
SubmitLauncherAuthorization();
}
int __stdcall FileOpenHook(void* archive, const char* name, uint32_t flags, void** out)
{
// The engine-init callback and synchronous Glue loader run on the same client
// thread. Polling here catches Lua becoming live before AccountLogin executes,
// without ever touching the state from background asset I/O workers.
if (GetCurrentThreadId() == g_clientThreadId)
RegisterLuaFunctionsForCurrentState();
const int result = g_nextFileOpen ? g_nextFileOpen(archive, name, flags, out) : 0;
return result;
}
void __cdecl GlueModelRenderHook(void* frame)
{
RegisterLuaFunctionsForCurrentState();
if (g_nextGlueModelRender) g_nextGlueModelRender(frame);
}
void __cdecl OnFrame(void*, const void*)
{
RegisterLuaFunctionsForCurrentState();
// Glue scripting is not a reliable authentication trigger: a broken
// cosmetic widget can abort AccountLogin_OnShow before it reaches the
// login call. Present is emitted on the client thread after the engine
// and Glue subsystem are ready, so submit once after a short grace period.
if (g_hasLauncherAuth
&& GetTickCount() - g_launcherAuthCapturedAt >= 3000)
SubmitLauncherAuthorization();
}
bool InstallLuaBridge()
{
const bool validator = HookByName("Lua.ValidateFunctionPointer", &ValidateCallbackHook,
&g_nextValidateCallback);
const bool registrar = HookByName("Lua.RegisterFunction", &RegisterFunctionHook,
&g_nextRegisterFunction);
if (!validator || !registrar)
const bool executor = HookByName("Lua.Execute", &ExecuteHook, &g_nextExecute);
const bool initializer = Hook("Lua.Initialize", 0x00819BB0,
&InitializeLuaHook, &g_nextInitializeLua);
const bool defaultLogin = Hook("MoonWell.DefaultServerLogin", 0x004DC260,
&DefaultServerLoginHook, &g_nextDefaultServerLogin);
const bool framePump = HookByName("Frame.Pump", &FramePumpHook, &g_nextFramePump);
const bool fileOpen = HookByName("Io.FileOpen", &FileOpenHook, &g_nextFileOpen);
const bool glueRender = HookByName("Gx.GlueModelRender", &GlueModelRenderHook,
&g_nextGlueModelRender);
if (!validator || !executor || !initializer || !defaultLogin
|| !framePump || !fileOpen || !glueRender)
WLOG_ERROR("moonwell: Lua bridge hook installation failed");
return validator && registrar;
return validator && executor && initializer && defaultLogin
&& framePump && fileOpen && glueRender;
}
}
}
@@ -504,9 +911,24 @@ int __cdecl WXL_Load(const WXL_Api* api)
{
if (!api || api->apiVersion != WXL_API_VERSION) return 0;
moonwell::g_api = api;
moonwell::g_clientThreadId = GetCurrentThreadId();
moonwell::CaptureLaunchEnvironment();
moonwell::InstallBoot();
const bool lua = moonwell::InstallLuaBridge();
// Event ordinal 5 is wxl::events::Event::OnFrame in API v1. It is emitted
// from Present on the client thread even while only the Glue UI is active.
if (api->Subscribe)
{
api->Subscribe(5, &moonwell::OnFrame, nullptr);
api->Subscribe(17, &moonwell::OnClientMessage, nullptr);
}
moonwell::RegisterLuaFunctionsBeforeHooksEnable();
if (HANDLE bootstrapThread = CreateThread(
nullptr, 0, &moonwell::ScheduleLuaBootstrap, nullptr, 0, nullptr))
CloseHandle(bootstrapThread);
else
api->Log(WXL_LOG_ERROR, "MoonWell", "%s", "failed to start Lua bootstrap scheduler");
moonwell::InstallCharacterCreateCamera();
moonwell::InstallEncounterJournalModelPreview();
const bool spells = moonwell::spells::Install(api);
+26
View File
@@ -0,0 +1,26 @@
{
"repositoryPatches": [
"Data/patch-4.MPQ",
"Data/patch-Z.MPQ",
"Data/ruRU/patch-ruRU-4.MPQ",
"Data/ruRU/patch-ruRU-5.MPQ",
"Data/ruRU/patch-ruRU-Z.MPQ"
],
"graphicsPatches": [
"Data/patch-X.MPQ",
"Data/ruRU/backup-ruRU.MPQ",
"Data/ruRU/patch-ruRU-6.MPQ",
"Data/ruRU/patch-ruRU-A.mpq",
"Data/ruRU/patch-ruRU-B.mpq",
"Data/ruRU/patch-ruRU-E.mpq",
"Data/ruRU/patch-ruRU-H.MPQ",
"Data/ruRU/patch-ruRU-I.mpq",
"Data/ruRU/patch-ruRU-M.MPQ",
"Data/ruRU/patch-ruRU-S.mpq",
"Data/ruRU/patch-ruRU-T.mpq",
"Data/ruRU/patch-ruRU-U.mpq",
"Data/ruRU/patch-ruRU-W.mpq",
"Data/ruRU/patch-ruRU-X.MPQ",
"Data/ruRU/patch-ruRU-Y.MPQ"
]
}
+36
View File
@@ -0,0 +1,36 @@
#!/usr/bin/env python3
import json
from dataclasses import dataclass
from pathlib import Path
def normalized_path(path: str) -> str:
return path.replace("\\", "/").casefold()
@dataclass(frozen=True)
class PatchLayout:
repository_patches: frozenset[str]
graphics_patches: frozenset[str]
def is_repository_patch(self, path: str) -> bool:
return normalized_path(path) in self.repository_patches
def is_graphics_patch(self, path: str) -> bool:
return normalized_path(path) in self.graphics_patches
def is_external_graphics_patch(self, path: str) -> bool:
key = normalized_path(path)
return key in self.graphics_patches and key not in self.repository_patches
def load_patch_layout(path: Path | None = None) -> PatchLayout:
config_path = path or Path(__file__).resolve().with_name("patch-layout.json")
data = json.loads(config_path.read_text(encoding="utf-8"))
repository = frozenset(normalized_path(item) for item in data["repositoryPatches"])
graphics = frozenset(normalized_path(item) for item in data["graphicsPatches"])
return PatchLayout(repository_patches=repository, graphics_patches=graphics)
PATCH_LAYOUT = load_patch_layout()
+254
View File
@@ -0,0 +1,254 @@
$script:MoonWellExtensionNames = @(
'MoonWell',
'wxl-db2',
'wxl-fdid-moonwell',
'wxl-grasswind',
'wxl-modern-adt',
'wxl-modern-blp',
'wxl-modern-m2',
'wxl-modern-wmo',
'wxl-moonwell-storage-fallback',
'wxl-unit-outline'
)
$patchLayoutPath = Join-Path $PSScriptRoot 'patch-layout.json'
if (-not (Test-Path -LiteralPath $patchLayoutPath -PathType Leaf)) {
throw "Patch layout was not found: $patchLayoutPath"
}
$patchLayout = Get-Content -LiteralPath $patchLayoutPath -Raw -Encoding UTF8 | ConvertFrom-Json
$script:MoonWellRepositoryPatches = @($patchLayout.repositoryPatches)
$script:MoonWellGraphicsPatches = @($patchLayout.graphicsPatches)
function Get-MoonWellExtensionNames {
return @($script:MoonWellExtensionNames)
}
function Get-MoonWellRepositoryPatchPaths {
return @($script:MoonWellRepositoryPatches)
}
function Get-MoonWellGraphicsPatchPaths {
return @($script:MoonWellGraphicsPatches)
}
function Test-MoonWellRepositoryPatch {
param([Parameter(Mandatory)][string]$RelativePath)
foreach ($repositoryPatch in $script:MoonWellRepositoryPatches) {
if ($RelativePath.Replace('/', '\').Equals(
$repositoryPatch.Replace('/', '\'),
[System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
function Test-MoonWellGraphicsPatch {
param([Parameter(Mandatory)][string]$RelativePath)
foreach ($graphicsPatch in $script:MoonWellGraphicsPatches) {
if ($RelativePath.Replace('/', '\').Equals(
$graphicsPatch.Replace('/', '\'),
[System.StringComparison]::OrdinalIgnoreCase)) {
return $true
}
}
return $false
}
function Test-MoonWellPreservedDataPatch {
param([Parameter(Mandatory)][string]$RelativePath)
if (Test-MoonWellRepositoryPatch -RelativePath $RelativePath) {
return $false
}
return Test-MoonWellGraphicsPatch -RelativePath $RelativePath
}
function Assert-SafePackagePath {
param(
[Parameter(Mandatory)]
[string]$PackagePath,
[Parameter(Mandatory)]
[string]$RepositoryPath,
[Parameter()]
[string]$ClientPath
)
$packageRoot = [System.IO.Path]::GetFullPath($PackagePath).TrimEnd('\')
$repositoryRoot = [System.IO.Path]::GetFullPath($RepositoryPath).TrimEnd('\')
$pathRoot = [System.IO.Path]::GetPathRoot($packageRoot).TrimEnd('\')
if (-not $packageRoot -or $packageRoot -eq $pathRoot) {
throw "Refusing to use a filesystem root as package staging: $packageRoot"
}
if ($packageRoot.Equals($repositoryRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
$repositoryRoot.StartsWith($packageRoot + '\', [System.StringComparison]::OrdinalIgnoreCase)) {
throw 'Package staging must not be the repository root or one of its ancestors.'
}
if (-not [string]::IsNullOrWhiteSpace($ClientPath)) {
$clientRoot = [System.IO.Path]::GetFullPath($ClientPath).TrimEnd('\')
$packageIsClientOrChild =
$packageRoot.Equals($clientRoot, [System.StringComparison]::OrdinalIgnoreCase) -or
$packageRoot.StartsWith($clientRoot + '\', [System.StringComparison]::OrdinalIgnoreCase)
$packageContainsClient =
$clientRoot.StartsWith($packageRoot + '\', [System.StringComparison]::OrdinalIgnoreCase)
if ($packageIsClientOrChild -or $packageContainsClient) {
throw 'Package staging must be separate from the installed client tree.'
}
}
}
function Get-RelativeFilePaths {
param([Parameter(Mandatory)][string]$Root)
if (-not (Test-Path -LiteralPath $Root -PathType Container)) {
return @()
}
$absoluteRoot = [System.IO.Path]::GetFullPath($Root).TrimEnd('\')
return @(
Get-ChildItem -LiteralPath $absoluteRoot -Recurse -Force -File |
ForEach-Object { $_.FullName.Substring($absoluteRoot.Length + 1) }
)
}
function Remove-EmptyMoonWellDirectories {
param([Parameter(Mandatory)][string]$Destination)
foreach ($relativeRoot in @('Data\Patch-WXL.MPQ', 'Extensions', 'Utils')) {
$managedRoot = Join-Path $Destination $relativeRoot
if (-not (Test-Path -LiteralPath $managedRoot -PathType Container)) {
continue
}
$directories = @(
Get-ChildItem -LiteralPath $managedRoot -Recurse -Force -Directory |
Sort-Object { $_.FullName.Length } -Descending
)
$directories += Get-Item -LiteralPath $managedRoot
foreach ($directory in $directories) {
if (-not @(Get-ChildItem -LiteralPath $directory.FullName -Force).Count) {
Remove-Item -LiteralPath $directory.FullName -Force
Write-Host "Removed empty managed directory: $($directory.FullName)"
}
}
}
}
function Reset-MoonWellPackageStaging {
param(
[Parameter(Mandatory)][string]$PackagePath,
[Parameter(Mandatory)][string]$RepositoryPath,
[Parameter(Mandatory)][string]$ClientPath
)
Assert-SafePackagePath `
-PackagePath $PackagePath `
-RepositoryPath $RepositoryPath `
-ClientPath $ClientPath
if (Test-Path -LiteralPath $PackagePath) {
Remove-Item -LiteralPath $PackagePath -Recurse -Force
}
New-Item -ItemType Directory -Path $PackagePath -Force | Out-Null
}
function Remove-MoonWellRuntimeGarbage {
param([Parameter(Mandatory)][string]$Destination)
if (-not (Test-Path -LiteralPath $Destination -PathType Container)) {
return
}
$allowedExtensions = @{}
foreach ($name in $script:MoonWellExtensionNames) {
$allowedExtensions[$name.ToLowerInvariant()] = $true
}
$extensionsRoot = Join-Path $Destination 'Extensions'
if (Test-Path -LiteralPath $extensionsRoot -PathType Container) {
foreach ($entry in Get-ChildItem -LiteralPath $extensionsRoot -Force) {
$isAllowedDirectory = $entry.PSIsContainer -and
$allowedExtensions.ContainsKey($entry.Name.ToLowerInvariant())
if (-not $isAllowedDirectory) {
Remove-Item -LiteralPath $entry.FullName -Recurse -Force
Write-Host "Removed unmanaged WarcraftXL extension artifact: $($entry.FullName)"
continue
}
foreach ($dll in Get-ChildItem -LiteralPath $entry.FullName -Force -File -Filter '*.dll') {
$expectedName = "$($entry.Name).dll"
if (-not $dll.Name.Equals($expectedName, [System.StringComparison]::OrdinalIgnoreCase)) {
Remove-Item -LiteralPath $dll.FullName -Force
Write-Host "Removed stale extension DLL: $($dll.FullName)"
}
}
}
}
foreach ($dll in Get-ChildItem -LiteralPath $Destination -Force -File -Filter 'WarcraftXL*.dll') {
if (-not $dll.Name.Equals('WarcraftXL.dll', [System.StringComparison]::OrdinalIgnoreCase)) {
Remove-Item -LiteralPath $dll.FullName -Force
Write-Host "Removed stale WarcraftXL DLL: $($dll.FullName)"
}
}
$utilsRoot = Join-Path $Destination 'Utils'
if (Test-Path -LiteralPath $utilsRoot -PathType Container) {
foreach ($legacyFile in Get-ChildItem -LiteralPath $utilsRoot -Force -File -Filter 'WarcraftXLHost*') {
Remove-Item -LiteralPath $legacyFile.FullName -Force
Write-Host "Removed obsolete WarcraftXL 1.0 host artifact: $($legacyFile.FullName)"
}
}
# GlueXML is shipped by patch-ruRU-4.MPQ. Historical development copies in
# the client root override that archive and can restore the password form.
foreach ($relativePath in @(
'Interface\GlueXML\AccountLogin.lua',
'Interface\GlueXML\AccountLogin.xml'
)) {
$legacyOverride = Join-Path $Destination $relativePath
if (Test-Path -LiteralPath $legacyOverride -PathType Leaf) {
Remove-Item -LiteralPath $legacyOverride -Force
Write-Host "Removed obsolete loose GlueXML override: $legacyOverride"
}
}
Remove-EmptyMoonWellDirectories -Destination $Destination
}
function Remove-StalePackageFilesFromClient {
param(
[Parameter(Mandatory)][string]$ClientPath,
[Parameter(Mandatory)][AllowEmptyCollection()][string[]]$PreviousFiles,
[Parameter(Mandatory)][AllowEmptyCollection()][string[]]$CurrentFiles
)
$current = @{}
foreach ($relativePath in $CurrentFiles) {
$current[$relativePath.ToLowerInvariant()] = $true
}
$clientRoot = [System.IO.Path]::GetFullPath($ClientPath).TrimEnd('\')
foreach ($relativePath in $PreviousFiles) {
if ($current.ContainsKey($relativePath.ToLowerInvariant()) -or
(Test-MoonWellPreservedDataPatch -RelativePath $relativePath)) {
continue
}
$target = [System.IO.Path]::GetFullPath((Join-Path $clientRoot $relativePath))
if (-not $target.StartsWith($clientRoot + '\', [System.StringComparison]::OrdinalIgnoreCase)) {
throw "Package inventory contains a path outside the client: $relativePath"
}
if (Test-Path -LiteralPath $target -PathType Leaf) {
Remove-Item -LiteralPath $target -Force
Write-Host "Removed stale packaged file from client: $target"
}
}
Remove-EmptyMoonWellDirectories -Destination $ClientPath
}
+1
View File
@@ -0,0 +1 @@
dvc[s3]==3.67.1
+50 -5
View File
@@ -1,7 +1,8 @@
param(
[Parameter(Mandatory=$false)]
[ValidateSet("production", "ptr", "local")]
[string]$Env = "local"
[string]$Env = "local",
[switch]$DeveloperLogin
)
# Stop on errors
@@ -9,6 +10,7 @@ $ErrorActionPreference = "Stop"
# --- Resolve project root (folder where script is located)
$ROOT = Split-Path -Parent $MyInvocation.MyCommand.Path
. (Join-Path $ROOT 'pipeline-layout.ps1')
# --- Load .env if WOW_HOME not already set
if (-not $env:WOW_HOME) {
@@ -67,9 +69,16 @@ 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..."
$elevationArguments = @(
"-NoProfile", "-ExecutionPolicy", "Bypass", "-File",
"`"$($MyInvocation.MyCommand.Path)`"", "-Env", $Env
)
if ($DeveloperLogin) {
$elevationArguments += "-DeveloperLogin"
}
$elevatedProcess = Start-Process -FilePath "powershell.exe" -Verb RunAs -PassThru -Wait `
-WorkingDirectory $ROOT `
-ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$($MyInvocation.MyCommand.Path)`"", "-Env", $Env
-ArgumentList $elevationArguments
exit $elevatedProcess.ExitCode
}
@@ -80,6 +89,11 @@ $TOOL = Join-Path $ROOT "tool\target\release\tool.exe"
$RELOAD_SCRIPT = Join-Path $ROOT "reload_wow.bat"
$WXL_BUILD_SCRIPT = Join-Path $ROOT "build-warcraftxl.ps1"
Assert-SafePackagePath `
-PackagePath $DIST_DIR `
-RepositoryPath $ROOT `
-ClientPath $WOW_HOME
function Stop-WowRuntime {
param([Parameter(Mandatory=$true)][string]$ClientPath)
@@ -118,7 +132,14 @@ if (!(Test-Path $TOOL)) {
Pop-Location
}
# --- Build MPQ archives into dist/
# --- Build MPQ archives into a clean dist/
$previousPackageFiles = @(
Get-RelativeFilePaths -Root $DIST_DIR
)
Reset-MoonWellPackageStaging `
-PackagePath $DIST_DIR `
-RepositoryPath $ROOT `
-ClientPath $WOW_HOME
Write-Host "Building MPQ archives from src/ -> dist/..."
& $TOOL $SRC_DIR $DIST_DIR
if ($LASTEXITCODE -ne 0) {
@@ -136,7 +157,8 @@ if (!(Test-Path (Join-Path $DIST_DIR "Data"))) {
# 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 `
$runtimeConfiguration = if ($DeveloperLogin) { "Debug" } else { "Release" }
& $WXL_BUILD_SCRIPT -Configuration $runtimeConfiguration -ClientPath $WOW_HOME `
-PackagePath $DIST_DIR -Deploy
if ($LASTEXITCODE -ne 0) {
Write-Error "WarcraftXL build/deploy failed!"
@@ -145,6 +167,22 @@ if ($LASTEXITCODE -ne 0) {
# --- Sync dist/ -> WOW_HOME
Write-Host "Syncing dist/ -> WOW_HOME..."
$python = Get-Command python -ErrorAction SilentlyContinue
if (-not $python) {
Write-Error "Python is required for package validation."
exit 1
}
& $python.Source (Join-Path $ROOT 'validate_package.py') --dir $DIST_DIR
if ($LASTEXITCODE -ne 0) {
Write-Error "Package validation failed!"
exit 1
}
$currentPackageFiles = @(Get-RelativeFilePaths -Root $DIST_DIR)
Remove-StalePackageFilesFromClient `
-ClientPath $WOW_HOME `
-PreviousFiles $previousPackageFiles `
-CurrentFiles $currentPackageFiles
Remove-MoonWellRuntimeGarbage -Destination $WOW_HOME
robocopy $DIST_DIR $WOW_HOME /E /NFL /NDL
# robocopy exit codes 0-7 are success/warnings; 8+ are errors
if ($LASTEXITCODE -ge 8) {
@@ -171,4 +209,11 @@ if ($realmlist) {
# --- Run WoW reload script
Write-Host "Launching WoW..."
cmd /c $RELOAD_SCRIPT
if ($DeveloperLogin) {
$env:MOONWELL_DEV_LOGIN = "1"
}
try {
cmd /c $RELOAD_SCRIPT
} finally {
Remove-Item Env:MOONWELL_DEV_LOGIN -ErrorAction SilentlyContinue
}
@@ -43,7 +43,8 @@ local Config = {
}
local LoginState = {
autoLoginTimer = nil, autoLoginDelay = Config.AUTO_LOGIN_DELAY, autoLoginAttempted = false, musicTimer = 0, sceneTimer = 0, currentFrame = 0, lastUpdateTime = 0, currentScene = 1
autoLoginTimer = nil, autoLoginDelay = Config.AUTO_LOGIN_DELAY, autoLoginAttempted = false, musicTimer = 0, sceneTimer = 0, currentFrame = 0, lastUpdateTime = 0, currentScene = 1,
launchMode = "locked", launcherLoginAttempted = false, luaBridgePending = true
}
local ModelManager = {
@@ -51,7 +52,7 @@ local ModelManager = {
}
local UICache = {
accountEdit = nil, passwordEdit = nil, saveAccountName = nil, savePassword = nil, autoLogin = nil, autoLoginText = nil, loginButton = nil, versionText = nil, realmName = nil, upgradeButton = nil, tosFrame = nil, tosAccept = nil, tosDecline = nil, backgroundTexture = nil, animatedTexture = nil, newLogo = nil, newLogoFrame = nil
accountEdit = nil, passwordEdit = nil, saveAccountName = nil, savePassword = nil, autoLogin = nil, autoLoginText = nil, saveOptionsFrame = nil, launcherHint = nil, loginButton = nil, versionText = nil, realmName = nil, upgradeButton = nil, tosFrame = nil, tosAccept = nil, tosDecline = nil, backgroundTexture = nil, animatedTexture = nil, newLogo = nil, newLogoFrame = nil
}
LOGIN_MODEL_LIGHTS = {
@@ -78,6 +79,8 @@ local function InitializeUICache()
UICache.savePassword = _G["AccountLoginSavePassword"]
UICache.autoLogin = _G["AccountLoginAutoLogin"]
UICache.autoLoginText = _G["AccountLoginAutoLoginText"]
UICache.saveOptionsFrame = _G["AccountLoginSaveOptionsFrame"]
UICache.launcherHint = _G["AccountLoginLauncherHint"]
UICache.loginButton = _G["AccountLoginLoginButton"]
UICache.versionText = _G["AccountLoginVersion"]
UICache.realmName = _G["AccountLoginRealmName"]
@@ -94,6 +97,106 @@ end
function GetLoginState()
return LoginState
end
local function ResolveLaunchMode()
if type(MoonWellGetLaunchMode) ~= "function" then
return "locked"
end
local mode = MoonWellGetLaunchMode()
if mode == "launcher" or mode == "developer" then
return mode
end
return "locked"
end
local function ApplyLaunchMode()
local developerMode = LoginState.launchMode == "developer"
local launcherMode = LoginState.launchMode == "launcher"
if launcherMode then
AccountLoginUI:Hide()
else
AccountLoginUI:Show()
AccountLoginUI:SetAlpha(1)
end
if developerMode then
UICache.accountEdit:Show()
UICache.passwordEdit:Show()
UICache.saveOptionsFrame:Show()
UICache.launcherHint:Hide()
UICache.loginButton:SetText(LOGIN)
else
UICache.accountEdit:Hide()
UICache.passwordEdit:Hide()
UICache.saveOptionsFrame:Hide()
UICache.launcherHint:Show()
UICache.loginButton:SetText("Авторизоваться")
end
end
function AccountLogin_OpenLauncher()
PlaySound("gsLogin")
local opened = false
if type(MoonWellOpenLauncher) == "function" then
opened = MoonWellOpenLauncher() and true or false
end
if not opened then
GlueDialog_Show("MOONWELL_LAUNCHER_NOT_FOUND")
end
end
function AccountLogin_PrimaryAction()
if LoginState.launchMode == "developer" then
AccountLogin_Login()
else
AccountLogin_OpenLauncher()
end
end
local function TryLauncherLogin()
if LoginState.launchMode ~= "launcher" or LoginState.launcherLoginAttempted then
return false
end
LoginState.launcherLoginAttempted = true
if type(MoonWellConsumeLauncherAuth) ~= "function" then
LoginState.launchMode = "locked"
ApplyLaunchMode()
return false
end
local accountName, ticket = MoonWellConsumeLauncherAuth()
if not accountName or accountName == "" or not ticket or ticket == "" then
LoginState.launchMode = "locked"
ApplyLaunchMode()
return false
end
SetSavedAccountName("")
SetSavedAccountList("")
SetUsesToken(false)
AccountLoginUI:Hide()
PlaySound("gsLogin")
DefaultServerLogin(accountName, ticket)
accountName = nil
ticket = nil
return true
end
local function TryLauncherFallbackLogin()
if not LoginState.luaBridgePending or LoginState.launcherLoginAttempted then
return false
end
-- MoonWell.dll intercepts this stock callback and substitutes the one-time
-- launcher credentials. With no launcher data it intentionally does nothing.
LoginState.launcherLoginAttempted = true
DefaultServerLogin("__MOONWELL_LAUNCHER__", "__MOONWELL_LAUNCHER__")
return true
end
-- ============================================================================
-- SISTEMA DE ANIMACIÓN BLP
-- ============================================================================
@@ -144,6 +247,11 @@ GlueDialogTypes["AUTO_LOGIN"] = {
UICache.autoLogin:SetChecked(0)
end,
}
GlueDialogTypes["MOONWELL_LAUNCHER_NOT_FOUND"] = {
text = "Не удалось открыть лаунчер MoonWell. Запустите его вручную и нажмите «Играть».",
button1 = OKAY,
}
-- ============================================================================
-- GESTIÓN DE MODELOS 3D
-- ============================================================================
@@ -151,10 +259,8 @@ local function CreateLoginModel(parent, modelData)
local model = CreateFrame("Model", nil, parent)
local width, height = parent:GetSize()
model:SetSize(
width * (modelData[LOGIN_MODEL_STRUCT.WIDTH_SQUISH] or 1),
height * (modelData[LOGIN_MODEL_STRUCT.HEIGHT_SQUISH] or 1)
)
model:SetWidth(width * (modelData[LOGIN_MODEL_STRUCT.WIDTH_SQUISH] or 1))
model:SetHeight(height * (modelData[LOGIN_MODEL_STRUCT.HEIGHT_SQUISH] or 1))
model:SetPoint("CENTER")
model:SetModel("Character/Human/Male/HumanMale.mdx")
model:SetCamera(1)
@@ -194,6 +300,13 @@ end
-- ACTUALIZACIÓN DE LA ESCENA
-- ============================================================================
function LoginScene_OnUpdate(self, elapsed)
if LoginState.luaBridgePending and type(MoonWellGetLaunchMode) == "function" then
LoginState.luaBridgePending = false
LoginState.launchMode = ResolveLaunchMode()
ApplyLaunchMode()
TryLauncherLogin()
end
if Config.LOGIN_AMBIENCE and not self.ambiencePlayed then
PlayGlueAmbience(Config.LOGIN_AMBIENCE, Config.AMBIENCE_FADE_TIME)
self.ambiencePlayed = true
@@ -283,8 +396,8 @@ function AccountLogin_OnLoad(self)
model:SetModel("Character/Human/Male/HumanMale.mdx")
model:SetPoint("CENTER", 0, 0)
model:SetSize(self:GetWidth() / (data[LOGIN_MODEL_STRUCT.WIDTH_SQUISH] or 1),
self:GetHeight() / (data[LOGIN_MODEL_STRUCT.HEIGHT_SQUISH] or 1))
model:SetWidth(self:GetWidth() / (data[LOGIN_MODEL_STRUCT.WIDTH_SQUISH] or 1))
model:SetHeight(self:GetHeight() / (data[LOGIN_MODEL_STRUCT.HEIGHT_SQUISH] or 1))
model:SetCamera(1)
if data[LOGIN_MODEL_STRUCT.LIGHT] then
@@ -312,6 +425,14 @@ function AccountLogin_OnLoad(self)
end
function AccountLogin_OnShow(self)
LoginState.luaBridgePending = type(MoonWellGetLaunchMode) ~= "function"
LoginState.launchMode = ResolveLaunchMode()
ApplyLaunchMode()
-- Submit launcher credentials before constructing the decorative login UI.
-- A cosmetic Lua error must never be able to block authentication.
TryLauncherFallbackLogin()
self:Show()
self:SetAlpha(1)
WorldOfWarcraftRating:Hide()
@@ -341,12 +462,10 @@ function AccountLogin_OnShow(self)
elseif self.animatedTexture then
self.animatedTexture:Hide()
end
AccountLoginUI:Show()
AccountLoginUI:SetAlpha(1)
if not self.newLogo then
self.newLogoFrame = CreateFrame("Frame", nil, self)
self.newLogoFrame:SetSize(Config.LOGO_SIZE, Config.LOGO_SIZE)
self.newLogoFrame:SetWidth(Config.LOGO_SIZE)
self.newLogoFrame:SetHeight(Config.LOGO_SIZE)
self.newLogoFrame:SetPoint("TOPLEFT", Config.LOGO_POSITION_X, Config.LOGO_POSITION_Y)
self.newLogo = self.newLogoFrame:CreateTexture("AccountLoginNewLogo", "OVERLAY")
@@ -376,7 +495,14 @@ function AccountLogin_OnShow(self)
self.modelUpdateCount = 0
self.modelUpdateTimer = 0
local accountName, password = unpack(string_explode(GetSavedAccountName(), "#&|&#"))
local accountName, password = "", ""
if LoginState.launchMode == "developer" then
accountName, password = unpack(string_explode(GetSavedAccountName(), "#&|&#"))
else
SetSavedAccountName("")
SetSavedAccountList("")
SetUsesToken(false)
end
UICache.accountEdit:SetText(accountName or "")
UICache.passwordEdit:SetText(password or "")
@@ -390,13 +516,15 @@ function AccountLogin_OnShow(self)
UICache.realmName:SetText("No Recent Server")
end
if accountName == "" then
AccountLogin_FocusAccountName()
else
AccountLogin_FocusPassword()
if LoginState.launchMode == "developer" then
if accountName == "" then
AccountLogin_FocusAccountName()
else
AccountLogin_FocusPassword()
end
end
if UICache.savePassword:GetChecked() then
if LoginState.launchMode == "developer" and UICache.savePassword:GetChecked() then
UICache.autoLoginText:Show()
UICache.autoLogin:Show()
else
@@ -415,7 +543,11 @@ function AccountLogin_OnShow(self)
ACCOUNT_MSG_BODY_LOADED = false
ACCOUNT_MSG_CURRENT_INDEX = nil
ApplyLaunchMode()
AccountLogin_CheckAutoLogin()
if not TryLauncherLogin() then
TryLauncherFallbackLogin()
end
self:SetScript("OnUpdate", LoginScene_OnUpdate)
end
@@ -450,11 +582,15 @@ function AccountLogin_OnHide(self)
end
function AccountLogin_FocusPassword()
UICache.passwordEdit:SetFocus()
if LoginState.launchMode == "developer" then
UICache.passwordEdit:SetFocus()
end
end
function AccountLogin_FocusAccountName()
UICache.accountEdit:SetFocus()
if LoginState.launchMode == "developer" then
UICache.accountEdit:SetFocus()
end
end
function AccountLogin_OnKeyDown(key)
@@ -462,7 +598,7 @@ function AccountLogin_OnKeyDown(key)
if ConnectionHelpFrame:IsShown() then
ConnectionHelpFrame:Hide()
AccountLoginUI:Show()
elseif SurveyNotificationFrame:IsShown() then
elseif SurveyNotificationFrame and SurveyNotificationFrame:IsShown() then
else
AccountLogin_Exit()
end
@@ -471,10 +607,10 @@ function AccountLogin_OnKeyDown(key)
return
elseif TOSFrame:IsShown() or ConnectionHelpFrame:IsShown() then
return
elseif SurveyNotificationFrame:IsShown() then
elseif SurveyNotificationFrame and SurveyNotificationFrame:IsShown() then
AccountLogin_SurveyNotificationDone(1)
end
AccountLogin_Login()
AccountLogin_PrimaryAction()
elseif key == "PRINTSCREEN" then
Screenshot()
end
@@ -526,6 +662,11 @@ end
-- SISTEMA DE LOGIN CON GUARDADO DE CONTRASEÑA
-- ============================================================================
function AccountLogin_Login()
if LoginState.launchMode ~= "developer" then
AccountLogin_OpenLauncher()
return
end
PlaySound("gsLogin")
local accountName = UICache.accountEdit:GetText()
local password = UICache.passwordEdit:GetText()
@@ -557,6 +698,11 @@ end
-- SISTEMA DE AUTOLOGIN
-- ============================================================================
function AccountLogin_CheckAutoLogin()
if LoginState.launchMode ~= "developer" then
LoginState.autoLoginTimer = nil
return
end
if not LoginState.autoLoginAttempted then
LoginState.autoLoginAttempted = true
local savedAccountInfo = GetSavedAccountName()
@@ -811,6 +957,12 @@ function AccountLogin_Exit()
end
function AccountLogin_ShowSurveyNotification()
if not SurveyNotificationFrame or
not SurveyNotificationAccept or
not SurveyNotificationDecline then
return
end
GlueDialog:Hide()
AccountLoginUI:Hide()
SurveyNotificationAccept:Enable()
@@ -819,11 +971,19 @@ function AccountLogin_ShowSurveyNotification()
end
function AccountLogin_SurveyNotificationDone(accepted)
SurveyNotificationFrame:Hide()
SurveyNotificationAccept:Disable()
SurveyNotificationDecline:Disable()
SurveyNotificationDone(accepted)
AccountLoginUI:Show()
if SurveyNotificationFrame then
SurveyNotificationFrame:Hide()
end
if SurveyNotificationAccept then
SurveyNotificationAccept:Disable()
end
if SurveyNotificationDecline then
SurveyNotificationDecline:Disable()
end
if type(SurveyNotificationDone) == "function" then
SurveyNotificationDone(accepted)
end
ApplyLaunchMode()
end
function AccountLogin_ShowUserAgreements()
@@ -115,6 +115,18 @@
</Anchor>
</Anchors>
</FontString>
<FontString name="AccountLoginLauncherHint" inherits="GlueFontNormal" justifyH="CENTER" text="Вход в игру выполняется через лаунчер MoonWell.">
<Size>
<AbsDimension x="420" y="24"/>
</Size>
<Anchors>
<Anchor point="BOTTOM">
<Offset>
<AbsDimension x="0" y="270"/>
</Offset>
</Anchor>
</Anchors>
</FontString>
<Texture name="WorldOfWarcraftRating" hidden="true">
<Size>
<AbsDimension x="128" y="128"/>
@@ -195,7 +207,7 @@
<!-- ====================================== -->
<!-- CAMPOS DE TEXTO: USUARIO Y CONTRASEÑA -->
<!-- ====================================== -->
<EditBox name="AccountLoginAccountEdit" inherits="GlueDark_EditBoxTemplate" letters="320">
<EditBox name="AccountLoginAccountEdit" inherits="GlueDark_EditBoxTemplate" letters="320" hidden="true">
<Size x="200" y="37"/>
<Anchors>
<Anchor point="BOTTOM">
@@ -246,7 +258,7 @@
</Scripts>
</EditBox>
<EditBox name="AccountLoginPasswordEdit" inherits="GlueDark_EditBoxTemplate" letters="16" password="1">
<EditBox name="AccountLoginPasswordEdit" inherits="GlueDark_EditBoxTemplate" letters="16" password="1" hidden="true">
<Size x="200" y="37"/>
<Anchors>
<Anchor point="BOTTOM">
@@ -299,7 +311,7 @@
<!-- ====================================== -->
<!-- CHECKBOXES: GUARDAR CUENTA Y CONTRASEÑA -->
<!-- ====================================== -->
<Frame name="AccountLoginSaveOptionsFrame">
<Frame name="AccountLoginSaveOptionsFrame" hidden="true">
<Size x="200" y="60"/>
<Anchors>
<Anchor point="TOP" relativeTo="AccountLoginPasswordEdit" relativePoint="BOTTOM">
@@ -883,7 +895,7 @@
<!-- ====================================== -->
<!-- BOTÓN DE LOGIN -->
<!-- ====================================== -->
<Button name="AccountLoginLoginButton" inherits="GlueButtonTemplateBlue" text="LOGIN">
<Button name="AccountLoginLoginButton" inherits="GlueButtonTemplateBlue" text="Авторизоваться">
<Size x="200" y="55"/>
<Anchors>
<Anchor point="BOTTOM">
@@ -905,7 +917,7 @@
</Layers>
<Scripts>
<OnClick>
AccountLogin_Login();
AccountLogin_PrimaryAction();
</OnClick>
</Scripts>
</Button>
@@ -7,9 +7,6 @@
## Dependencies: CallbackHandler-1.0, AIO_Client, LibStub, Ace3
## X-Hidden: 1
# Libraries
embeds.xml
# Locale
Locale\enUS.lua
Locale\ruRU.lua
Binary file not shown.
+64
View File
@@ -0,0 +1,64 @@
# Sync DBC files from src with files on a server
# Parse .env
if (Test-Path ".env") {
Get-Content ".env" | ForEach-Object {
if ($_ -match '^[^#\s]+=.+') {
$name, $value = $_ -split '=', 2
$name = $name.Trim()
$value = $value.Trim().Trim('"').Trim("'")
[System.Environment]::SetEnvironmentVariable($name, $value)
}
}
} else {
Write-Error ".env not found!"
exit 1
}
$user = $env:SSH_USER
$sshHost = $env:SSH_HOST
$port = $env:SSH_PORT
if (-not $user -or -not $sshHost) {
Write-Error "SSH_USER or SSH_HOST env variables are not defined"
exit 1
}
if (-not $port) {
$port = "22"
}
$input = @(
".\src\Data\ruRU\patch-ruRU-5\DBFilesClient\Achievement.dbc",
".\src\Data\ruRU\patch-ruRU-Z\DBFilesClient\Spell.dbc",
".\src\Data\ruRU\patch-ruRU-Z\DBFilesClient\BarberShopStyle.dbc",
".\src\Data\ruRU\patch-ruRU-Z\DBFilesClient\CreatureDisplayInfoExtra.dbc",
".\src\Data\ruRU\patch-ruRU-Z\DBFilesClient\SpellVisualKitModelAttach.dbc"
)
$output = "/home/mangos/moonwell-server/env/dist/data/dbc"
foreach ($file in $input) {
if (Test-Path $file) {
$fileName = Split-Path $file -Leaf
$outputPath = "$output/$fileName"
Write-Host "Copying $fileName to $output..."
scp -P $port $file "${user}@${sshHost}:${output}"
if ($LASTEXITCODE -eq 0) {
Write-Host "Running chmod..."
ssh -p $port "${user}@${sshHost}" "chown mangos:mangos '$outputPath' && chmod 775 '$outputPath'"
}
} else {
Write-Warning "Input file not found: $file"
}
}
Write-Host "Restarting server..."
ssh -p $port "${user}@${sshHost}" "cd /home/mangos/moonwell-server && docker compose restart ac-worldserver"
Write-Host "Done."
+410
View File
@@ -0,0 +1,410 @@
param(
[string]$ProjectRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path,
[string]$ClientPath
)
$ErrorActionPreference = 'Stop'
function Get-EnvValue([string]$Path, [string]$Name) {
$line = Get-Content -LiteralPath $Path | Where-Object { $_ -match "^$([regex]::Escape($Name))=" } | Select-Object -First 1
if ($line) { return $line.Substring($Name.Length + 1).Trim() }
return $null
}
if (-not $ClientPath) {
$ClientPath = Get-EnvValue (Join-Path $ProjectRoot '.env') 'WOW_HOME'
}
if (-not $ClientPath -or -not (Test-Path -LiteralPath (Join-Path $ClientPath 'Data\ruRU'))) {
throw 'A valid WoW 3.3.5 client path is required.'
}
$reader = Join-Path $ProjectRoot 'tool\target\release\mpqread.exe'
if (-not (Test-Path -LiteralPath $reader)) {
throw "MPQ reader not found: $reader"
}
$work = Join-Path $ProjectRoot 'build\customization-compat'
New-Item -ItemType Directory -Path $work -Force | Out-Null
function Export-MpqFile([string]$Archive, [string]$VirtualPath, [string]$OutputName) {
$output = Join-Path $work $OutputName
& $reader $Archive $VirtualPath $output 2>$null
if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $output)) {
throw "Cannot extract $VirtualPath from $Archive"
}
return $output
}
function Read-U32([byte[]]$Bytes, [int]$Offset) {
return [BitConverter]::ToUInt32($Bytes, $Offset)
}
function Write-U32([byte[]]$Bytes, [int]$Field, [uint32]$Value) {
[BitConverter]::GetBytes($Value).CopyTo($Bytes, $Field * 4)
}
function Read-Wdbc([string]$Path) {
[byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
if ($bytes.Length -lt 20 -or [Text.Encoding]::ASCII.GetString($bytes, 0, 4) -ne 'WDBC') {
throw "Not a WDBC file: $Path"
}
$count = Read-U32 $bytes 4
$fields = Read-U32 $bytes 8
$recordSize = Read-U32 $bytes 12
$stringSize = Read-U32 $bytes 16
$recordsEnd = 20 + ($count * $recordSize)
if ($recordsEnd + $stringSize -ne $bytes.Length) {
throw "Invalid WDBC size: $Path"
}
$records = [Collections.Generic.Dictionary[uint32,byte[]]]::new()
for ($index = 0; $index -lt $count; $index++) {
[byte[]]$row = New-Object byte[] $recordSize
[Array]::Copy($bytes, 20 + ($index * $recordSize), $row, 0, $recordSize)
$records.Add((Read-U32 $row 0), $row)
}
[byte[]]$strings = New-Object byte[] $stringSize
[Array]::Copy($bytes, $recordsEnd, $strings, 0, $stringSize)
return [pscustomobject]@{
Path = $Path
Fields = [uint32]$fields
RecordSize = [uint32]$recordSize
Records = $records
Strings = $strings
}
}
function Read-WdbcRows([string]$Path) {
[byte[]]$bytes = [IO.File]::ReadAllBytes($Path)
if ($bytes.Length -lt 20 -or [Text.Encoding]::ASCII.GetString($bytes, 0, 4) -ne 'WDBC') {
throw "Not a WDBC file: $Path"
}
$count = Read-U32 $bytes 4
$fields = Read-U32 $bytes 8
$recordSize = Read-U32 $bytes 12
$stringSize = Read-U32 $bytes 16
$recordsEnd = 20 + ($count * $recordSize)
if ($recordsEnd + $stringSize -ne $bytes.Length) {
throw "Invalid WDBC size: $Path"
}
$rows = New-Object Collections.Generic.List[byte[]]
for ($index = 0; $index -lt $count; $index++) {
[byte[]]$row = New-Object byte[] $recordSize
[Array]::Copy($bytes, 20 + ($index * $recordSize), $row, 0, $recordSize)
$rows.Add($row)
}
[byte[]]$strings = New-Object byte[] $stringSize
[Array]::Copy($bytes, $recordsEnd, $strings, 0, $stringSize)
return [pscustomobject]@{
Path = $Path
Fields = [uint32]$fields
RecordSize = [uint32]$recordSize
Rows = $rows
Strings = $strings
}
}
function Get-StringBytes($Table, [uint32]$Offset) {
if ($Offset -eq 0) { return ,([byte[]]@()) }
if ($Offset -ge $Table.Strings.Length) { throw "Bad string offset $Offset in $($Table.Path)" }
$end = [int]$Offset
while ($end -lt $Table.Strings.Length -and $Table.Strings[$end] -ne 0) { $end++ }
[byte[]]$value = New-Object byte[] ($end - [int]$Offset)
if ($value.Length) { [Array]::Copy($Table.Strings, $Offset, $value, 0, $value.Length) }
return ,$value
}
function Get-RowSignature($Table, [byte[]]$Row, [int[]]$StringFields) {
$parts = New-Object Collections.Generic.List[string]
for ($field = 0; $field -lt $Table.Fields; $field++) {
$value = Read-U32 $Row ($field * 4)
if ($StringFields -contains $field) {
$parts.Add('s:' + [Convert]::ToBase64String((Get-StringBytes $Table $value)))
} else {
$parts.Add('u:' + $value)
}
}
return $parts -join '|'
}
function Select-DeltaMerge($Base, $Old, $New, [int[]]$StringFields, [string]$Label) {
$ids = [Collections.Generic.HashSet[uint32]]::new()
foreach ($id in $Base.Records.Keys) { [void]$ids.Add($id) }
foreach ($id in $Old.Records.Keys) { [void]$ids.Add($id) }
foreach ($id in $New.Records.Keys) { [void]$ids.Add($id) }
$selected = [Collections.Generic.Dictionary[uint32,object]]::new()
$oldOnly = 0
$newChanges = 0
$conflicts = 0
foreach ($id in $ids) {
$baseRow = $null; $oldRow = $null; $newRow = $null
[void]$Base.Records.TryGetValue($id, [ref]$baseRow)
[void]$Old.Records.TryGetValue($id, [ref]$oldRow)
[void]$New.Records.TryGetValue($id, [ref]$newRow)
$baseSig = if ($baseRow) { Get-RowSignature $Base $baseRow $StringFields } else { $null }
$oldSig = if ($oldRow) { Get-RowSignature $Old $oldRow $StringFields } else { $null }
$newSig = if ($newRow) { Get-RowSignature $New $newRow $StringFields } else { $null }
$oldChanged = $oldRow -and ($null -eq $baseRow -or $oldSig -ne $baseSig)
$newChanged = $newRow -and ($null -eq $baseRow -or $newSig -ne $baseSig)
if ($oldChanged -and $newChanged -and $oldSig -ne $newSig) { $conflicts++ }
if ($newChanged) {
$selected.Add($id, [pscustomobject]@{ Table = $New; Row = $newRow })
$newChanges++
} elseif ($oldChanged) {
$selected.Add($id, [pscustomobject]@{ Table = $Old; Row = $oldRow })
$oldOnly++
} elseif ($newRow) {
$selected.Add($id, [pscustomobject]@{ Table = $New; Row = $newRow })
} elseif ($oldRow) {
$selected.Add($id, [pscustomobject]@{ Table = $Old; Row = $oldRow })
} else {
$selected.Add($id, [pscustomobject]@{ Table = $Base; Row = $baseRow })
}
}
Write-Host "${Label}: rows=$($selected.Count), retained-old=$oldOnly, applied-new=$newChanges, same-id-conflicts=$conflicts"
return $selected
}
function Select-DisplayExtraMerge($Base, $OldItem, $New) {
$ids = [Collections.Generic.HashSet[uint32]]::new()
foreach ($id in $OldItem.Records.Keys) { [void]$ids.Add($id) }
foreach ($id in $New.Records.Keys) { [void]$ids.Add($id) }
$selected = [Collections.Generic.Dictionary[uint32,object]]::new()
$itemFieldsApplied = 0
foreach ($id in $ids) {
$baseRow = $null; $oldRow = $null; $newRow = $null
[void]$Base.Records.TryGetValue($id, [ref]$baseRow)
[void]$OldItem.Records.TryGetValue($id, [ref]$oldRow)
[void]$New.Records.TryGetValue($id, [ref]$newRow)
if ($newRow) {
[byte[]]$merged = $newRow.Clone()
if ($oldRow) {
for ($field = 8; $field -le 18; $field++) {
$oldValue = Read-U32 $oldRow ($field * 4)
$baseValue = if ($baseRow) { Read-U32 $baseRow ($field * 4) } else { 0 }
if (-not $baseRow -or $oldValue -ne $baseValue) {
Write-U32 $merged $field $oldValue
$itemFieldsApplied++
}
}
}
$selected.Add($id, [pscustomobject]@{ Table = $New; Row = $merged })
} else {
$selected.Add($id, [pscustomobject]@{ Table = $OldItem; Row = $oldRow })
}
}
Write-Host "CreatureDisplayInfoExtra: rows=$($selected.Count), retained item fields from patch I=$itemFieldsApplied"
return $selected
}
function Write-Wdbc($Template, $Selected, [int[]]$StringFields, [string]$Path) {
$stringOffsets = [Collections.Generic.Dictionary[string,uint32]]::new([StringComparer]::Ordinal)
$strings = New-Object Collections.Generic.List[byte]
$strings.Add(0)
$stringOffsets.Add('', 0)
$rows = New-Object Collections.Generic.List[byte[]]
foreach ($id in ($Selected.Keys | Sort-Object)) {
$choice = $Selected[$id]
[byte[]]$row = $choice.Row.Clone()
foreach ($field in $StringFields) {
$sourceOffset = Read-U32 $row ($field * 4)
[byte[]]$value = Get-StringBytes $choice.Table $sourceOffset
$key = [Convert]::ToBase64String($value)
[uint32]$targetOffset = 0
if (-not $stringOffsets.TryGetValue($key, [ref]$targetOffset)) {
$targetOffset = [uint32]$strings.Count
if ($value.Length) { $strings.AddRange($value) }
$strings.Add(0)
$stringOffsets.Add($key, $targetOffset)
}
Write-U32 $row $field $targetOffset
}
$rows.Add($row)
}
$parent = Split-Path -Parent $Path
New-Item -ItemType Directory -Path $parent -Force | Out-Null
$stream = [IO.File]::Open($Path, [IO.FileMode]::Create, [IO.FileAccess]::Write)
try {
$writer = New-Object IO.BinaryWriter($stream)
$writer.Write([Text.Encoding]::ASCII.GetBytes('WDBC'))
$writer.Write([uint32]$rows.Count)
$writer.Write([uint32]$Template.Fields)
$writer.Write([uint32]$Template.RecordSize)
$writer.Write([uint32]$strings.Count)
foreach ($row in $rows) { $writer.Write($row) }
$writer.Write($strings.ToArray())
$writer.Flush()
} finally {
$stream.Dispose()
}
}
function Write-ExtendedBarberShopStyle($Base, $HairGeosets, $FacialHairStyles, [string]$Path) {
if ($Base.Fields -ne 40 -or $Base.RecordSize -ne 160) {
throw "Unexpected BarberShopStyle schema in $($Base.Path)"
}
if ($HairGeosets.Fields -ne 6 -or $HairGeosets.RecordSize -ne 24) {
throw "Unexpected CharHairGeosets schema in $($HairGeosets.Path)"
}
if ($FacialHairStyles.Fields -ne 8 -or $FacialHairStyles.RecordSize -ne 32) {
throw "Unexpected CharacterFacialHairStyles schema in $($FacialHairStyles.Path)"
}
$templates = [Collections.Generic.Dictionary[string,byte[]]]::new([StringComparer]::Ordinal)
$existing = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
[uint32]$nextId = 1
foreach ($row in $Base.Rows) {
$id = Read-U32 $row 0
if ($id -ge $nextId) { $nextId = $id + 1 }
$type = Read-U32 $row 4
if ($type -ne 0 -and $type -ne 2) { continue }
$race = Read-U32 $row (37 * 4)
$gender = Read-U32 $row (38 * 4)
$style = Read-U32 $row (39 * 4)
$groupKey = "${race}:${gender}:${type}"
if (-not $templates.ContainsKey($groupKey)) { $templates.Add($groupKey, $row) }
[void]$existing.Add("${groupKey}:${style}")
}
$targets = New-Object Collections.Generic.List[object]
$targetKeys = [Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal)
foreach ($row in $HairGeosets.Rows) {
$race = Read-U32 $row 4
$gender = Read-U32 $row 8
$style = Read-U32 $row 12
$groupKey = "${race}:${gender}:0"
$targetKey = "${groupKey}:${style}"
if ($templates.ContainsKey($groupKey) -and $targetKeys.Add($targetKey)) {
$targets.Add([pscustomobject]@{ Race = $race; Gender = $gender; Type = 0; Style = $style; GroupKey = $groupKey; TargetKey = $targetKey })
}
}
foreach ($row in $FacialHairStyles.Rows) {
$race = Read-U32 $row 0
$gender = Read-U32 $row 4
$style = Read-U32 $row 8
$groupKey = "${race}:${gender}:2"
$targetKey = "${groupKey}:${style}"
if ($templates.ContainsKey($groupKey) -and $targetKeys.Add($targetKey)) {
$targets.Add([pscustomobject]@{ Race = $race; Gender = $gender; Type = 2; Style = $style; GroupKey = $groupKey; TargetKey = $targetKey })
}
}
foreach ($groupKey in $templates.Keys) {
if (-not ($targets | Where-Object { $_.GroupKey -eq $groupKey } | Select-Object -First 1)) {
throw "No customization rows found for BarberShopStyle group $groupKey"
}
}
$rows = New-Object Collections.Generic.List[byte[]]
foreach ($row in $Base.Rows) { $rows.Add($row.Clone()) }
$strings = New-Object Collections.Generic.List[byte]
$strings.AddRange($Base.Strings)
$newStringOffsets = [Collections.Generic.Dictionary[string,uint32]]::new([StringComparer]::Ordinal)
$addedHair = 0
$addedFacial = 0
foreach ($target in ($targets | Sort-Object Race, Gender, Type, Style)) {
if ($existing.Contains($target.TargetKey)) { continue }
[byte[]]$row = $templates[$target.GroupKey].Clone()
Write-U32 $row 0 $nextId
Write-U32 $row 39 ([uint32]$target.Style)
# patch-ruRU-3 stores its localized display name in locale slot 8
# (field 10). New names are intentionally generic because the source
# customization archive contains no BarberShopStyle names.
for ($field = 2; $field -le 17; $field++) { Write-U32 $row $field 0 }
$label = if ($target.Type -eq 0) {
"New hairstyle $([uint32]$target.Style + 1)"
} else {
"New appearance $([uint32]$target.Style + 1)"
}
[uint32]$nameOffset = 0
if (-not $newStringOffsets.TryGetValue($label, [ref]$nameOffset)) {
$nameOffset = [uint32]$strings.Count
$strings.AddRange([Text.Encoding]::UTF8.GetBytes($label))
$strings.Add(0)
$newStringOffsets.Add($label, $nameOffset)
}
Write-U32 $row 10 $nameOffset
$rows.Add($row)
[void]$existing.Add($target.TargetKey)
$nextId++
if ($target.Type -eq 0) { $addedHair++ } else { $addedFacial++ }
}
foreach ($target in $targets) {
if (-not $existing.Contains($target.TargetKey)) {
throw "Missing generated BarberShopStyle row $($target.TargetKey)"
}
}
$parent = Split-Path -Parent $Path
New-Item -ItemType Directory -Path $parent -Force | Out-Null
$stream = [IO.File]::Open($Path, [IO.FileMode]::Create, [IO.FileAccess]::Write)
try {
$writer = New-Object IO.BinaryWriter($stream)
$writer.Write([Text.Encoding]::ASCII.GetBytes('WDBC'))
$writer.Write([uint32]$rows.Count)
$writer.Write([uint32]$Base.Fields)
$writer.Write([uint32]$Base.RecordSize)
$writer.Write([uint32]$strings.Count)
foreach ($row in $rows) { $writer.Write($row) }
$writer.Write($strings.ToArray())
$writer.Flush()
} finally {
$stream.Dispose()
}
Write-Host "BarberShopStyle: rows=$($rows.Count), added-hair=$addedHair, added-appearance=$addedFacial"
}
$locale = Join-Path $ClientPath 'Data\ruRU'
$baseArchive = Join-Path $locale 'patch-ruRU-3.MPQ'
$oldA = Join-Path $locale 'patch-ruRU-A.mpq'
$oldB = Join-Path $locale 'patch-ruRU-B.mpq'
$oldI = Join-Path $locale 'patch-ruRU-I.mpq'
$oldS = Join-Path $locale 'patch-ruRU-S.mpq'
$newB = Join-Path $ProjectRoot 'new customization mpqs\patch-b-002.mpq'
$baseModel = Read-Wdbc (Export-MpqFile $baseArchive 'DBFilesClient\CreatureModelData.dbc' 'base-CreatureModelData.dbc')
$baseDisplay = Read-Wdbc (Export-MpqFile $baseArchive 'DBFilesClient\CreatureDisplayInfo.dbc' 'base-CreatureDisplayInfo.dbc')
$baseExtra = Read-Wdbc (Export-MpqFile $baseArchive 'DBFilesClient\CreatureDisplayInfoExtra.dbc' 'base-CreatureDisplayInfoExtra.dbc')
$oldModel = Read-Wdbc (Export-MpqFile $oldA 'DBFilesClient\CreatureModelData.dbc' 'old-CreatureModelData.dbc')
$oldDisplay = Read-Wdbc (Export-MpqFile $oldB 'DBFilesClient\CreatureDisplayInfo.dbc' 'old-CreatureDisplayInfo.dbc')
$oldExtra = Read-Wdbc (Export-MpqFile $oldI 'DBFilesClient\CreatureDisplayInfoExtra.dbc' 'old-CreatureDisplayInfoExtra.dbc')
$newModel = Read-Wdbc (Export-MpqFile $newB 'DBFilesClient\CreatureModelData.dbc' 'new-CreatureModelData.dbc')
$newDisplay = Read-Wdbc (Export-MpqFile $newB 'DBFilesClient\CreatureDisplayInfo.dbc' 'new-CreatureDisplayInfo.dbc')
$newExtra = Read-Wdbc (Export-MpqFile $newB 'DBFilesClient\CreatureDisplayInfoExtra.dbc' 'new-CreatureDisplayInfoExtra.dbc')
$spellAttach = Export-MpqFile $oldS 'DBFilesClient\SpellVisualKitModelAttach.dbc' 'SpellVisualKitModelAttach.dbc'
$baseBarber = Read-WdbcRows (Export-MpqFile $baseArchive 'DBFilesClient\BarberShopStyle.dbc' 'base-BarberShopStyle.dbc')
$newHairGeosets = Read-WdbcRows (Export-MpqFile $newB 'DBFilesClient\CharHairGeosets.dbc' 'new-CharHairGeosets.dbc')
$newFacialHair = Read-WdbcRows (Export-MpqFile $newB 'DBFilesClient\CharacterFacialHairStyles.dbc' 'new-CharacterFacialHairStyles.dbc')
$modelSelected = Select-DeltaMerge $baseModel $oldModel $newModel @(2) 'CreatureModelData'
$displaySelected = Select-DeltaMerge $baseDisplay $oldDisplay $newDisplay @(6,7,8,9) 'CreatureDisplayInfo'
$extraSelected = Select-DisplayExtraMerge $baseExtra $oldExtra $newExtra
$mergedModel = Join-Path $work 'merged-CreatureModelData.dbc'
$mergedDisplay = Join-Path $work 'merged-CreatureDisplayInfo.dbc'
$mergedExtra = Join-Path $work 'merged-CreatureDisplayInfoExtra.dbc'
$extendedBarber = Join-Path $work 'extended-BarberShopStyle.dbc'
Write-Wdbc $newModel $modelSelected @(2) $mergedModel
Write-Wdbc $newDisplay $displaySelected @(6,7,8,9) $mergedDisplay
Write-Wdbc $newExtra $extraSelected @(20) $mergedExtra
Write-ExtendedBarberShopStyle $baseBarber $newHairGeosets $newFacialHair $extendedBarber
$baseTarget = Join-Path $ProjectRoot 'assets\dbc\3.3.5a'
$compatTarget = Join-Path $ProjectRoot 'src\Data\ruRU\patch-ruRU-Z\DBFilesClient'
New-Item -ItemType Directory -Path $compatTarget -Force | Out-Null
Copy-Item -LiteralPath $mergedModel -Destination (Join-Path $baseTarget 'CreatureModelData.dbc') -Force
Copy-Item -LiteralPath $mergedDisplay -Destination (Join-Path $baseTarget 'CreatureDisplayInfo.dbc') -Force
Copy-Item -LiteralPath $mergedExtra -Destination (Join-Path $compatTarget 'CreatureDisplayInfoExtra.dbc') -Force
Copy-Item -LiteralPath $spellAttach -Destination (Join-Path $compatTarget 'SpellVisualKitModelAttach.dbc') -Force
Copy-Item -LiteralPath $extendedBarber -Destination (Join-Path $compatTarget 'BarberShopStyle.dbc') -Force
$serverTarget = Join-Path $work 'server-dbc'
New-Item -ItemType Directory -Path $serverTarget -Force | Out-Null
Copy-Item -LiteralPath $extendedBarber -Destination (Join-Path $serverTarget 'BarberShopStyle.dbc') -Force
Write-Host "Compatibility sources updated in $baseTarget and $compatTarget"
Write-Host "Matching server DBC staged in $serverTarget"
+266
View File
@@ -0,0 +1,266 @@
use std::collections::{HashMap, HashSet};
use std::env;
use std::error::Error;
use std::fs;
use std::path::{Component, Path, PathBuf};
use serde::Serialize;
use wow_mpq::{Archive, special_files::parse_listfile};
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UnresolvedFile {
name_hash_a: String,
name_hash_b: String,
hash_index: usize,
block_index: usize,
size: u64,
compressed_size: u64,
flags: String,
blob: String,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct UnresolvedManifest {
version: u32,
archive_blocks: usize,
resolved_blocks: usize,
special_blocks: usize,
unresolved: Vec<UnresolvedFile>,
}
fn guess_extension(data: &[u8]) -> &'static str {
match data {
[b'B', b'L', b'P', b'1' | b'2', ..] => "blp",
[b'M', b'D', b'2', b'0' | b'1', ..] => "m2",
[b'S', b'K', b'I', b'N', ..] => "skin",
[b'W', b'D', b'B', b'C', ..] => "dbc",
[b'O', b'g', b'g', b'S', ..] => "ogg",
[b'R', b'I', b'F', b'F', ..] => "wav",
[0x89, b'P', b'N', b'G', ..] => "png",
[b'D', b'D', b'S', b' ', ..] => "dds",
[0xff, 0xd8, 0xff, ..] => "jpg",
_ => "bin",
}
}
fn safe_output_path(root: &Path, archive_path: &str) -> Result<PathBuf, Box<dyn Error>> {
let normalized = archive_path.replace('\\', "/");
let mut relative = PathBuf::new();
for part in normalized.split('/') {
if part.is_empty() || part == "." {
continue;
}
if part == ".." || part.contains(':') {
return Err(format!("unsafe archive path: {archive_path}").into());
}
relative.push(part);
}
if relative.as_os_str().is_empty()
|| relative
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(format!("unsafe archive path: {archive_path}").into());
}
Ok(root.join(relative))
}
fn extract_archive(
archive_path: &Path,
output_dir: &Path,
external_listfiles: &[String],
) -> Result<(), Box<dyn Error>> {
if output_dir.exists() && fs::read_dir(output_dir)?.next().is_some() {
return Err(format!(
"output directory is not empty: {} (extract to a new directory)",
output_dir.display()
)
.into());
}
let mut archive = Archive::open(archive_path)?;
let all_entries = archive.list_all()?;
let all_blocks: HashSet<usize> = all_entries
.iter()
.filter_map(|entry| entry.table_indices.and_then(|(_, block)| block))
.collect();
let hashes_by_block: HashMap<usize, (u32, u32)> = archive
.list_all_with_hashes()?
.into_iter()
.filter_map(|entry| Some((entry.table_indices?.1?, entry.hashes?)))
.collect();
let mut special_blocks = HashSet::new();
for special in [
"(listfile)",
"(attributes)",
"(signature)",
"(patch_metadata)",
] {
if let Some(info) = archive.find_file(special)? {
special_blocks.insert(info.block_index);
}
}
let mut files = Vec::new();
if let Ok(listfile) = archive.read_file("(listfile)") {
files.extend(parse_listfile(&listfile)?);
}
for listfile_path in external_listfiles {
files.extend(parse_listfile(&fs::read(listfile_path)?)?);
}
if files.is_empty() {
return Err(format!(
"{} has no readable (listfile); provide an external listfile",
archive_path.display()
)
.into());
}
fs::create_dir_all(output_dir)?;
let mut seen = HashSet::new();
let mut matched_blocks = HashSet::new();
let mut extracted = 0usize;
for archive_name in files {
if archive_name.starts_with('(') && archive_name.ends_with(')') {
continue;
}
// MPQ file lookup is case-insensitive. Avoid writing the same logical path
// twice on Windows when a malformed listfile contains casing duplicates.
if !seen.insert(archive_name.to_ascii_lowercase()) {
continue;
}
let Some(info) = archive.find_file(&archive_name)? else {
continue;
};
if !matched_blocks.insert(info.block_index) {
continue;
}
let output_path = safe_output_path(output_dir, &archive_name)?;
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let data = archive
.read_file(&archive_name)
.map_err(|error| format!("cannot extract {archive_name}: {error}"))?;
fs::write(&output_path, data)?;
extracted += 1;
if extracted.is_multiple_of(1000) {
println!("Extracted {extracted} files...");
}
}
println!(
"Extracted {extracted} files from {} to {} ({} data blocks, {} special blocks)",
archive_path.display(),
output_dir.display(),
all_blocks.len(),
special_blocks.len()
);
let unresolved_blocks: Vec<usize> = all_blocks
.difference(&special_blocks)
.filter(|block| !matched_blocks.contains(block))
.copied()
.collect();
if !unresolved_blocks.is_empty() {
let metadata_root = output_dir.join("__mpqmeta__");
let blob_root = metadata_root.join("unresolved");
fs::create_dir_all(&blob_root)?;
let mut unresolved = Vec::with_capacity(unresolved_blocks.len());
for block_index in unresolved_blocks {
let entry = all_entries
.iter()
.find(|entry| entry.table_indices.and_then(|(_, block)| block) == Some(block_index))
.ok_or_else(|| format!("missing metadata for block {block_index}"))?;
let (hash_index, _) = entry
.table_indices
.ok_or_else(|| format!("missing table indices for block {block_index}"))?;
if entry.is_encrypted() || entry.is_patch_file() {
return Err(format!(
"unresolved block {block_index} is encrypted or is a binary patch"
)
.into());
}
let (name_hash_a, name_hash_b) = hashes_by_block
.get(&block_index)
.copied()
.ok_or_else(|| format!("missing name hashes for block {block_index}"))?;
let data = archive.read_file_by_indices(hash_index, Some(block_index))?;
let extension = guess_extension(&data);
let blob_name =
format!("{name_hash_a:08x}-{name_hash_b:08x}-{block_index:08}.{extension}");
fs::write(blob_root.join(&blob_name), data)?;
unresolved.push(UnresolvedFile {
name_hash_a: format!("{name_hash_a:08x}"),
name_hash_b: format!("{name_hash_b:08x}"),
hash_index,
block_index,
size: entry.size,
compressed_size: entry.compressed_size,
flags: format!("0x{:08x}", entry.flags),
blob: format!("unresolved/{blob_name}"),
});
}
unresolved.sort_by_key(|entry| entry.block_index);
let manifest = UnresolvedManifest {
version: 1,
archive_blocks: all_blocks.len(),
resolved_blocks: matched_blocks.len(),
special_blocks: special_blocks.len(),
unresolved,
};
fs::write(
metadata_root.join("unresolved.json"),
serde_json::to_vec_pretty(&manifest)?,
)?;
println!(
"Stored {} path-unresolved data blocks in {}",
manifest.unresolved.len(),
metadata_root.display()
);
}
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: mpqextract <archive.mpq> <output_dir> [external-listfile ...]");
std::process::exit(2);
}
extract_archive(Path::new(&args[1]), Path::new(&args[2]), &args[3..])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn keeps_safe_relative_paths() {
let root = Path::new("output");
assert_eq!(
safe_output_path(root, "Character\\Human\\Human.m2").unwrap(),
root.join("Character").join("Human").join("Human.m2")
);
}
#[test]
fn rejects_parent_and_drive_paths() {
assert!(safe_output_path(Path::new("output"), "..\\secret").is_err());
assert!(safe_output_path(Path::new("output"), "C:\\secret").is_err());
}
}
+38
View File
@@ -0,0 +1,38 @@
use std::env;
use std::error::Error;
use std::path::Path;
use wow_mpq::Archive;
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect();
if args.len() != 2 {
eprintln!("Usage: mpqinfo <archive.mpq>");
std::process::exit(2);
}
let archive_path = Path::new(&args[1]);
let mut archive = Archive::open(archive_path)?;
let entries = archive.list()?;
let file_count = entries
.iter()
.filter(|entry| !entry.name.starts_with('('))
.count();
let unpacked_bytes: u64 = entries
.iter()
.filter(|entry| !entry.name.starts_with('('))
.map(|entry| entry.size)
.sum();
let encrypted_files = entries.iter().filter(|entry| entry.is_encrypted()).count();
let patch_files = entries.iter().filter(|entry| entry.is_patch_file()).count();
println!(
"{}\t{}\t{}\t{} encrypted\t{} patch",
archive_path.display(),
file_count,
unpacked_bytes,
encrypted_files,
patch_files
);
Ok(())
}
+78
View File
@@ -0,0 +1,78 @@
use std::collections::BTreeMap;
use std::env;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use wow_mpq::{Archive, special_files::parse_listfile};
fn collect_inputs(path: &Path, inputs: &mut Vec<PathBuf>) -> Result<(), Box<dyn Error>> {
if path.is_file() {
inputs.push(path.to_path_buf());
return Ok(());
}
for entry in WalkDir::new(path) {
let entry = entry?;
if entry.file_type().is_file() {
inputs.push(entry.path().to_path_buf());
}
}
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect();
if args.len() < 3 {
eprintln!("Usage: mpqmasterlist <output.txt> <archive-or-listfile> [...]");
std::process::exit(2);
}
let output = Path::new(&args[1]);
let mut inputs = Vec::new();
for input in &args[2..] {
collect_inputs(Path::new(input), &mut inputs)?;
}
let mut names = BTreeMap::new();
let mut sources = 0usize;
for input in inputs {
let extension = input
.extension()
.and_then(|value| value.to_str())
.unwrap_or_default();
let data = if extension.eq_ignore_ascii_case("mpq") {
let mut archive = match Archive::open(&input) {
Ok(archive) => archive,
Err(_) => continue,
};
match archive.read_file("(listfile)") {
Ok(data) => data,
Err(_) => continue,
}
} else if extension.eq_ignore_ascii_case("txt") || extension.eq_ignore_ascii_case("csv") {
fs::read(&input)?
} else {
continue;
};
for name in parse_listfile(&data)? {
names.entry(name.to_ascii_lowercase()).or_insert(name);
}
sources += 1;
}
if let Some(parent) = output.parent() {
fs::create_dir_all(parent)?;
}
let mut contents = names.into_values().collect::<Vec<_>>().join("\r\n");
contents.push_str("\r\n");
fs::write(output, contents)?;
println!(
"Wrote {} unique paths from {sources} listfiles to {}",
parse_listfile(&fs::read(output)?)?.len(),
output.display()
);
Ok(())
}
+87
View File
@@ -0,0 +1,87 @@
use std::env;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use wow_mpq::compression::flags;
use wow_mpq::{ArchiveBuilder, FormatVersion, ListfileOption};
fn collect_files(input_dir: &Path) -> Result<Vec<(PathBuf, String)>, Box<dyn Error>> {
let root = input_dir.canonicalize()?;
let mut files = Vec::new();
for entry in WalkDir::new(&root) {
let entry = entry?;
if !entry.file_type().is_file() {
continue;
}
let source = entry.path().canonicalize()?;
let archive_path = source
.strip_prefix(&root)?
.to_string_lossy()
.replace('\\', "/");
files.push((source, archive_path));
}
files.sort_by(|left, right| {
left.1
.to_ascii_lowercase()
.cmp(&right.1.to_ascii_lowercase())
.then_with(|| left.1.cmp(&right.1))
});
Ok(files)
}
fn pack_directory(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>> {
if input_dir.join("__mpqmeta__").exists() {
return Err(format!(
"{} contains path-unresolved MPQ blocks and cannot be rebuilt safely",
input_dir.display()
)
.into());
}
let files = collect_files(input_dir)?;
if files.is_empty() {
return Err(format!("input directory contains no files: {}", input_dir.display()).into());
}
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
let mut builder = ArchiveBuilder::new()
.version(FormatVersion::V2)
.block_size(7)
.default_compression(flags::ZLIB)
.listfile_option(ListfileOption::Generate);
let total = files.len();
for (index, (source, archive_path)) in files.into_iter().enumerate() {
builder = builder.add_file(source, &archive_path);
let packed = index + 1;
if packed.is_multiple_of(1000) {
println!("Queued {packed}/{total} files...");
}
}
builder.build(output_path)?;
println!(
"Packed {total} files from {} to {}",
input_dir.display(),
output_path.display()
);
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect();
if args.len() != 3 {
eprintln!("Usage: mpqpack <input_dir> <archive.mpq>");
std::process::exit(2);
}
pack_directory(Path::new(&args[1]), Path::new(&args[2]))
}
+11 -7
View File
@@ -167,17 +167,21 @@ 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, None)?;
// The final ruRU Z layer is the compatibility authority for customization,
// legacy visual-patch DBCs and MoonWell custom creature rows.
let creature_files = if name.eq_ignore_ascii_case("ruRU")
&& sub_name.eq_ignore_ascii_case("patch-ruRU-Z")
{
generated_creatures.as_ref()
} else {
None
};
build_mpq(&sub_path, &output_mpq, creature_files)?;
}
} else {
// Regular patch dir, e.g. patch-4/ or patch-Z/
let output_mpq = output_dir.join("Data").join(&name);
let creature_files = if name.eq_ignore_ascii_case("patch-Z") {
generated_creatures.as_ref()
} else {
None
};
build_mpq(&path, &output_mpq, creature_files)?;
build_mpq(&path, &output_mpq, None)?;
}
}
-38
View File
@@ -1,38 +0,0 @@
#!/usr/bin/env node
// Development-only catalog reader. It consumes the JSON generated by the
// MoonWell-enabled wow.export checkout; wow.export is not a runtime dependency.
const fs = require('fs');
const [catalogPath, raceName = 'Blood Elf', sexName = 'female', optionName = 'Earrings'] = process.argv.slice(2);
if (!catalogPath) {
console.error('Usage: node tools/find_customization_assets.js <catalog.json> [race] [sex] [option]');
process.exit(2);
}
const catalog = JSON.parse(fs.readFileSync(catalogPath, 'utf8'));
const race = catalog.races.find(value => value.name.toLowerCase() === raceName.toLowerCase());
const sex = race && race.sexes.find(value => value.name.toLowerCase() === sexName.toLowerCase());
const option = sex && sex.options.find(value => value.name.toLowerCase() === optionName.toLowerCase());
if (!race || !sex || !option) {
console.error(`Not found: race=${raceName}, sex=${sexName}, option=${optionName}`);
process.exit(1);
}
console.log(`retailBuild=${catalog.source && catalog.source.build}`);
console.log(`raceID=${race.id} chrModelID=${sex.chrModelID} modelFileDataID=${sex.modelFileDataID}`);
console.log(`optionID=${option.id} option=${option.name}`);
for (const choice of option.choices) {
for (const element of choice.elements) {
const fields = [
`choiceID=${choice.id}`,
`order=${choice.orderIndex}`,
`elementID=${element.id}`,
`geosetID=${element.geosetID || 0}`,
`skinnedModelID=${element.skinnedModelID || 0}`,
];
if (element.material) fields.push(`FileDataID=${element.material.FileDataID || 0}`);
if (element.skinnedModel) fields.push(`collectionFileDataID=${element.skinnedModel.collectionFileDataID || 0}`);
console.log(fields.join(' '));
}
}
+167 -16
View File
@@ -11,9 +11,17 @@ import boto3
from botocore.config import Config
from botocore.exceptions import ClientError
from patch_layout import PATCH_LAYOUT, normalized_path
S3_PREFIX = "World of Warcraft"
MANIFEST_S3_KEY = "manifest.json"
MANAGED_MANIFEST_S3_KEY = "moonwell-managed-manifest.json"
LEGACY_MANAGED_FILES = {
"utils/warcraftxlhost.exe",
"utils/warcraftxlhost.log",
"warcraftxl.before-texture-upload-fix.dll",
}
def compute_build_hash(files: list[dict]) -> str:
lines = [
@@ -25,15 +33,59 @@ def compute_build_hash(files: list[dict]) -> str:
def normalized_path_key(path: str) -> str:
"""Match client paths using Windows filesystem semantics."""
return path.replace("\\", "/").casefold()
return normalized_path(path)
def merge_manifests(base_manifest: dict, staging_manifest: dict) -> dict:
def missing_repository_patches(manifest: dict) -> list[str]:
paths = {
normalized_path_key(item["path"])
for item in manifest.get("files", [])
if isinstance(item, dict) and isinstance(item.get("path"), str)
}
return sorted(PATCH_LAYOUT.repository_patches - paths)
def external_graphics_patches(manifest: dict) -> list[str]:
return sorted(
item["path"]
for item in manifest.get("files", [])
if isinstance(item, dict)
and isinstance(item.get("path"), str)
and PATCH_LAYOUT.is_external_graphics_patch(item["path"])
)
def is_legacy_managed_path(path: str) -> bool:
key = normalized_path_key(path)
return (
key in LEGACY_MANAGED_FILES
or key in {"wow.exe", "d3d9.dll", "warcraftxl.dll", "utils/d3d9-native.dll"}
or key.startswith("extensions/")
or key.startswith("data/patch-wxl.mpq/")
)
def merge_manifests(
base_manifest: dict,
staging_manifest: dict,
previous_managed_paths: list[str] | None = None,
) -> dict:
previous_managed_keys = {
normalized_path_key(path) for path in (previous_managed_paths or [])
}
files_by_path = {
normalized_path_key(item["path"]): item
for item in base_manifest.get("files", [])
if not (
normalized_path_key(item["path"]) in previous_managed_keys
or is_legacy_managed_path(item["path"])
or PATCH_LAYOUT.is_repository_patch(item["path"])
)
or PATCH_LAYOUT.is_external_graphics_patch(item["path"])
}
for item in staging_manifest.get("files", []):
if PATCH_LAYOUT.is_external_graphics_patch(item["path"]):
continue
files_by_path[normalized_path_key(item["path"])] = item
files = sorted(files_by_path.values(), key=lambda item: item["path"].casefold())
@@ -43,6 +95,36 @@ def merge_manifests(base_manifest: dict, staging_manifest: dict) -> dict:
}
def find_stale_managed_paths(
base_manifest: dict,
staging_manifest: dict,
previous_managed_paths: list[str] | None = None,
) -> list[str]:
previous_managed_keys = {
normalized_path_key(path) for path in (previous_managed_paths or [])
}
staging_keys = {
normalized_path_key(item["path"])
for item in staging_manifest.get("files", [])
}
return sorted(
(
item["path"]
for item in base_manifest.get("files", [])
if (
(
normalized_path_key(item["path"]) in previous_managed_keys
or is_legacy_managed_path(item["path"])
or PATCH_LAYOUT.is_repository_patch(item["path"])
)
and not PATCH_LAYOUT.is_external_graphics_patch(item["path"])
)
and normalized_path_key(item["path"]) not in staging_keys
),
key=str.casefold,
)
def load_dotenv(env_path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not env_path.exists():
@@ -103,6 +185,24 @@ def main() -> int:
print(f"ERROR: unable to read local manifest.json: {error}", file=sys.stderr)
return 1
missing_repository = missing_repository_patches(staging_manifest)
if missing_repository:
print(
"ERROR: local manifest is missing repository patches: "
+ ", ".join(missing_repository),
file=sys.stderr,
)
return 1
unexpected_graphics = external_graphics_patches(staging_manifest)
if unexpected_graphics:
print(
"ERROR: managed manifest contains externally managed graphics patches: "
+ ", ".join(unexpected_graphics),
file=sys.stderr,
)
return 1
try:
response = s3.get_object(Bucket=bucket, Key=MANIFEST_S3_KEY)
production_manifest = json.loads(response["Body"].read())
@@ -116,7 +216,32 @@ def main() -> int:
print(f"ERROR: invalid production manifest: {error}", file=sys.stderr)
return 1
merged_manifest = merge_manifests(production_manifest, staging_manifest)
try:
response = s3.get_object(Bucket=bucket, Key=MANAGED_MANIFEST_S3_KEY)
previous_managed_manifest = json.loads(response["Body"].read())
previous_managed_paths = [
item["path"] for item in previous_managed_manifest.get("files", [])
]
except ClientError as error:
error_code = error.response.get("Error", {}).get("Code")
if error_code not in {"NoSuchKey", "404"}:
print(f"ERROR: unable to read managed manifest: {error}", file=sys.stderr)
return 1
previous_managed_paths = []
except (UnicodeDecodeError, json.JSONDecodeError, KeyError) as error:
print(f"ERROR: invalid managed manifest: {error}", file=sys.stderr)
return 1
merged_manifest = merge_manifests(
production_manifest,
staging_manifest,
previous_managed_paths,
)
stale_managed_paths = find_stale_managed_paths(
production_manifest,
staging_manifest,
previous_managed_paths,
)
print(
"Manifest merge: "
f"production={len(production_manifest.get('files', []))}, "
@@ -139,6 +264,9 @@ def main() -> int:
for local_path in files:
rel_path = local_path.relative_to(dist_dir).as_posix()
if PATCH_LAYOUT.is_external_graphics_patch(rel_path):
print(f"Skipping externally managed graphics patch: {rel_path}")
continue
s3_key = f"{S3_PREFIX}/{rel_path}"
size_mb = local_path.stat().st_size / (1024 * 1024)
print(f"Uploading {rel_path} ({size_mb:.1f} MB) -> s3://{bucket}/{s3_key}")
@@ -154,27 +282,50 @@ def main() -> int:
print("\nFinished with errors; production manifest was not changed.")
return 1
manifest_path.write_text(
json.dumps(merged_manifest, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
newline="\n",
json_content_type = "application/json"
no_cache = "no-cache, no-store, must-revalidate"
print(
f"Uploading managed package inventory -> "
f"s3://{bucket}/{MANAGED_MANIFEST_S3_KEY}"
)
try:
s3.put_object(
Bucket=bucket,
Key=MANAGED_MANIFEST_S3_KEY,
Body=(json.dumps(staging_manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
ContentType=json_content_type,
CacheControl=no_cache,
)
print(" OK")
except Exception as e:
print(f" ERROR: {e}", file=sys.stderr)
return 1
print(f"Uploading manifest.json -> s3://{bucket}/{MANIFEST_S3_KEY}")
try:
s3.upload_file(
str(manifest_path),
bucket,
MANIFEST_S3_KEY,
ExtraArgs={
"ContentType": "application/json",
"CacheControl": "no-cache, no-store, must-revalidate",
},
s3.put_object(
Bucket=bucket,
Key=MANIFEST_S3_KEY,
Body=(json.dumps(merged_manifest, ensure_ascii=False, indent=2) + "\n").encode("utf-8"),
ContentType=json_content_type,
CacheControl=no_cache,
)
print(f" OK")
except Exception as e:
print(f" ERROR: {e}", file=sys.stderr)
errors = True
return 1
if stale_managed_paths:
print(f"Deleting {len(stale_managed_paths)} stale managed object(s)...")
for relative_path in stale_managed_paths:
s3_key = f"{S3_PREFIX}/{relative_path.replace(chr(92), '/')}"
try:
s3.delete_object(Bucket=bucket, Key=s3_key)
print(f" Deleted {s3_key}")
except Exception as e:
print(f" ERROR deleting {s3_key}: {e}", file=sys.stderr)
errors = True
if errors:
print("\nFinished with errors.")
+135
View File
@@ -0,0 +1,135 @@
#!/usr/bin/env python3
import argparse
import sys
from pathlib import Path
from patch_layout import PATCH_LAYOUT, normalized_path
EXTENSIONS = {
"MoonWell",
"wxl-db2",
"wxl-fdid-moonwell",
"wxl-grasswind",
"wxl-modern-adt",
"wxl-modern-blp",
"wxl-modern-m2",
"wxl-modern-wmo",
"wxl-moonwell-storage-fallback",
"wxl-unit-outline",
}
EXTENSION_KEYS = {name.casefold() for name in EXTENSIONS}
ALLOWED_TOP_LEVEL_FILES = {
"d3d9.dll",
"warcraftxl.dll",
"wow.exe",
}
ALLOWED_TOP_LEVEL_DIRS = {"data", "extensions", "utils"}
REQUIRED_RUNTIME_FILES = {
"wow.exe",
"d3d9.dll",
"warcraftxl.dll",
*PATCH_LAYOUT.repository_patches,
"utils/d3d9-native.dll",
*(f"extensions/{name.casefold()}/{name.casefold()}.dll" for name in EXTENSIONS),
}
ALLOWED_DLL_FILES = {
path for path in REQUIRED_RUNTIME_FILES if path.endswith(".dll")
}
BUILD_ONLY_SUFFIXES = {
".bak",
".exp",
".ilk",
".lib",
".log",
".obj",
".old",
".orig",
".pdb",
".pyc",
".rej",
".temp",
".tmp",
}
def normalized(path: Path) -> str:
return normalized_path(path.as_posix())
def validate(root: Path) -> list[str]:
errors: list[str] = []
files = sorted(path for path in root.rglob("*") if path.is_file())
relative_files = {normalized(path.relative_to(root)) for path in files}
for missing in sorted(REQUIRED_RUNTIME_FILES - relative_files):
errors.append(f"missing required runtime file: {missing}")
for entry in root.iterdir():
key = entry.name.casefold()
if entry.is_file() and key not in ALLOWED_TOP_LEVEL_FILES:
errors.append(f"unexpected top-level file: {entry.name}")
elif entry.is_dir() and key not in ALLOWED_TOP_LEVEL_DIRS:
errors.append(f"unexpected top-level directory: {entry.name}")
for path in files:
relative = path.relative_to(root)
relative_key = normalized(relative)
parts = relative.parts
if PATCH_LAYOUT.is_external_graphics_patch(relative.as_posix()):
errors.append(
f"externally managed graphics patch in repository package: {relative.as_posix()}"
)
if path.suffix.casefold() in BUILD_ONLY_SUFFIXES:
errors.append(f"build-only or temporary file: {relative.as_posix()}")
if path.suffix.casefold() == ".dll" and relative_key not in ALLOWED_DLL_FILES:
errors.append(f"unexpected DLL: {relative.as_posix()}")
if path.suffix.casefold() == ".exe" and relative_key != "wow.exe":
errors.append(f"unexpected executable: {relative.as_posix()}")
if parts and parts[0].casefold() == "extensions":
if len(parts) != 3:
errors.append(f"unexpected Extensions layout: {relative.as_posix()}")
continue
extension_name = parts[1]
expected_file = f"{extension_name}.dll"
if extension_name.casefold() not in EXTENSION_KEYS:
errors.append(f"unexpected extension: {relative.as_posix()}")
elif parts[2].casefold() != expected_file.casefold():
errors.append(f"unexpected file in extension package: {relative.as_posix()}")
return errors
def main() -> int:
parser = argparse.ArgumentParser(description="Validate MoonWell package layout")
parser.add_argument("--dir", required=True, help="Package staging directory")
args = parser.parse_args()
root = Path(args.dir).resolve()
if not root.is_dir():
print(f"ERROR: package directory not found: {root}", file=sys.stderr)
return 1
errors = validate(root)
if errors:
print("ERROR: package validation failed:", file=sys.stderr)
for error in errors:
print(f" - {error}", file=sys.stderr)
return 1
print(f"OK: package layout validated ({len(list(root.rglob('*')))} entries)")
return 0
if __name__ == "__main__":
raise SystemExit(main())