11 Commits

99 changed files with 2573 additions and 19 deletions
+5
View File
@@ -1,15 +1,20 @@
[submodule "vendor/warcraftxl"]
path = vendor/warcraftxl
url = https://github.com/WarcraftXL/wxl-core.git
branch = main
[submodule "vendor/modules/wxl-modern-assets"]
path = vendor/modules/wxl-modern-assets
url = https://github.com/WarcraftXL/wxl-modern-assets.git
branch = main
[submodule "vendor/modules/wxl-modern-render"]
path = vendor/modules/wxl-modern-render
url = https://github.com/WarcraftXL/wxl-modern-render.git
branch = main
[submodule "vendor/modules/wxl-unit-outline"]
path = vendor/modules/wxl-unit-outline
url = https://github.com/WarcraftXL/wxl-unit-outline.git
branch = main
[submodule "vendor/modules/wxl-modern-adt"]
path = vendor/modules/wxl-modern-adt
url = https://github.com/WarcraftXL/wxl-modern-adt.git
branch = main
+3
View File
@@ -41,6 +41,8 @@ if(TARGET WarcraftXL)
endif()
if(TARGET WarcraftXLHost)
wxl_collect_sources(WXL_MOONWELL_HOST
"${CMAKE_CURRENT_SOURCE_DIR}/modules/moonwell/host/*.cpp")
wxl_collect_sources(WXL_MODERN_ASSETS_HOST
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-assets/host/*.cpp"
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-assets/shared/*.cpp")
@@ -53,6 +55,7 @@ if(TARGET WarcraftXLHost)
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-adt/shared/*.cpp")
target_sources(WarcraftXLHost PRIVATE
${WXL_MOONWELL_HOST}
${WXL_MODERN_ASSETS_HOST}
${WXL_MODERN_ADT_HOST})
endif()
+102
View File
@@ -0,0 +1,102 @@
# Автоматический импорт mount и pet
Список современных моделей находится в `custom-creatures.json`. В нём есть две независимые
секции:
```json
{
"mounts": [
"assets/creatures/my_mount"
],
"pets": [
"assets/creatures/my_pet"
]
}
```
Папка должна быть результатом **Raw M2 export** из wow.export и содержать:
- один основной `.m2`;
- `*.manifest.json`;
- все упомянутые в manifest текстуры, skin, skeleton, bone и animation-файлы.
Разрешены абсолютные пути, например `C:/Users/sindo/wow.export/creature/catslime`, но для
воспроизводимой сборки папку лучше скопировать внутрь репозитория и указать относительный путь.
## Что делает сборщик
При обычном запуске `tool.exe`, `run.ps1` или `deploy.ps1` сборщик:
1. читает manifest каждой модели и размещает retail M2 со всеми зависимостями в loose-каталоге
`Data/Patch-WXL.MPQ`, создавая рядом `.mdx`-алиас для загрузчика WoW 3.3.5, чтобы WarcraftXL всегда
преобразовывал MD21 до загрузки клиентом;
2. генерирует `WXLFileData.csv` для WarcraftXL;
3. дописывает модели в `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc`;
4. сохраняет стабильные ID в `custom-creatures.lock.json`;
5. для `pets` создаёт companion summon spell и строку skill line `778`;
6. кладёт серверные DBC, SQL и таблицу ID в `build/server-dbc`.
Полный `Spell.dbc` в патч не копируется. WarcraftXL читает компактный
`WXLSpellOverrides.tsv` и при открытии DBC добавляет новые spell-записи к штатному файлу клиента.
Перестановка моделей в списке не меняет уже выданные ID. Lock-файл необходимо хранить в Git.
## Дополнительные параметры
Если значений по умолчанию недостаточно, вместо строки используется объект:
```json
{
"folder": "assets/creatures/my_mount",
"key": "my_mount",
"name": "My Mount",
"name_ru": "Мой транспорт",
"race_mask": 1101,
"icon": "my_icon.jpg",
"archive": "Creature/MyMount",
"model_file": "my_mount.m2",
"template_display_id": 2404,
"display_scale": 1.0,
"model_scale": 1.0,
"collision_width": 0.6111,
"collision_height": 2.031,
"mount_height": 1.8657,
"flags": 0
}
```
Для `mounts` по умолчанию клонируется верховая лошадь с display ID `2404`, для `pets` —
компаньон-кот с display ID `5448`. Шаблон задаёт физические размеры, звук и прочие поля DBC;
параметры объекта позволяют их переопределить.
Для `pets` поле `race_mask` ограничивает строку коллекции набором рас (`1101` — Альянс,
`690` — Орда, `0` или отсутствие поля — все расы). Поле `icon` указывает JPG, PNG или TGA рядом
с моделью либо путь от корня проекта. Сборщик конвертирует изображение в BLP2, выдаёт стабильный
`spellIconID`, добавляет запись в `SpellIcon.dbc` через MoonWell provider и назначает её summon-spell.
## Конвертация иконок
Встроенная команда CLI преобразует JPG, PNG или TGA в совместимый с клиентом BLP2:
```powershell
tool.exe icon "путь/к/icon.jpg" ["путь/к/icon.blp"]
```
Если выходной путь не указан, `.blp` создаётся рядом с исходным файлом. Конвертер автоматически
приводит изображение к ближайшему квадратному размеру степени двойки (не более 512×512), кодирует
его в DXT1 и создаёт mipmap-уровни. Например, иконка 56×56 будет преобразована в 64×64.
## Установка на сервер
Для каждого элемента `pets` сборщик автоматически выдаёт `creatureEntry`, `summonSpellID` и
`skillLineAbilityID`. Значения находятся в `build/server-dbc/custom-creatures.csv`.
1. Скопировать `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc` из `build/server-dbc` в
серверный каталог `data/dbc`.
2. Применить `build/server-dbc/custom-creatures.sql` к `acore_world`.
3. Перезапустить worldserver: DBC и таблицы `spell_dbc` загружаются только при старте.
4. Для проверки изучить выданный spell командой `.learn <summonSpellID>`.
SQL создаёт `creature_template`, `creature_model_info`, `creature_template_model`, companion spell и строку skill line.
Генерация полноценного mount spell для секции `mounts` остаётся отдельным этапом: mount требует
другой spell-шаблон с аурами скорости и `SPELL_AURA_MOUNTED`.
Binary file not shown.
Binary file not shown.
+40
View File
@@ -0,0 +1,40 @@
{
"base_dbc_dir": "assets/dbc/3.3.5a",
"model_data_id_start": 50000,
"display_info_id_start": 50000,
"creature_entry_id_start": 5000000,
"summon_spell_id_start": 110000,
"skill_line_ability_id_start": 30000,
"spell_icon_id_start": 50000,
"defaults": {
"mount_template_display_id": 2404,
"pet_template_display_id": 5448
},
"mounts": [],
"pets": [
{
"folder": "src/Data/patch-Z/Creature/Catslime",
"name": "Cat Slime",
"name_ru": "Котослизень",
"display_scale": 0.3
},
{
"folder": "src/Data/patch-Z/Creature/MoonkinHatchling_Alliance",
"key": "moonkin_hatchling_alliance",
"race_mask": 1101,
"icon": "inv_misc_petmoonkinne.jpg",
"name": "Moonkin Hatchling",
"name_ru": "Лунный совушек",
"display_scale": 0.3
},
{
"folder": "src/Data/patch-Z/Creature/MoonkinHatchling_Horde",
"key": "moonkin_hatchling_horde",
"race_mask": 690,
"icon": "inv_misc_petmoonkinta.jpg",
"name": "Moonkin Hatchling",
"name_ru": "Лунный совушек",
"display_scale": 0.3
}
]
}
+27
View File
@@ -0,0 +1,27 @@
{
"models": {
"catslime": {
"model_data_id": 50000,
"display_info_id": 50000,
"creature_entry": 5000000,
"summon_spell_id": 110000,
"skill_line_ability_id": 30000
},
"moonkin_hatchling_alliance": {
"model_data_id": 50001,
"display_info_id": 50001,
"creature_entry": 5000001,
"summon_spell_id": 110001,
"skill_line_ability_id": 30001,
"spell_icon_id": 50000
},
"moonkin_hatchling_horde": {
"model_data_id": 50002,
"display_info_id": 50002,
"creature_entry": 5000002,
"summon_spell_id": 110002,
"skill_line_ability_id": 30002,
"spell_icon_id": 50001
}
}
}
+4 -3
View File
@@ -76,11 +76,12 @@ New-Item -ItemType Directory -Path $PackagePath -Force | Out-Null
if (-not $SkipDataBuild) {
Write-Host '[2/5] Building MPQ patches...'
$tool = Join-Path $repoRoot 'tool\target\release\tool.exe'
if (-not (Test-Path -LiteralPath $tool -PathType Leaf)) {
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
if (-not $cargo) { throw 'Cargo was not found and the MPQ builder is not compiled.' }
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
if ($cargo) {
& $cargo.Source build --release --manifest-path (Join-Path $repoRoot 'tool\Cargo.toml')
if ($LASTEXITCODE -ne 0) { throw 'MPQ builder compilation failed.' }
} elseif (-not (Test-Path -LiteralPath $tool -PathType Leaf)) {
throw 'Cargo was not found and the MPQ builder is not compiled.'
}
& $tool (Join-Path $repoRoot 'src') $PackagePath
@@ -0,0 +1,354 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// Appends compact MoonWell spell overrides to the stock 3.3.5 DBCs at open time.
#include "Host.hpp"
#include "core/Logger.hpp"
#include "mpq/MpqStore.hpp"
#include <cctype>
#include <charconv>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <string>
#include <string_view>
#include <system_error>
#include <vector>
namespace moonwell::host
{
namespace
{
constexpr std::string_view kOverridesPath = "WXLSpellOverrides.tsv";
constexpr std::string_view kSpellPath = "DBFilesClient\\Spell.dbc";
constexpr std::string_view kSkillPath = "DBFilesClient\\SkillLineAbility.dbc";
constexpr std::string_view kSpellIconPath = "DBFilesClient\\SpellIcon.dbc";
constexpr uint32_t kSpellFields = 234;
constexpr uint32_t kSpellRecordSize = kSpellFields * 4;
constexpr uint32_t kSkillFields = 14;
constexpr uint32_t kSkillRecordSize = kSkillFields * 4;
constexpr uint32_t kSpellIconFields = 2;
constexpr uint32_t kSpellIconRecordSize = kSpellIconFields * 4;
constexpr char8_t kDescriptionPrefixUtf8[] =
u8"\u041F\u0440\u0438\u0437\u044B\u0432\u0430\u0435\u0442 \u0438\u043B\u0438 \u043E\u0442\u043F\u0443\u0441\u043A\u0430\u0435\u0442 \u0441\u043F\u0443\u0442\u043D\u0438\u043A\u0430: ";
struct Override
{
uint32_t spellId = 0;
uint32_t templateSpellId = 0;
uint32_t creatureEntry = 0;
uint32_t skillLineAbilityId = 0;
uint32_t raceMask = 0;
uint32_t spellIconId = 0;
std::string iconPath;
std::string name;
};
std::once_flag g_loadOnce;
bool g_ready = false;
std::vector<uint8_t> g_spellDbc;
std::vector<uint8_t> g_skillDbc;
std::vector<uint8_t> g_spellIconDbc;
uint32_t ReadU32(const uint8_t* data)
{
uint32_t value = 0;
std::memcpy(&value, data, sizeof(value));
return value;
}
void WriteU32(uint8_t* data, uint32_t value)
{
std::memcpy(data, &value, sizeof(value));
}
std::string_view Trim(std::string_view value)
{
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front())))
value.remove_prefix(1);
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.back())))
value.remove_suffix(1);
return value;
}
bool ParseU32(std::string_view text, uint32_t& value)
{
text = Trim(text);
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size();
}
bool SamePath(std::string_view left, std::string_view right)
{
if (left.size() != right.size()) return false;
for (size_t i = 0; i < left.size(); ++i)
{
const unsigned char a = static_cast<unsigned char>(left[i] == '/' ? '\\' : left[i]);
const unsigned char b = static_cast<unsigned char>(right[i] == '/' ? '\\' : right[i]);
if (std::tolower(a) != std::tolower(b)) return false;
}
return true;
}
bool ParseOverrides(const std::vector<uint8_t>& bytes, std::vector<Override>& out)
{
const std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
size_t lineStart = 0;
while (lineStart < text.size())
{
size_t lineEnd = text.find('\n', lineStart);
if (lineEnd == std::string_view::npos) lineEnd = text.size();
std::string_view line = Trim(text.substr(lineStart, lineEnd - lineStart));
lineStart = lineEnd + 1;
if (line.empty() || line.front() == '#') continue;
std::vector<std::string_view> fields;
size_t fieldStart = 0;
while (true)
{
const size_t tab = line.find('\t', fieldStart);
if (tab == std::string_view::npos)
{
fields.push_back(Trim(line.substr(fieldStart)));
break;
}
fields.push_back(Trim(line.substr(fieldStart, tab - fieldStart)));
fieldStart = tab + 1;
}
Override item;
if (fields.size() == 5)
{
if (!ParseU32(fields[0], item.spellId) ||
!ParseU32(fields[1], item.templateSpellId) ||
!ParseU32(fields[2], item.creatureEntry) ||
!ParseU32(fields[3], item.skillLineAbilityId) || fields[4].empty())
return false;
item.name.assign(fields[4]);
}
else if (fields.size() == 8)
{
if (!ParseU32(fields[0], item.spellId) ||
!ParseU32(fields[1], item.templateSpellId) ||
!ParseU32(fields[2], item.creatureEntry) ||
!ParseU32(fields[3], item.skillLineAbilityId) ||
!ParseU32(fields[4], item.raceMask) ||
!ParseU32(fields[5], item.spellIconId) || fields[7].empty())
return false;
if (item.spellIconId != 0)
{
if (fields[6].empty() || fields[6] == "-") return false;
item.iconPath.assign(fields[6]);
}
item.name.assign(fields[7]);
}
else
{
return false;
}
out.push_back(std::move(item));
}
return !out.empty();
}
bool ValidateWdbc(const std::vector<uint8_t>& bytes, uint32_t fields, uint32_t recordSize,
uint32_t& count, uint32_t& stringSize, size_t& recordsEnd)
{
if (bytes.size() < 20 || std::memcmp(bytes.data(), "WDBC", 4) != 0) return false;
count = ReadU32(bytes.data() + 4);
if (ReadU32(bytes.data() + 8) != fields || ReadU32(bytes.data() + 12) != recordSize)
return false;
stringSize = ReadU32(bytes.data() + 16);
recordsEnd = 20ull + static_cast<size_t>(count) * recordSize;
return recordsEnd <= bytes.size() && recordsEnd + stringSize == bytes.size();
}
const uint8_t* FindSpell(const std::vector<uint8_t>& bytes, uint32_t count, uint32_t id)
{
for (uint32_t i = 0; i < count; ++i)
{
const uint8_t* row = bytes.data() + 20 + static_cast<size_t>(i) * kSpellRecordSize;
if (ReadU32(row) == id) return row;
}
return nullptr;
}
const uint8_t* FindSkillRow(const std::vector<uint8_t>& bytes, uint32_t count, uint32_t spellId)
{
for (uint32_t i = 0; i < count; ++i)
{
const uint8_t* row = bytes.data() + 20 + static_cast<size_t>(i) * kSkillRecordSize;
if (ReadU32(row + 4) == 778 && ReadU32(row + 8) == spellId) return row;
}
return nullptr;
}
bool BuildSpellDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
std::vector<uint8_t>& out)
{
uint32_t count = 0, stringSize = 0;
size_t recordsEnd = 0;
if (!ValidateWdbc(base, kSpellFields, kSpellRecordSize, count, stringSize, recordsEnd))
return false;
std::vector<uint8_t> records;
records.reserve(overrides.size() * kSpellRecordSize);
std::vector<uint8_t> strings;
for (const Override& item : overrides)
{
const uint8_t* source = FindSpell(base, count, item.templateSpellId);
if (!source) return false;
const size_t recordStart = records.size();
records.insert(records.end(), source, source + kSpellRecordSize);
uint8_t* row = records.data() + recordStart;
WriteU32(row, item.spellId);
WriteU32(row + 110 * 4, item.creatureEntry);
if (item.spellIconId != 0) WriteU32(row + 133 * 4, item.spellIconId);
const uint32_t nameOffset = stringSize + static_cast<uint32_t>(strings.size());
strings.insert(strings.end(), item.name.begin(), item.name.end());
strings.push_back(0);
const std::string description =
std::string(reinterpret_cast<const char*>(kDescriptionPrefixUtf8)) + item.name + ".";
const uint32_t descriptionOffset = stringSize + static_cast<uint32_t>(strings.size());
strings.insert(strings.end(), description.begin(), description.end());
strings.push_back(0);
for (uint32_t field = 136; field <= 151; ++field) WriteU32(row + field * 4, nameOffset);
for (uint32_t field = 170; field <= 185; ++field) WriteU32(row + field * 4, descriptionOffset);
for (uint32_t field = 187; field <= 202; ++field) WriteU32(row + field * 4, 0);
}
out.reserve(base.size() + records.size() + strings.size());
out.insert(out.end(), base.begin(), base.begin() + 20);
WriteU32(out.data() + 4, count + static_cast<uint32_t>(overrides.size()));
WriteU32(out.data() + 16, stringSize + static_cast<uint32_t>(strings.size()));
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
out.insert(out.end(), records.begin(), records.end());
out.insert(out.end(), base.begin() + recordsEnd, base.end());
out.insert(out.end(), strings.begin(), strings.end());
return true;
}
bool BuildSkillDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
std::vector<uint8_t>& out)
{
uint32_t count = 0, stringSize = 0;
size_t recordsEnd = 0;
if (!ValidateWdbc(base, kSkillFields, kSkillRecordSize, count, stringSize, recordsEnd))
return false;
std::vector<uint8_t> records;
records.reserve(overrides.size() * kSkillRecordSize);
for (const Override& item : overrides)
{
const uint8_t* source = FindSkillRow(base, count, item.templateSpellId);
if (!source) return false;
const size_t recordStart = records.size();
records.insert(records.end(), source, source + kSkillRecordSize);
uint8_t* row = records.data() + recordStart;
WriteU32(row, item.skillLineAbilityId);
WriteU32(row + 8, item.spellId);
WriteU32(row + 12, item.raceMask);
}
out.reserve(base.size() + records.size());
out.insert(out.end(), base.begin(), base.begin() + 20);
WriteU32(out.data() + 4, count + static_cast<uint32_t>(overrides.size()));
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
out.insert(out.end(), records.begin(), records.end());
out.insert(out.end(), base.begin() + recordsEnd, base.end());
return true;
}
bool BuildSpellIconDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
std::vector<uint8_t>& out)
{
uint32_t count = 0, stringSize = 0;
size_t recordsEnd = 0;
if (!ValidateWdbc(base, kSpellIconFields, kSpellIconRecordSize, count, stringSize, recordsEnd))
return false;
std::vector<uint8_t> records;
std::vector<uint8_t> strings;
for (const Override& item : overrides)
{
if (item.spellIconId == 0) continue;
for (uint32_t i = 0; i < count; ++i)
{
const uint8_t* row = base.data() + 20 + static_cast<size_t>(i) * kSpellIconRecordSize;
if (ReadU32(row) == item.spellIconId) return false;
}
for (size_t i = 0; i < records.size(); i += kSpellIconRecordSize)
if (ReadU32(records.data() + i) == item.spellIconId) return false;
const uint32_t pathOffset = stringSize + static_cast<uint32_t>(strings.size());
const size_t recordStart = records.size();
records.resize(recordStart + kSpellIconRecordSize);
WriteU32(records.data() + recordStart, item.spellIconId);
WriteU32(records.data() + recordStart + 4, pathOffset);
strings.insert(strings.end(), item.iconPath.begin(), item.iconPath.end());
strings.push_back(0);
}
if (records.empty()) return true;
out.reserve(base.size() + records.size() + strings.size());
out.insert(out.end(), base.begin(), base.begin() + 20);
WriteU32(out.data() + 4, count + static_cast<uint32_t>(records.size() / kSpellIconRecordSize));
WriteU32(out.data() + 16, stringSize + static_cast<uint32_t>(strings.size()));
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
out.insert(out.end(), records.begin(), records.end());
out.insert(out.end(), base.begin() + recordsEnd, base.end());
out.insert(out.end(), strings.begin(), strings.end());
return true;
}
void Load()
{
const std::string root = wxl::host::ClientRoot();
wxl::host::mpq::MpqStore store;
std::vector<uint8_t> overrideBytes, spellBase, skillBase, spellIconBase;
if (root.empty() || !store.Mount(root) || !store.ReadAll(kOverridesPath, overrideBytes))
return;
std::vector<Override> overrides;
if (!ParseOverrides(overrideBytes, overrides) ||
!store.ReadAll(kSpellPath, spellBase) || !store.ReadAll(kSkillPath, skillBase) ||
!store.ReadAll(kSpellIconPath, spellIconBase) ||
!BuildSpellDbc(spellBase, overrides, g_spellDbc) ||
!BuildSkillDbc(skillBase, overrides, g_skillDbc) ||
!BuildSpellIconDbc(spellIconBase, overrides, g_spellIconDbc))
{
WLOG_ERROR("moonwell-spells: failed to build custom companion DBCs");
return;
}
g_ready = true;
WLOG_INFO("moonwell-spells: appended %zu companion spell(s)", overrides.size());
}
bool Provide(std::string_view name, std::vector<uint8_t>& out)
{
if (!SamePath(name, kSpellPath) && !SamePath(name, kSkillPath) &&
!SamePath(name, kSpellIconPath))
return false;
std::call_once(g_loadOnce, &Load);
if (!g_ready) return false;
if (SamePath(name, kSpellPath))
out = g_spellDbc;
else if (SamePath(name, kSkillPath))
out = g_skillDbc;
else
{
if (g_spellIconDbc.empty()) return false;
out = g_spellIconDbc;
}
return true;
}
struct Registrar
{
Registrar() { wxl::host::RegisterProvider("moonwell-companion-spells", &Provide); }
};
Registrar g_registrar;
}
}
@@ -0,0 +1,95 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// FileDataID resolver for selected retail assets shipped in MoonWell patches.
#include "Host.hpp"
#include "core/Logger.hpp"
#include "mpq/MpqStore.hpp"
#include <charconv>
#include <cstdint>
#include <mutex>
#include <string>
#include <string_view>
#include <system_error>
#include <unordered_map>
#include <vector>
namespace moonwell::host
{
namespace
{
constexpr std::string_view kFileDataMap = "WXLFileData.csv";
std::once_flag g_loadOnce;
std::unordered_map<uint32_t, std::string> g_paths;
std::string_view Trim(std::string_view value)
{
while (!value.empty() && (value.front() == ' ' || value.front() == '\t' ||
value.front() == '\r' || value.front() == '\n'))
value.remove_prefix(1);
while (!value.empty() && (value.back() == ' ' || value.back() == '\t' ||
value.back() == '\r' || value.back() == '\n'))
value.remove_suffix(1);
return value;
}
void Load()
{
const std::string root = wxl::host::ClientRoot();
wxl::host::mpq::MpqStore store;
std::vector<uint8_t> bytes;
if (root.empty() || !store.Mount(root) || !store.ReadAll(kFileDataMap, bytes))
{
WLOG_WARN("moonwell: FileDataID map '%.*s' was not found",
int(kFileDataMap.size()), kFileDataMap.data());
return;
}
const std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
size_t lineStart = 0;
while (lineStart < text.size())
{
size_t lineEnd = text.find('\n', lineStart);
if (lineEnd == std::string_view::npos) lineEnd = text.size();
std::string_view line = Trim(text.substr(lineStart, lineEnd - lineStart));
lineStart = lineEnd + 1;
if (line.empty() || line.front() == '#') continue;
const size_t comma = line.find(',');
if (comma == std::string_view::npos) continue;
const std::string_view idText = Trim(line.substr(0, comma));
std::string_view pathText = Trim(line.substr(comma + 1));
uint32_t fileDataId = 0;
const auto parsed = std::from_chars(idText.data(), idText.data() + idText.size(), fileDataId);
if (parsed.ec != std::errc{} || parsed.ptr != idText.data() + idText.size() ||
fileDataId == 0 || pathText.empty())
continue;
std::string path(pathText);
for (char& c : path) if (c == '/') c = '\\';
g_paths[fileDataId] = std::move(path);
}
WLOG_INFO("moonwell: loaded %zu FileDataID path(s) from %.*s",
g_paths.size(), int(kFileDataMap.size()), kFileDataMap.data());
}
bool Resolve(uint32_t fileDataId, std::string& outPath)
{
std::call_once(g_loadOnce, &Load);
const auto found = g_paths.find(fileDataId);
if (found == g_paths.end()) return false;
outPath = found->second;
return true;
}
struct Registrar
{
Registrar() { wxl::host::RegisterResolver("moonwell-filedata", &Resolve); }
};
Registrar g_registrar;
}
}
+85 -5
View File
@@ -37,6 +37,42 @@ if (-not $env:WOW_HOME) {
$WOW_HOME = $env:WOW_HOME
Write-Host "WOW_HOME = $WOW_HOME"
function Test-ExistingFileWriteAccess {
param([Parameter(Mandatory=$true)][string]$Path)
try {
$stream = [System.IO.File]::Open(
$Path,
[System.IO.FileMode]::Open,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::ReadWrite
)
$stream.Close()
return $true
} catch [System.UnauthorizedAccessException] {
return $false
} catch {
# Sharing violations are handled later by Stop-WowRuntime. They do not
# indicate that the whole script needs an elevated token.
return $true
}
}
$installedWarcraftXL = Join-Path $WOW_HOME "WarcraftXL.dll"
$isAdministrator = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole(
[Security.Principal.WindowsBuiltInRole]::Administrator
)
if ((Test-Path -LiteralPath $installedWarcraftXL -PathType Leaf) -and
-not (Test-ExistingFileWriteAccess -Path $installedWarcraftXL) -and
-not $isAdministrator) {
Write-Host "WarcraftXL.dll requires administrator access. Requesting elevation..."
$elevatedProcess = Start-Process -FilePath "powershell.exe" -Verb RunAs -PassThru -Wait `
-WorkingDirectory $ROOT `
-ArgumentList "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "`"$($MyInvocation.MyCommand.Path)`"", "-Env", $Env
exit $elevatedProcess.ExitCode
}
# --- Paths
$SRC_DIR = Join-Path $ROOT "src"
$DIST_DIR = Join-Path $ROOT "dist"
@@ -44,11 +80,49 @@ $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"
# --- Ensure tool exists
if (!(Test-Path $TOOL)) {
Write-Host "Building Rust tool..."
Push-Location (Join-Path $ROOT "tool")
cargo build --release
function Stop-WowRuntime {
param([Parameter(Mandatory=$true)][string]$ClientPath)
$clientRoot = [System.IO.Path]::GetFullPath($ClientPath).TrimEnd('\') + '\'
$runtimeProcesses = @(Get-Process -Name "Wow", "WarcraftXLHost" -ErrorAction SilentlyContinue | Where-Object {
try {
$_.Path -and $_.Path.StartsWith($clientRoot, [System.StringComparison]::OrdinalIgnoreCase)
} catch {
$false
}
})
if (-not $runtimeProcesses.Count) {
return
}
Write-Host "Stopping the running WoW client and WarcraftXL host..."
$runtimeProcesses | Stop-Process -Force
try {
$runtimeProcesses | Wait-Process -Timeout 10 -ErrorAction Stop
} catch {
$stillRunning = @($runtimeProcesses | Where-Object { -not $_.HasExited })
if ($stillRunning.Count) {
$processNames = ($stillRunning | ForEach-Object { "$($_.ProcessName) (PID $($_.Id))" }) -join ", "
throw "Could not stop processes that lock WarcraftXL files: $processNames"
}
}
}
# --- Always rebuild the tool so generated DBC/SQL formats match the current sources.
$cargo = Get-Command cargo -ErrorAction SilentlyContinue
if (-not $cargo) {
throw "Cargo was not found; the MPQ/DBC builder cannot be refreshed."
}
Write-Host "Building Rust tool..."
Push-Location (Join-Path $ROOT "tool")
try {
& $cargo.Source build --release
if ($LASTEXITCODE -ne 0) {
throw "Rust tool build failed with exit code $LASTEXITCODE."
}
} finally {
Pop-Location
}
@@ -68,6 +142,12 @@ if (!(Test-Path (Join-Path $DIST_DIR "Data"))) {
# --- Build/package/deploy WarcraftXL before the dist sync. The packaged files
# are uploaded by upload_to_s3.py and consumed by the launcher manifest.
Stop-WowRuntime -ClientPath $WOW_HOME
$creatureCache = Join-Path $WOW_HOME "Cache\WDB\ruRU\creaturecache.wdb"
if (Test-Path -LiteralPath $creatureCache -PathType Leaf) {
Remove-Item -LiteralPath $creatureCache -Force
Write-Host "Cleared stale creature cache: $creatureCache"
}
Write-Host "Building and packaging WarcraftXL..."
& $WXL_BUILD_SCRIPT -Configuration Release -ClientPath $WOW_HOME `
-PackagePath $DIST_DIR -Deploy
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 MiB

Binary file not shown.
@@ -0,0 +1,95 @@
{
"fileDataID": 3730958,
"textures": [
{
"fileDataID": 3732460,
"file": "catslime_3732460.blp"
},
{
"fileDataID": 3732461,
"file": "catslime_3732461.blp"
}
],
"skins": [
{
"fileDataID": 3732472,
"file": "catslime00.skin"
}
],
"lodSkins": [
{
"fileDataID": 3732473,
"file": "catslime_lod01.skin"
},
{
"fileDataID": 3732474,
"file": "catslime_lod02.skin"
},
{
"fileDataID": 3732475,
"file": "catslime_lod03.skin"
}
],
"anims": [
{
"fileDataID": 3732462,
"file": "catslime0097-00.anim",
"animID": 97,
"subAnimID": 0
},
{
"fileDataID": 3732463,
"file": "catslime0096-00.anim",
"animID": 96,
"subAnimID": 0
},
{
"fileDataID": 3732464,
"file": "catslime0098-00.anim",
"animID": 98,
"subAnimID": 0
},
{
"fileDataID": 3732465,
"file": "catslime0100-00.anim",
"animID": 100,
"subAnimID": 0
},
{
"fileDataID": 3732466,
"file": "catslime0099-00.anim",
"animID": 99,
"subAnimID": 0
},
{
"fileDataID": 3732467,
"file": "catslime0101-00.anim",
"animID": 101,
"subAnimID": 0
},
{
"fileDataID": 3732468,
"file": "catslime0069-00.anim",
"animID": 69,
"subAnimID": 0
},
{
"fileDataID": 3732469,
"file": "catslime0123-00.anim",
"animID": 123,
"subAnimID": 0
},
{
"fileDataID": 3732470,
"file": "catslime0061-00.anim",
"animID": 61,
"subAnimID": 0
},
{
"fileDataID": 3732471,
"file": "catslime0075-00.anim",
"animID": 75,
"subAnimID": 0
}
]
}
Binary file not shown.
@@ -0,0 +1,160 @@
{
"fileDataID": 467885,
"textures": [
{
"fileDataID": 467884,
"file": "babymoonkinne.blp"
},
{
"fileDataID": 944915,
"file": "armorreflect4.blp"
}
],
"skins": [
{
"fileDataID": 473055,
"file": "babymoonkin_ne00.skin"
}
],
"lodSkins": [],
"anims": [
{
"fileDataID": 473051,
"file": "babymoonkin_ne0074-00.anim",
"animID": 74,
"subAnimID": 0
},
{
"fileDataID": 473072,
"file": "babymoonkin_ne0062-00.anim",
"animID": 62,
"subAnimID": 0
},
{
"fileDataID": 473068,
"file": "babymoonkin_ne0060-00.anim",
"animID": 60,
"subAnimID": 0
},
{
"fileDataID": 473061,
"file": "babymoonkin_ne0070-00.anim",
"animID": 70,
"subAnimID": 0
},
{
"fileDataID": 473038,
"file": "babymoonkin_ne0097-00.anim",
"animID": 97,
"subAnimID": 0
},
{
"fileDataID": 473067,
"file": "babymoonkin_ne0096-00.anim",
"animID": 96,
"subAnimID": 0
},
{
"fileDataID": 473047,
"file": "babymoonkin_ne0098-00.anim",
"animID": 98,
"subAnimID": 0
},
{
"fileDataID": 473040,
"file": "babymoonkin_ne0100-00.anim",
"animID": 100,
"subAnimID": 0
},
{
"fileDataID": 473035,
"file": "babymoonkin_ne0099-00.anim",
"animID": 99,
"subAnimID": 0
},
{
"fileDataID": 473041,
"file": "babymoonkin_ne0101-00.anim",
"animID": 101,
"subAnimID": 0
},
{
"fileDataID": 473031,
"file": "babymoonkin_ne0069-00.anim",
"animID": 69,
"subAnimID": 0
},
{
"fileDataID": 473050,
"file": "babymoonkin_ne0069-01.anim",
"animID": 69,
"subAnimID": 1
},
{
"fileDataID": 473046,
"file": "babymoonkin_ne0069-02.anim",
"animID": 69,
"subAnimID": 2
},
{
"fileDataID": 473042,
"file": "babymoonkin_ne0069-03.anim",
"animID": 69,
"subAnimID": 3
},
{
"fileDataID": 473060,
"file": "babymoonkin_ne0061-00.anim",
"animID": 61,
"subAnimID": 0
},
{
"fileDataID": 473030,
"file": "babymoonkin_ne0067-00.anim",
"animID": 67,
"subAnimID": 0
},
{
"fileDataID": 473037,
"file": "babymoonkin_ne0075-00.anim",
"animID": 75,
"subAnimID": 0
},
{
"fileDataID": 473069,
"file": "babymoonkin_ne0133-00.anim",
"animID": 133,
"subAnimID": 0
},
{
"fileDataID": 473057,
"file": "babymoonkin_ne0134-00.anim",
"animID": 134,
"subAnimID": 0
},
{
"fileDataID": 473074,
"file": "babymoonkin_ne0123-00.anim",
"animID": 123,
"subAnimID": 0
},
{
"fileDataID": 473056,
"file": "babymoonkin_ne0128-00.anim",
"animID": 128,
"subAnimID": 0
},
{
"fileDataID": 473029,
"file": "babymoonkin_ne0129-00.anim",
"animID": 129,
"subAnimID": 0
},
{
"fileDataID": 473073,
"file": "babymoonkin_ne0079-00.anim",
"animID": 79,
"subAnimID": 0
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

@@ -0,0 +1,160 @@
{
"fileDataID": 467887,
"textures": [
{
"fileDataID": 467886,
"file": "babymoonkinta.blp"
},
{
"fileDataID": 249237,
"file": "..\\..\\item\\objectcomponents\\shield\\armorreflect4.blp"
}
],
"skins": [
{
"fileDataID": 473058,
"file": "babymoonkin_ta00.skin"
}
],
"lodSkins": [],
"anims": [
{
"fileDataID": 473066,
"file": "babymoonkin_ta0074-00.anim",
"animID": 74,
"subAnimID": 0
},
{
"fileDataID": 473039,
"file": "babymoonkin_ta0062-00.anim",
"animID": 62,
"subAnimID": 0
},
{
"fileDataID": 473064,
"file": "babymoonkin_ta0060-00.anim",
"animID": 60,
"subAnimID": 0
},
{
"fileDataID": 473075,
"file": "babymoonkin_ta0070-00.anim",
"animID": 70,
"subAnimID": 0
},
{
"fileDataID": 473053,
"file": "babymoonkin_ta0097-00.anim",
"animID": 97,
"subAnimID": 0
},
{
"fileDataID": 473032,
"file": "babymoonkin_ta0096-00.anim",
"animID": 96,
"subAnimID": 0
},
{
"fileDataID": 473076,
"file": "babymoonkin_ta0098-00.anim",
"animID": 98,
"subAnimID": 0
},
{
"fileDataID": 473071,
"file": "babymoonkin_ta0100-00.anim",
"animID": 100,
"subAnimID": 0
},
{
"fileDataID": 473036,
"file": "babymoonkin_ta0099-00.anim",
"animID": 99,
"subAnimID": 0
},
{
"fileDataID": 473048,
"file": "babymoonkin_ta0101-00.anim",
"animID": 101,
"subAnimID": 0
},
{
"fileDataID": 473065,
"file": "babymoonkin_ta0069-00.anim",
"animID": 69,
"subAnimID": 0
},
{
"fileDataID": 473044,
"file": "babymoonkin_ta0069-01.anim",
"animID": 69,
"subAnimID": 1
},
{
"fileDataID": 473070,
"file": "babymoonkin_ta0069-02.anim",
"animID": 69,
"subAnimID": 2
},
{
"fileDataID": 473052,
"file": "babymoonkin_ta0069-03.anim",
"animID": 69,
"subAnimID": 3
},
{
"fileDataID": 473062,
"file": "babymoonkin_ta0061-00.anim",
"animID": 61,
"subAnimID": 0
},
{
"fileDataID": 473043,
"file": "babymoonkin_ta0067-00.anim",
"animID": 67,
"subAnimID": 0
},
{
"fileDataID": 473045,
"file": "babymoonkin_ta0075-00.anim",
"animID": 75,
"subAnimID": 0
},
{
"fileDataID": 473034,
"file": "babymoonkin_ta0133-00.anim",
"animID": 133,
"subAnimID": 0
},
{
"fileDataID": 473063,
"file": "babymoonkin_ta0134-00.anim",
"animID": 134,
"subAnimID": 0
},
{
"fileDataID": 473054,
"file": "babymoonkin_ta0123-00.anim",
"animID": 123,
"subAnimID": 0
},
{
"fileDataID": 473049,
"file": "babymoonkin_ta0128-00.anim",
"animID": 128,
"subAnimID": 0
},
{
"fileDataID": 473033,
"file": "babymoonkin_ta0129-00.anim",
"animID": 129,
"subAnimID": 0
},
{
"fileDataID": 473059,
"file": "babymoonkin_ta0079-00.anim",
"animID": 79,
"subAnimID": 0
}
]
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.
@@ -9,6 +9,7 @@
AchievementFactions.lua
MoonWellClient.lua
SoloLFGCompat.lua
EncounterJournal\EncounterJournalItemCache.lua
EncounterJournal\EncounterJournalCompat.lua
EncounterJournal\SharedXML\SoundKitConstants.lua
@@ -0,0 +1,27 @@
local SOLO_RANDOM_DUNGEONS = {
[1258] = true,
[1259] = true,
[1260] = true,
[1261] = true,
[1262] = true,
}
local GetStockTexCoordsForRole = GetTexCoordsForRole
function GetTexCoordsForRole(role)
if role == "UNKNOWN" then
local proposalExists, _, dungeonID = GetLFGProposal()
if proposalExists and SOLO_RANDOM_DUNGEONS[dungeonID] then
local _, tank, healer = GetLFGRoles()
if tank then
role = "TANK"
elseif healer then
role = "HEALER"
else
role = "DAMAGER"
end
end
end
return GetStockTexCoordsForRole(role)
end
+288 -7
View File
@@ -82,6 +82,18 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit_field"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.11.0"
@@ -103,6 +115,12 @@ version = "3.20.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb"
[[package]]
name = "bytemuck"
version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
[[package]]
name = "byteorder"
version = "1.5.0"
@@ -170,6 +188,12 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "colorchoice"
version = "1.0.5"
@@ -253,6 +277,12 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crunchy"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
[[package]]
name = "crypto-common"
version = "0.1.7"
@@ -313,12 +343,38 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "exr"
version = "1.74.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3"
dependencies = [
"bit_field",
"half",
"lebe",
"miniz_oxide",
"num-complex",
"pulp",
"rayon-core",
"smallvec",
"zune-inflate",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "flate2"
version = "1.1.9"
@@ -381,6 +437,27 @@ dependencies = [
"wasip3",
]
[[package]]
name = "gif"
version = "0.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ae047235e33e2829703574b54fdec96bfbad892062d97fed2f76022287de61b"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "half"
version = "2.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b"
dependencies = [
"cfg-if",
"crunchy",
"zerocopy",
]
[[package]]
name = "hashbrown"
version = "0.15.5"
@@ -416,6 +493,39 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "image"
version = "0.24.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d"
dependencies = [
"bytemuck",
"byteorder",
"color_quant",
"exr",
"gif",
"jpeg-decoder",
"num-traits",
"png",
"qoi",
"tiff",
]
[[package]]
name = "image-blp"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b863207a84a413c9e3e94909828ac796d24b1a6f53baa88f186d4cf61eca691f"
dependencies = [
"color_quant",
"image",
"log",
"nom",
"spin",
"texpresso",
"thiserror 1.0.69",
]
[[package]]
name = "implode"
version = "0.1.1"
@@ -459,6 +569,15 @@ version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jpeg-decoder"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00810f1d8b74be64b13dbf3db89ac67740615d6c891f0e7b6179326533011a07"
dependencies = [
"rayon",
]
[[package]]
name = "js-sys"
version = "0.3.91"
@@ -484,6 +603,12 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "lebe"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8"
[[package]]
name = "libbz2-rs-sys"
version = "0.2.2"
@@ -558,6 +683,12 @@ version = "2.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
@@ -568,6 +699,16 @@ dependencies = [
"simd-adler32",
]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
@@ -594,6 +735,16 @@ dependencies = [
"zeroize",
]
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"bytemuck",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
@@ -665,6 +816,12 @@ dependencies = [
"windows-link",
]
[[package]]
name = "paste"
version = "1.0.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a"
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
@@ -703,7 +860,20 @@ checksum = "5a7ff566c5cb263cf6a9f3b8b8ccbeaf236c0b3516c184134c062863258b3789"
dependencies = [
"clap",
"indicatif",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
name = "png"
version = "0.17.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526"
dependencies = [
"bitflags 1.3.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide",
]
[[package]]
@@ -740,6 +910,38 @@ dependencies = [
"unicode-ident",
]
[[package]]
name = "pulp"
version = "0.22.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a"
dependencies = [
"bytemuck",
"cfg-if",
"libm",
"num-complex",
"paste",
"pulp-wasm-simd-flag",
"raw-cpuid",
"reborrow",
"version_check",
]
[[package]]
name = "pulp-wasm-simd-flag"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740"
[[package]]
name = "qoi"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001"
dependencies = [
"bytemuck",
]
[[package]]
name = "quote"
version = "1.0.45"
@@ -819,6 +1021,15 @@ dependencies = [
"getrandom 0.3.4",
]
[[package]]
name = "raw-cpuid"
version = "11.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186"
dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "rayon"
version = "1.11.0"
@@ -839,13 +1050,19 @@ dependencies = [
"crossbeam-utils",
]
[[package]]
name = "reborrow"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430"
[[package]]
name = "redox_syscall"
version = "0.5.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d"
dependencies = [
"bitflags",
"bitflags 2.11.0",
]
[[package]]
@@ -874,7 +1091,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"errno",
"libc",
"linux-raw-sys",
@@ -915,6 +1132,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
@@ -988,6 +1206,9 @@ name = "spin"
version = "0.9.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67"
dependencies = [
"lock_api",
]
[[package]]
name = "spki"
@@ -1035,13 +1256,43 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "texpresso"
version = "2.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8550677e2259d675a7841cb1403db35f330cc9e58674c8c5caa12dd12c51dc71"
dependencies = [
"libm",
"rayon",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl 1.0.69",
]
[[package]]
name = "thiserror"
version = "2.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4"
dependencies = [
"thiserror-impl",
"thiserror-impl 2.0.18",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
@@ -1055,10 +1306,25 @@ dependencies = [
"syn",
]
[[package]]
name = "tiff"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e"
dependencies = [
"flate2",
"jpeg-decoder",
"weezl",
]
[[package]]
name = "tool"
version = "0.1.0"
dependencies = [
"image",
"image-blp",
"serde",
"serde_json",
"walkdir",
"wow-mpq",
]
@@ -1206,7 +1472,7 @@ version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags",
"bitflags 2.11.0",
"hashbrown 0.15.5",
"indexmap",
"semver",
@@ -1222,6 +1488,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "weezl"
version = "0.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88"
[[package]]
name = "winapi-util"
version = "0.1.11"
@@ -1377,7 +1649,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags",
"bitflags 2.11.0",
"indexmap",
"log",
"serde",
@@ -1434,7 +1706,7 @@ dependencies = [
"rsa",
"sha1",
"tempfile",
"thiserror",
"thiserror 2.0.18",
]
[[package]]
@@ -1468,3 +1740,12 @@ name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
[[package]]
name = "zune-inflate"
version = "0.2.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02"
dependencies = [
"simd-adler32",
]
+4
View File
@@ -6,3 +6,7 @@ edition = "2024"
[dependencies]
walkdir = "2.5.0"
wow-mpq = "0.6.4"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
image = { version = "0.24.9", default-features = false, features = ["jpeg", "png", "tga"] }
image-blp = "1.2.0"
+923
View File
@@ -0,0 +1,923 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{BTreeMap, HashSet};
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
const MODEL_FIELDS: u32 = 28;
const MODEL_RECORD_SIZE: u32 = 112;
const DISPLAY_FIELDS: u32 = 16;
const DISPLAY_RECORD_SIZE: u32 = 64;
#[derive(Debug, Deserialize)]
pub struct Registry {
pub base_dbc_dir: String,
#[serde(default = "default_model_id")]
pub model_data_id_start: u32,
#[serde(default = "default_display_id")]
pub display_info_id_start: u32,
#[serde(default = "default_creature_entry")]
pub creature_entry_id_start: u32,
#[serde(default = "default_summon_spell_id")]
pub summon_spell_id_start: u32,
#[serde(default = "default_skill_line_ability_id")]
pub skill_line_ability_id_start: u32,
#[serde(default = "default_spell_icon_id")]
pub spell_icon_id_start: u32,
pub defaults: Defaults,
#[serde(default)]
pub mounts: Vec<EntrySpec>,
#[serde(default)]
pub pets: Vec<EntrySpec>,
}
fn default_model_id() -> u32 {
50_000
}
fn default_display_id() -> u32 {
50_000
}
fn default_creature_entry() -> u32 {
5_000_000
}
fn default_summon_spell_id() -> u32 {
110_000
}
fn default_skill_line_ability_id() -> u32 {
30_000
}
fn default_spell_icon_id() -> u32 {
50_000
}
#[derive(Debug, Deserialize)]
pub struct Defaults {
pub mount_template_display_id: u32,
pub pet_template_display_id: u32,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
pub enum EntrySpec {
Folder(String),
Detailed(Box<EntryOptions>),
}
#[derive(Debug, Default, Deserialize)]
pub struct EntryOptions {
pub folder: String,
pub key: Option<String>,
pub archive: Option<String>,
pub model_file: Option<String>,
pub template_display_id: Option<u32>,
pub display_scale: Option<f32>,
pub model_scale: Option<f32>,
pub collision_width: Option<f32>,
pub collision_height: Option<f32>,
pub mount_height: Option<f32>,
pub flags: Option<u32>,
pub name: Option<String>,
pub name_ru: Option<String>,
pub race_mask: Option<u32>,
pub icon: Option<String>,
}
impl EntrySpec {
fn options(&self) -> EntryOptions {
match self {
EntrySpec::Folder(folder) => EntryOptions {
folder: folder.clone(),
..Default::default()
},
EntrySpec::Detailed(options) => EntryOptions {
folder: options.folder.clone(),
key: options.key.clone(),
archive: options.archive.clone(),
model_file: options.model_file.clone(),
template_display_id: options.template_display_id,
display_scale: options.display_scale,
model_scale: options.model_scale,
collision_width: options.collision_width,
collision_height: options.collision_height,
mount_height: options.mount_height,
flags: options.flags,
name: options.name.clone(),
name_ru: options.name_ru.clone(),
race_mask: options.race_mask,
icon: options.icon.clone(),
},
}
}
}
#[derive(Debug, Default, Deserialize, Serialize)]
struct LockFile {
#[serde(default)]
models: BTreeMap<String, LockedIds>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
struct LockedIds {
model_data_id: u32,
display_info_id: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
creature_entry: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
summon_spell_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
skill_line_ability_id: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
spell_icon_id: Option<u32>,
}
#[derive(Debug)]
struct PreparedEntry {
key: String,
folder: PathBuf,
archive: String,
model_file: String,
file_data: Vec<(u32, String)>,
assets: Vec<(PathBuf, String)>,
template_display_id: u32,
ids: LockedIds,
options: EntryOptions,
summon: bool,
name: String,
name_ru: String,
icon_path: Option<String>,
icon_source: Option<PathBuf>,
icon_bytes: Option<Vec<u8>>,
}
pub struct GeneratedCreatures {
pub assets: Vec<(PathBuf, String)>,
pub icons: Vec<(PathBuf, Vec<u8>, String)>,
pub file_data_csv: Vec<u8>,
pub creature_model_data: Vec<u8>,
pub creature_display_info: Vec<u8>,
pub spell_overrides: Vec<u8>,
}
#[derive(Clone)]
struct Wdbc {
field_count: u32,
record_size: u32,
records: Vec<Vec<u8>>,
strings: Vec<u8>,
}
impl Wdbc {
fn read(path: &Path, fields: u32, record_size: u32) -> Result<Self, Box<dyn Error>> {
let bytes = fs::read(path)?;
if bytes.len() < 20 || &bytes[0..4] != b"WDBC" {
return Err(format!("{} is not a WDBC file", path.display()).into());
}
let count = read_u32(&bytes, 4)? as usize;
let actual_fields = read_u32(&bytes, 8)?;
let actual_size = read_u32(&bytes, 12)?;
let string_size = read_u32(&bytes, 16)? as usize;
if actual_fields != fields || actual_size != record_size {
return Err(format!(
"{} has unexpected layout: fields={}, record_size={}",
path.display(),
actual_fields,
actual_size
)
.into());
}
let records_end = 20usize
.checked_add(
count
.checked_mul(record_size as usize)
.ok_or("DBC size overflow")?,
)
.ok_or("DBC size overflow")?;
if records_end
.checked_add(string_size)
.ok_or("DBC size overflow")?
!= bytes.len()
{
return Err(format!("{} has an invalid header size", path.display()).into());
}
let mut records = Vec::with_capacity(count + 16);
for record in bytes[20..records_end].chunks_exact(record_size as usize) {
records.push(record.to_vec());
}
Ok(Self {
field_count: fields,
record_size,
records,
strings: bytes[records_end..].to_vec(),
})
}
fn record(&self, id: u32) -> Option<&Vec<u8>> {
self.records
.iter()
.find(|row| read_u32(row, 0).ok() == Some(id))
}
fn add_string(&mut self, value: &str) -> u32 {
if value.is_empty() {
return 0;
}
let offset = self.strings.len() as u32;
self.strings.extend_from_slice(value.as_bytes());
self.strings.push(0);
offset
}
fn build(mut self) -> Vec<u8> {
self.records
.sort_by_key(|row| read_u32(row, 0).unwrap_or(0));
let mut bytes = Vec::with_capacity(
20 + self.records.len() * self.record_size as usize + self.strings.len(),
);
bytes.extend_from_slice(b"WDBC");
bytes.extend_from_slice(&(self.records.len() as u32).to_le_bytes());
bytes.extend_from_slice(&self.field_count.to_le_bytes());
bytes.extend_from_slice(&self.record_size.to_le_bytes());
bytes.extend_from_slice(&(self.strings.len() as u32).to_le_bytes());
for row in self.records {
bytes.extend_from_slice(&row);
}
bytes.extend_from_slice(&self.strings);
bytes
}
}
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, Box<dyn Error>> {
let value = bytes
.get(offset..offset + 4)
.ok_or("record field is out of bounds")?;
Ok(u32::from_le_bytes(value.try_into()?))
}
fn write_u32(bytes: &mut [u8], field: usize, value: u32) -> Result<(), Box<dyn Error>> {
let offset = field.checked_mul(4).ok_or("record field overflow")?;
bytes
.get_mut(offset..offset + 4)
.ok_or("record field is out of bounds")?
.copy_from_slice(&value.to_le_bytes());
Ok(())
}
fn write_f32(bytes: &mut [u8], field: usize, value: f32) -> Result<(), Box<dyn Error>> {
write_u32(bytes, field, value.to_bits())
}
fn normalized_archive(value: &str) -> String {
value.trim_matches(['/', '\\']).replace('\\', "/")
}
fn resolve_path(project_root: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
project_root.join(path)
}
}
fn default_key(folder: &Path) -> Result<String, Box<dyn Error>> {
Ok(folder
.file_name()
.ok_or("creature folder has no name")?
.to_string_lossy()
.to_ascii_lowercase())
}
fn default_archive(folder: &Path) -> Result<String, Box<dyn Error>> {
Ok(format!(
"Creature/{}",
folder
.file_name()
.ok_or("creature folder has no name")?
.to_string_lossy()
))
}
fn find_manifest(folder: &Path) -> Result<PathBuf, Box<dyn Error>> {
let manifests: Vec<PathBuf> = fs::read_dir(folder)?
.filter_map(Result::ok)
.map(|entry| entry.path())
.filter(|path| {
path.file_name()
.is_some_and(|name| name.to_string_lossy().ends_with(".manifest.json"))
})
.collect();
match manifests.as_slice() {
[manifest] => Ok(manifest.clone()),
[] => Err(format!("no *.manifest.json in {}", folder.display()).into()),
_ => Err(format!("more than one *.manifest.json in {}", folder.display()).into()),
}
}
fn find_model(folder: &Path, explicit: Option<&str>) -> Result<String, Box<dyn Error>> {
if let Some(model) = explicit {
return Ok(model.to_string());
}
let models: Vec<String> = fs::read_dir(folder)?
.filter_map(Result::ok)
.filter(|entry| {
entry
.path()
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("m2"))
})
.map(|entry| entry.file_name().to_string_lossy().into_owned())
.collect();
match models.as_slice() {
[model] => Ok(model.clone()),
[] => Err(format!("no .m2 file in {}", folder.display()).into()),
_ => Err(format!(
"more than one .m2 file in {}; set model_file",
folder.display()
)
.into()),
}
}
fn collect_manifest_files(value: &Value, out: &mut Vec<(u32, String)>) {
match value {
Value::Object(object) => {
if let (Some(id), Some(file)) = (
object.get("fileDataID").and_then(Value::as_u64),
object.get("file").and_then(Value::as_str),
) && id <= u32::MAX as u64
{
out.push((id as u32, file.to_string()));
}
for child in object.values() {
collect_manifest_files(child, out);
}
}
Value::Array(array) => {
for child in array {
collect_manifest_files(child, out);
}
}
_ => {}
}
}
fn next_free(start: u32, used: &HashSet<u32>) -> u32 {
let mut value = start;
while used.contains(&value) {
value = value.checked_add(1).expect("custom ID range exhausted");
}
value
}
fn allocate_ids(
lock: &mut LockFile,
key: &str,
registry: &Registry,
summon: bool,
custom_icon: bool,
) -> LockedIds {
let used_models: HashSet<u32> = lock.models.values().map(|ids| ids.model_data_id).collect();
let used_displays: HashSet<u32> = lock
.models
.values()
.map(|ids| ids.display_info_id)
.collect();
let used_creatures: HashSet<u32> = lock
.models
.values()
.filter_map(|ids| ids.creature_entry)
.collect();
let used_spells: HashSet<u32> = lock
.models
.values()
.filter_map(|ids| ids.summon_spell_id)
.collect();
let used_skill_rows: HashSet<u32> = lock
.models
.values()
.filter_map(|ids| ids.skill_line_ability_id)
.collect();
let used_spell_icons: HashSet<u32> = lock
.models
.values()
.filter_map(|ids| ids.spell_icon_id)
.collect();
let mut ids = lock.models.get(key).cloned().unwrap_or(LockedIds {
model_data_id: next_free(registry.model_data_id_start, &used_models),
display_info_id: next_free(registry.display_info_id_start, &used_displays),
creature_entry: None,
summon_spell_id: None,
skill_line_ability_id: None,
spell_icon_id: None,
});
if summon {
if ids.creature_entry.is_none() {
ids.creature_entry = Some(next_free(registry.creature_entry_id_start, &used_creatures));
}
if ids.summon_spell_id.is_none() {
ids.summon_spell_id = Some(next_free(registry.summon_spell_id_start, &used_spells));
}
if ids.skill_line_ability_id.is_none() {
ids.skill_line_ability_id = Some(next_free(
registry.skill_line_ability_id_start,
&used_skill_rows,
));
}
if custom_icon && ids.spell_icon_id.is_none() {
ids.spell_icon_id = Some(next_free(registry.spell_icon_id_start, &used_spell_icons));
}
}
lock.models.insert(key.to_string(), ids.clone());
ids
}
fn validate_text(value: &str, field: &str, key: &str) -> Result<(), Box<dyn Error>> {
if value.contains(['\t', '\r', '\n']) {
return Err(format!("{} for '{}' contains a tab or newline", field, key).into());
};
Ok(())
}
fn resolve_icon_path(project_root: &Path, folder: &Path, value: &str) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
let beside_model = folder.join(&path);
if beside_model.is_file() {
beside_model
} else {
project_root.join(path)
}
}
}
fn icon_file_stem(key: &str) -> String {
let sanitized: String = key
.chars()
.map(|character| {
if character.is_ascii_alphanumeric() || character == '_' {
character
} else {
'_'
}
})
.collect();
format!("MoonWell_{sanitized}")
}
fn prepare_entry(
project_root: &Path,
spec: &EntrySpec,
template_default: u32,
summon: bool,
lock: &mut LockFile,
registry: &Registry,
) -> Result<PreparedEntry, Box<dyn Error>> {
let options = spec.options();
let folder = resolve_path(project_root, &options.folder).canonicalize()?;
if !folder.is_dir() {
return Err(format!("creature folder not found: {}", folder.display()).into());
}
let key = options.key.clone().unwrap_or(default_key(&folder)?);
let name = options.name.clone().unwrap_or_else(|| key.clone());
let name_ru = options.name_ru.clone().unwrap_or_else(|| name.clone());
validate_text(&name, "name", &key)?;
validate_text(&name_ru, "name_ru", &key)?;
let archive = normalized_archive(
options
.archive
.as_deref()
.unwrap_or(&default_archive(&folder)?),
);
let model_file = find_model(&folder, options.model_file.as_deref())?;
let manifest_path = find_manifest(&folder)?;
let manifest: Value = serde_json::from_slice(&fs::read(&manifest_path)?)?;
let main_id = manifest
.get("fileDataID")
.and_then(Value::as_u64)
.ok_or_else(|| format!("{} has no root fileDataID", manifest_path.display()))?;
if main_id > u32::MAX as u64 {
return Err("model FileDataID is too large".into());
}
let mut dependencies = Vec::new();
collect_manifest_files(&manifest, &mut dependencies);
let mut file_data = vec![(main_id as u32, format!("{archive}/{model_file}"))];
let mut assets = vec![(folder.join(&model_file), format!("{archive}/{model_file}"))];
for (file_data_id, file) in dependencies {
let source = folder.join(&file);
if !source.is_file() {
return Err(format!("manifest dependency not found: {}", source.display()).into());
}
file_data.push((file_data_id, format!("{archive}/{file}")));
assets.push((source, format!("{archive}/{file}")));
}
if options.icon.is_some() && !summon {
return Err(format!("icon is only supported for pet '{}'", key).into());
}
let (icon_path, icon_source, icon_bytes) = if let Some(value) = options.icon.as_deref() {
let source = resolve_icon_path(project_root, &folder, value);
if !source.is_file() {
return Err(format!("icon source not found: {}", source.display()).into());
}
let stem = icon_file_stem(&key);
(
Some(format!("Interface\\Icons\\{stem}")),
Some(source.clone()),
Some(crate::icon::convert_icon_bytes(&source)?),
)
} else {
(None, None, None)
};
let ids = allocate_ids(lock, &key, registry, summon, icon_path.is_some());
Ok(PreparedEntry {
key,
folder,
archive,
model_file,
file_data,
assets,
template_display_id: options.template_display_id.unwrap_or(template_default),
ids,
options,
summon,
name,
name_ru,
icon_path,
icon_source,
icon_bytes,
})
}
fn generate_dbc(
base_dir: &Path,
entries: &[PreparedEntry],
) -> Result<(Vec<u8>, Vec<u8>), Box<dyn Error>> {
let mut models = Wdbc::read(
&base_dir.join("CreatureModelData.dbc"),
MODEL_FIELDS,
MODEL_RECORD_SIZE,
)?;
let mut displays = Wdbc::read(
&base_dir.join("CreatureDisplayInfo.dbc"),
DISPLAY_FIELDS,
DISPLAY_RECORD_SIZE,
)?;
let base_models = models.clone();
let base_displays = displays.clone();
for entry in entries {
if base_models.record(entry.ids.model_data_id).is_some()
|| base_displays.record(entry.ids.display_info_id).is_some()
{
return Err(format!("custom IDs for '{}' collide with base DBC", entry.key).into());
}
let template_display = base_displays
.record(entry.template_display_id)
.ok_or_else(|| {
format!(
"template display {} not found for '{}'",
entry.template_display_id, entry.key
)
})?
.clone();
let template_model_id = read_u32(&template_display, 4)?;
let mut model = base_models
.record(template_model_id)
.ok_or_else(|| {
format!(
"model {} for template display {} not found",
template_model_id, entry.template_display_id
)
})?
.clone();
let mut display = template_display;
let model_stem = Path::new(&entry.model_file)
.file_stem()
.ok_or("model file has no stem")?
.to_string_lossy();
let dbc_model_path = format!("{}\\{}.mdx", entry.archive.replace('/', "\\"), model_stem);
let string_offset = models.add_string(&dbc_model_path);
write_u32(&mut model, 0, entry.ids.model_data_id)?;
write_u32(&mut model, 2, string_offset)?;
if let Some(value) = entry.options.flags {
write_u32(&mut model, 1, value)?;
}
if let Some(value) = entry.options.model_scale {
write_f32(&mut model, 4, value)?;
}
if let Some(value) = entry.options.collision_width {
write_f32(&mut model, 14, value)?;
}
if let Some(value) = entry.options.collision_height {
write_f32(&mut model, 15, value)?;
}
if let Some(value) = entry.options.mount_height {
write_f32(&mut model, 16, value)?;
}
write_u32(&mut display, 0, entry.ids.display_info_id)?;
write_u32(&mut display, 1, entry.ids.model_data_id)?;
if let Some(value) = entry.options.display_scale {
write_f32(&mut display, 4, value)?;
}
// Modern models carry their textures through TXID/FileDataID. Template skin names must not override them.
for field in 6..=9 {
write_u32(&mut display, field, 0)?;
}
models.records.push(model);
displays.records.push(display);
}
Ok((models.build(), displays.build()))
}
fn sql_string(value: &str) -> String {
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''"))
}
fn generate_server_sql(entries: &[PreparedEntry]) -> Result<String, Box<dyn Error>> {
let mut sql = String::from(
"-- Generated by MoonWell custom creature builder.\n\
-- Apply to acore_world, then restart ac-worldserver.\n\
SET NAMES utf8mb4;\n\
START TRANSACTION;\n\n",
);
for entry in entries.iter().filter(|entry| entry.summon) {
let creature = entry
.ids
.creature_entry
.ok_or("pet has no creature entry")?;
let spell = entry
.ids
.summon_spell_id
.ok_or("pet has no summon spell ID")?;
let skill_row = entry
.ids
.skill_line_ability_id
.ok_or("pet has no skill-line row ID")?;
let name_en = sql_string(&entry.name);
let name_ru = sql_string(&entry.name_ru);
let description_en = sql_string(&format!(
"Summons or dismisses your companion: {}.",
entry.name
));
let description_ru = sql_string(&format!(
"Призывает или отпускает спутника: {}.",
entry.name_ru
));
let race_mask = entry.options.race_mask.unwrap_or(0);
let spell_icon_id = entry.ids.spell_icon_id.unwrap_or(2686);
sql.push_str(&format!(
"-- {}\n\
INSERT INTO creature_template\n\
(entry,name,minlevel,maxlevel,faction,speed_walk,speed_run,unit_class,unit_flags,unit_flags2,type,MovementType,RegenHealth,VerifiedBuild)\n\
VALUES ({creature},{name_en},1,1,188,1,1.14286,1,0,2048,12,0,1,12340)\n\
ON DUPLICATE KEY UPDATE name=VALUES(name),faction=VALUES(faction),type=VALUES(type),unit_flags2=VALUES(unit_flags2);\n\
INSERT INTO creature_template_locale\n\
(entry,locale,Name,Title,VerifiedBuild)\n\
VALUES ({creature},'ruRU',{name_ru},'',12340)\n\
ON DUPLICATE KEY UPDATE Name=VALUES(Name),Title=VALUES(Title),VerifiedBuild=VALUES(VerifiedBuild);\n\
INSERT INTO creature_model_info\n\
(DisplayID,BoundingRadius,CombatReach,Gender,DisplayID_Other_Gender,VerifiedBuild)\n\
VALUES ({},0.25,0.5,2,0,12340)\n\
ON DUPLICATE KEY UPDATE BoundingRadius=VALUES(BoundingRadius),CombatReach=VALUES(CombatReach),Gender=VALUES(Gender),DisplayID_Other_Gender=VALUES(DisplayID_Other_Gender),VerifiedBuild=VALUES(VerifiedBuild);\n\
INSERT INTO creature_template_model\n\
(CreatureID,Idx,CreatureDisplayID,DisplayScale,Probability,VerifiedBuild)\n\
VALUES ({creature},0,{},1,1,12340)\n\
ON DUPLICATE KEY UPDATE CreatureDisplayID=VALUES(CreatureDisplayID),DisplayScale=VALUES(DisplayScale),Probability=VALUES(Probability);\n\n",
entry.key, entry.ids.display_info_id, entry.ids.display_info_id
));
sql.push_str(&format!(
"INSERT INTO spell_dbc\n\
(ID,Attributes,AttributesEx3,CastingTimeIndex,InterruptFlags,ProcChance,DurationIndex,RangeIndex,EquippedItemClass,Effect_1,EffectDieSides_1,EffectBasePoints_1,ImplicitTargetA_1,EffectMultipleValue_1,EffectMiscValue_1,EffectMiscValueB_1,SpellVisualID_1,SpellIconID,Name_Lang_enUS,Name_Lang_ruRU,Description_Lang_enUS,Description_Lang_ruRU,StartRecoveryCategory,StartRecoveryTime,EffectChainAmplitude_1,EffectChainAmplitude_2,EffectChainAmplitude_3,SchoolMask)\n\
VALUES ({spell},262416,536870912,1,31,101,21,1,-1,28,1,1,32,1000,{creature},41,353,{spell_icon_id},{name_en},{name_ru},{description_en},{description_ru},133,1500,1,1,1,1)\n\
ON DUPLICATE KEY UPDATE EffectMiscValue_1=VALUES(EffectMiscValue_1),SpellIconID=VALUES(SpellIconID),Name_Lang_enUS=VALUES(Name_Lang_enUS),Name_Lang_ruRU=VALUES(Name_Lang_ruRU),Description_Lang_enUS=VALUES(Description_Lang_enUS),Description_Lang_ruRU=VALUES(Description_Lang_ruRU);\n\
INSERT INTO skilllineability_dbc\n\
(ID,SkillLine,Spell,RaceMask,ClassMask,ExcludeRace,ExcludeClass,MinSkillLineRank,SupercededBySpell,AcquireMethod,TrivialSkillLineRankHigh,TrivialSkillLineRankLow,CharacterPoints_1,CharacterPoints_2)\n\
VALUES ({skill_row},778,{spell},{race_mask},0,0,0,1,0,0,0,0,0,0)\n\
ON DUPLICATE KEY UPDATE SkillLine=VALUES(SkillLine),Spell=VALUES(Spell),RaceMask=VALUES(RaceMask),MinSkillLineRank=VALUES(MinSkillLineRank);\n\n"
));
}
sql.push_str("COMMIT;\n");
Ok(sql)
}
pub fn prepare(project_root: &Path) -> Result<Option<GeneratedCreatures>, Box<dyn Error>> {
let registry_path = project_root.join("custom-creatures.json");
if !registry_path.is_file() {
return Ok(None);
}
let registry: Registry = serde_json::from_slice(&fs::read(&registry_path)?)?;
let lock_path = project_root.join("custom-creatures.lock.json");
let mut lock: LockFile = if lock_path.is_file() {
serde_json::from_slice(&fs::read(&lock_path)?)?
} else {
LockFile::default()
};
let original_lock = serde_json::to_vec(&lock)?;
let mut entries = Vec::new();
let mut keys = HashSet::new();
for spec in &registry.mounts {
let entry = prepare_entry(
project_root,
spec,
registry.defaults.mount_template_display_id,
false,
&mut lock,
&registry,
)?;
if !keys.insert(entry.key.clone()) {
return Err(format!("duplicate creature key '{}'", entry.key).into());
}
entries.push(entry);
}
for spec in &registry.pets {
let entry = prepare_entry(
project_root,
spec,
registry.defaults.pet_template_display_id,
true,
&mut lock,
&registry,
)?;
if !keys.insert(entry.key.clone()) {
return Err(format!("duplicate creature key '{}'", entry.key).into());
}
entries.push(entry);
}
let updated_lock = serde_json::to_vec(&lock)?;
if updated_lock != original_lock || !lock_path.exists() {
let mut pretty = serde_json::to_vec_pretty(&lock)?;
pretty.push(b'\n');
fs::write(&lock_path, pretty)?;
println!("Updated creature ID lock: {}", lock_path.display());
}
let mut all_ids = BTreeMap::new();
let mut active_model_ids = HashSet::new();
let mut active_display_ids = HashSet::new();
let mut active_creature_entries = HashSet::new();
let mut active_spell_ids = HashSet::new();
let mut active_skill_rows = HashSet::new();
let mut active_spell_icon_ids = HashSet::new();
let mut assets = Vec::new();
let mut icons = Vec::new();
for entry in &entries {
if !active_model_ids.insert(entry.ids.model_data_id) {
return Err(format!(
"duplicate modelDataID {} in creature lock",
entry.ids.model_data_id
)
.into());
}
if !active_display_ids.insert(entry.ids.display_info_id) {
return Err(format!(
"duplicate displayInfoID {} in creature lock",
entry.ids.display_info_id
)
.into());
}
for (value, used, label) in [
(
entry.ids.creature_entry,
&mut active_creature_entries,
"creature entry",
),
(
entry.ids.summon_spell_id,
&mut active_spell_ids,
"summon spell ID",
),
(
entry.ids.skill_line_ability_id,
&mut active_skill_rows,
"skill-line row ID",
),
(
entry.ids.spell_icon_id,
&mut active_spell_icon_ids,
"spell icon ID",
),
] {
if let Some(value) = value
&& !used.insert(value)
{
return Err(format!("duplicate {label} {value} in creature lock").into());
}
}
println!(
"Creature {}: modelDataID={}, displayInfoID={}, source={}",
entry.key,
entry.ids.model_data_id,
entry.ids.display_info_id,
entry.folder.display()
);
for (id, path) in &entry.file_data {
if let Some(existing) = all_ids.get(id) {
if existing != path {
return Err(format!(
"FileDataID {} maps to both '{}' and '{}'",
id, existing, path
)
.into());
}
} else {
all_ids.insert(*id, path.clone());
}
}
assets.extend(entry.assets.clone());
if let (Some(source), Some(bytes), Some(path)) = (
entry.icon_source.as_ref(),
entry.icon_bytes.as_ref(),
entry.icon_path.as_ref(),
) {
icons.push((
source.clone(),
bytes.clone(),
format!("{}.blp", path.replace('\\', "/")),
));
}
}
let mut csv = String::from("# FileDataID,archive path\n");
for (id, path) in all_ids {
csv.push_str(&format!("{id},{}\n", path.replace('/', "\\")));
}
let mut spell_overrides = String::from(
"# spellId\ttemplateSpellId\tcreatureEntry\tskillLineAbilityId\traceMask\tspellIconId\ticonPath\tname\n",
);
for entry in entries.iter().filter(|entry| entry.summon) {
spell_overrides.push_str(&format!(
"{}\t70613\t{}\t{}\t{}\t{}\t{}\t{}\n",
entry
.ids
.summon_spell_id
.ok_or("pet has no summon spell ID")?,
entry
.ids
.creature_entry
.ok_or("pet has no creature entry")?,
entry
.ids
.skill_line_ability_id
.ok_or("pet has no skill-line row ID")?,
entry.options.race_mask.unwrap_or(0),
entry.ids.spell_icon_id.unwrap_or(0),
entry.icon_path.as_deref().unwrap_or("-"),
entry.name_ru
));
}
let base_dir = resolve_path(project_root, &registry.base_dbc_dir);
let (model_dbc, display_dbc) = generate_dbc(&base_dir, &entries)?;
// Keep server-only artifacts outside the client package directory. Both run.ps1 and deploy.ps1
// mirror the complete package directory into the client and launcher manifest.
let server_dir = project_root.join("build").join("server-dbc");
fs::create_dir_all(&server_dir)?;
fs::write(server_dir.join("CreatureModelData.dbc"), &model_dbc)?;
fs::write(server_dir.join("CreatureDisplayInfo.dbc"), &display_dbc)?;
fs::write(
server_dir.join("custom-creatures.sql"),
generate_server_sql(&entries)?,
)?;
let mut id_map = String::from(
"key,type,modelDataID,displayInfoID,creatureEntry,summonSpellID,skillLineAbilityID,spellIconID\n",
);
for (kind, specs) in [("mount", &registry.mounts), ("pet", &registry.pets)] {
for spec in specs {
let options = spec.options();
let folder = resolve_path(project_root, &options.folder).canonicalize()?;
let key = options.key.unwrap_or(default_key(&folder)?);
let ids = lock.models.get(&key).ok_or("lock entry disappeared")?;
id_map.push_str(&format!(
"{key},{kind},{},{},{},{},{},{}\n",
ids.model_data_id,
ids.display_info_id,
ids.creature_entry
.map_or_else(String::new, |value| value.to_string()),
ids.summon_spell_id
.map_or_else(String::new, |value| value.to_string()),
ids.skill_line_ability_id
.map_or_else(String::new, |value| value.to_string()),
ids.spell_icon_id
.map_or_else(String::new, |value| value.to_string())
));
}
}
fs::write(server_dir.join("custom-creatures.csv"), id_map)?;
Ok(Some(GeneratedCreatures {
assets,
icons,
file_data_csv: csv.into_bytes(),
creature_model_data: model_dbc,
creature_display_info: display_dbc,
spell_overrides: spell_overrides.into_bytes(),
}))
}
+97
View File
@@ -0,0 +1,97 @@
use image::GenericImageView;
use image::imageops::FilterType as ImageFilterType;
use image_blp::convert::{Blp2Format, BlpTarget, FilterType, image_to_blp};
use image_blp::encode::encode_blp;
use std::error::Error;
use std::fs;
use std::path::{Path, PathBuf};
const MAX_ICON_SIZE: u32 = 512;
struct EncodedIcon {
bytes: Vec<u8>,
source_width: u32,
source_height: u32,
dimension: u32,
}
fn target_dimension(width: u32, height: u32) -> Result<u32, Box<dyn Error>> {
let largest = width.max(height).max(4);
let dimension = largest
.checked_next_power_of_two()
.ok_or("icon dimensions are too large")?;
if dimension > MAX_ICON_SIZE {
return Err(format!(
"icon is too large: {width}x{height}; maximum converted size is {MAX_ICON_SIZE}x{MAX_ICON_SIZE}"
)
.into());
}
Ok(dimension)
}
fn encode_icon(input: &Path) -> Result<EncodedIcon, Box<dyn Error>> {
let source = image::io::Reader::open(input)?
.with_guessed_format()?
.decode()?;
let (width, height) = source.dimensions();
let dimension = target_dimension(width, height)?;
let resized = source.resize_exact(dimension, dimension, ImageFilterType::Lanczos3);
let blp = image_to_blp(
resized,
true,
BlpTarget::Blp2(Blp2Format::Dxt1 {
has_alpha: false,
compress_algorithm: Default::default(),
}),
FilterType::Lanczos3,
)?;
Ok(EncodedIcon {
bytes: encode_blp(&blp)?,
source_width: width,
source_height: height,
dimension,
})
}
pub fn convert_icon_bytes(input: &Path) -> Result<Vec<u8>, Box<dyn Error>> {
Ok(encode_icon(input)?.bytes)
}
pub fn default_output_path(input: &Path) -> PathBuf {
input.with_extension("blp")
}
pub fn convert_icon(input: &Path, output: &Path) -> Result<(), Box<dyn Error>> {
if !input.is_file() {
return Err(format!("icon source not found: {}", input.display()).into());
}
let encoded = encode_icon(input)?;
if let Some(parent) = output.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent)?;
}
fs::write(output, encoded.bytes)?;
println!(
"Converted icon: {} ({}x{}) -> {} (BLP2 DXT1, {}x{}, mipmaps)",
input.display(),
encoded.source_width,
encoded.source_height,
output.display(),
encoded.dimension,
encoded.dimension
);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rounds_icon_dimensions_to_supported_power_of_two() {
assert_eq!(target_dimension(56, 56).unwrap(), 64);
assert_eq!(target_dimension(128, 64).unwrap(), 128);
assert!(target_dimension(513, 512).is_err());
}
}
+102 -3
View File
@@ -7,6 +7,10 @@ use walkdir::WalkDir;
use wow_mpq::compression::flags;
use wow_mpq::{ArchiveBuilder, FormatVersion, ListfileOption};
mod creatures;
mod icon;
use creatures::GeneratedCreatures;
/// Checks if a directory name looks like a WoW locale folder (e.g. ruRU, enUS, deDE).
fn is_locale_dir(name: &str) -> bool {
if name.len() != 4 {
@@ -19,7 +23,11 @@ fn is_locale_dir(name: &str) -> bool {
&& b[3].is_ascii_uppercase()
}
fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>> {
fn build_mpq(
input_dir: &Path,
output_path: &Path,
creatures: Option<&GeneratedCreatures>,
) -> Result<(), Box<dyn Error>> {
let mut output = output_path.to_path_buf();
output.set_extension("MPQ");
@@ -36,12 +44,26 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>>
.default_compression(flags::ZLIB)
.listfile_option(ListfileOption::Generate);
let loose_sources: std::collections::HashSet<PathBuf> = creatures
.into_iter()
.flat_map(|generated| generated.assets.iter())
.filter_map(|(source, _)| source.canonicalize().ok())
.collect();
let generated_icon_sources: std::collections::HashSet<PathBuf> = creatures
.into_iter()
.flat_map(|generated| generated.icons.iter())
.filter_map(|(source, _, _)| source.canonicalize().ok())
.collect();
for entry in WalkDir::new(&base)
.into_iter()
.filter_map(|e| e.ok())
.filter(|e| e.file_type().is_file())
{
let full_path = entry.path().canonicalize()?;
if loose_sources.contains(&full_path) || generated_icon_sources.contains(&full_path) {
continue;
}
let rel_path = match full_path.strip_prefix(&base) {
Ok(p) => p,
Err(_) => {
@@ -50,20 +72,82 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>>
}
};
let archive_path = rel_path.to_string_lossy().replace('\\', "/");
if archive_path.ends_with(".manifest.json") || archive_path == "WXLFileData.csv" {
continue;
}
println!(" Adding: {}", archive_path);
builder = builder.add_file(&full_path, &archive_path);
}
if let Some(generated) = creatures {
for (_, bytes, archive_path) in &generated.icons {
println!(" Adding generated icon: {archive_path}");
builder = builder.add_file_data(bytes.clone(), archive_path);
}
println!(" Adding generated: WXLFileData.csv");
builder = builder.add_file_data(generated.file_data_csv.clone(), "WXLFileData.csv");
println!(" Adding generated: WXLSpellOverrides.tsv");
builder = builder.add_file_data(generated.spell_overrides.clone(), "WXLSpellOverrides.tsv");
println!(" Adding generated: DBFilesClient/CreatureModelData.dbc");
builder = builder.add_file_data(
generated.creature_model_data.clone(),
"DBFilesClient/CreatureModelData.dbc",
);
println!(" Adding generated: DBFilesClient/CreatureDisplayInfo.dbc");
builder = builder.add_file_data(
generated.creature_display_info.clone(),
"DBFilesClient/CreatureDisplayInfo.dbc",
);
}
builder.build(&output)?;
println!("Created: {}", output.display());
Ok(())
}
fn stage_loose_assets(
output_dir: &Path,
creatures: &GeneratedCreatures,
) -> Result<(), Box<dyn Error>> {
let loose_root = output_dir.join("Data").join("Patch-WXL.MPQ");
for (source, archive_path) in &creatures.assets {
let destination = loose_root.join(archive_path.replace('/', "\\"));
if let Some(parent) = destination.parent() {
fs::create_dir_all(parent)?;
}
fs::copy(source, &destination)?;
println!("Staging loose modern asset: {}", archive_path);
if source
.extension()
.is_some_and(|extension| extension.eq_ignore_ascii_case("m2"))
{
let mdx_alias = destination.with_extension("mdx");
fs::copy(source, &mdx_alias)?;
println!("Staging WoW 3.3.5 model alias: {}", mdx_alias.display());
}
}
Ok(())
}
fn main() -> Result<(), Box<dyn Error>> {
let args: Vec<String> = env::args().collect();
if args.get(1).is_some_and(|arg| arg == "icon") {
if !(args.len() == 3 || args.len() == 4) {
eprintln!("Usage: tool icon <input.jpg|png|tga> [output.blp]");
std::process::exit(1);
}
let input = PathBuf::from(&args[2]);
let output = args
.get(3)
.map(PathBuf::from)
.unwrap_or_else(|| icon::default_output_path(&input));
return icon::convert_icon(&input, &output);
}
if args.len() != 3 {
eprintln!("Usage: tool <src_dir> <output_dir>");
eprintln!(" tool icon <input.jpg|png|tga> [output.blp]");
eprintln!();
eprintln!(" src_dir — root of source tree (must contain Data/)");
eprintln!(" output_dir — destination root; MPQs are placed mirroring the src structure");
@@ -72,6 +156,16 @@ fn main() -> Result<(), Box<dyn Error>> {
let src_dir = PathBuf::from(&args[1]);
let output_dir = PathBuf::from(&args[2]);
let project_root = src_dir
.canonicalize()?
.parent()
.ok_or("src_dir has no parent")?
.to_path_buf();
let generated_creatures = creatures::prepare(&project_root)?;
if let Some(generated) = generated_creatures.as_ref() {
stage_loose_assets(&output_dir, generated)?;
}
let data_dir = src_dir.join("Data");
if !data_dir.exists() {
@@ -97,12 +191,17 @@ fn main() -> Result<(), Box<dyn Error>> {
}
let sub_name = sub.file_name().to_string_lossy().to_string();
let output_mpq = output_dir.join("Data").join(&name).join(&sub_name);
build_mpq(&sub_path, &output_mpq)?;
build_mpq(&sub_path, &output_mpq, None)?;
}
} else {
// Regular patch dir, e.g. patch-4/ or patch-Z/
let output_mpq = output_dir.join("Data").join(&name);
build_mpq(&path, &output_mpq)?;
let creature_files = if name.eq_ignore_ascii_case("patch-Z") {
generated_creatures.as_ref()
} else {
None
};
build_mpq(&path, &output_mpq, creature_files)?;
}
}