Compare commits
8 Commits
01f476ca1b
...
887ab7879b
| Author | SHA1 | Date | |
|---|---|---|---|
| 887ab7879b | |||
| e3b8d2649e | |||
| e916cdea19 | |||
| befdaa456d | |||
| 7291fff664 | |||
| 790506fc7a | |||
| 87329f2633 | |||
| 8375b95f7b |
@@ -41,6 +41,8 @@ if(TARGET WarcraftXL)
|
||||
endif()
|
||||
|
||||
if(TARGET WarcraftXLHost)
|
||||
wxl_collect_sources(WXL_MOONWELL_HOST
|
||||
"${CMAKE_CURRENT_SOURCE_DIR}/modules/moonwell/host/*.cpp")
|
||||
wxl_collect_sources(WXL_MODERN_ASSETS_HOST
|
||||
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-assets/host/*.cpp"
|
||||
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-assets/shared/*.cpp")
|
||||
@@ -53,6 +55,7 @@ if(TARGET WarcraftXLHost)
|
||||
"${WXL_EXTERNAL_MODULES_DIR}/wxl-modern-adt/shared/*.cpp")
|
||||
|
||||
target_sources(WarcraftXLHost PRIVATE
|
||||
${WXL_MOONWELL_HOST}
|
||||
${WXL_MODERN_ASSETS_HOST}
|
||||
${WXL_MODERN_ADT_HOST})
|
||||
endif()
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
# Автоматический импорт mount и pet
|
||||
|
||||
Список современных моделей находится в `custom-creatures.json`. В нём есть две независимые
|
||||
секции:
|
||||
|
||||
```json
|
||||
{
|
||||
"mounts": [
|
||||
"assets/creatures/my_mount"
|
||||
],
|
||||
"pets": [
|
||||
"assets/creatures/my_pet"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Папка должна быть результатом **Raw M2 export** из wow.export и содержать:
|
||||
|
||||
- один основной `.m2`;
|
||||
- `*.manifest.json`;
|
||||
- все упомянутые в manifest текстуры, skin, skeleton, bone и animation-файлы.
|
||||
|
||||
Разрешены абсолютные пути, например `C:/Users/sindo/wow.export/creature/catslime`, но для
|
||||
воспроизводимой сборки папку лучше скопировать внутрь репозитория и указать относительный путь.
|
||||
|
||||
## Что делает сборщик
|
||||
|
||||
При обычном запуске `tool.exe`, `run.ps1` или `deploy.ps1` сборщик:
|
||||
|
||||
1. читает manifest каждой модели и размещает retail M2 со всеми зависимостями в loose-каталоге
|
||||
`Data/Patch-WXL.MPQ`, создавая рядом `.mdx`-алиас для загрузчика WoW 3.3.5, чтобы WarcraftXL всегда
|
||||
преобразовывал MD21 до загрузки клиентом;
|
||||
2. генерирует `WXLFileData.csv` для WarcraftXL;
|
||||
3. дописывает модели в `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc`;
|
||||
4. сохраняет стабильные ID в `custom-creatures.lock.json`;
|
||||
5. для `pets` создаёт companion summon spell и строку skill line `778`;
|
||||
6. кладёт серверные DBC, SQL и таблицу ID в `build/server-dbc`.
|
||||
|
||||
Полный `Spell.dbc` в патч не копируется. WarcraftXL читает компактный
|
||||
`WXLSpellOverrides.tsv` и при открытии DBC добавляет новые spell-записи к штатному файлу клиента.
|
||||
|
||||
Перестановка моделей в списке не меняет уже выданные ID. Lock-файл необходимо хранить в Git.
|
||||
|
||||
## Дополнительные параметры
|
||||
|
||||
Если значений по умолчанию недостаточно, вместо строки используется объект:
|
||||
|
||||
```json
|
||||
{
|
||||
"folder": "assets/creatures/my_mount",
|
||||
"key": "my_mount",
|
||||
"name": "My Mount",
|
||||
"name_ru": "Мой транспорт",
|
||||
"archive": "Creature/MyMount",
|
||||
"model_file": "my_mount.m2",
|
||||
"template_display_id": 2404,
|
||||
"display_scale": 1.0,
|
||||
"model_scale": 1.0,
|
||||
"collision_width": 0.6111,
|
||||
"collision_height": 2.031,
|
||||
"mount_height": 1.8657,
|
||||
"flags": 0
|
||||
}
|
||||
```
|
||||
|
||||
Для `mounts` по умолчанию клонируется верховая лошадь с display ID `2404`, для `pets` —
|
||||
компаньон-кот с display ID `5448`. Шаблон задаёт физические размеры, звук и прочие поля DBC;
|
||||
параметры объекта позволяют их переопределить.
|
||||
|
||||
## Установка на сервер
|
||||
|
||||
Для каждого элемента `pets` сборщик автоматически выдаёт `creatureEntry`, `summonSpellID` и
|
||||
`skillLineAbilityID`. Значения находятся в `build/server-dbc/custom-creatures.csv`.
|
||||
|
||||
1. Скопировать `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc` из `build/server-dbc` в
|
||||
серверный каталог `data/dbc`.
|
||||
2. Применить `build/server-dbc/custom-creatures.sql` к `acore_world`.
|
||||
3. Перезапустить worldserver: DBC и таблицы `spell_dbc` загружаются только при старте.
|
||||
4. Для проверки изучить выданный spell командой `.learn <summonSpellID>`.
|
||||
|
||||
SQL создаёт `creature_template`, `creature_model_info`, `creature_template_model`, companion spell и строку skill line.
|
||||
Генерация полноценного mount spell для секции `mounts` остаётся отдельным этапом: mount требует
|
||||
другой spell-шаблон с аурами скорости и `SPELL_AURA_MOUNTED`.
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"base_dbc_dir": "assets/dbc/3.3.5a",
|
||||
"model_data_id_start": 50000,
|
||||
"display_info_id_start": 50000,
|
||||
"creature_entry_id_start": 5000000,
|
||||
"summon_spell_id_start": 110000,
|
||||
"skill_line_ability_id_start": 30000,
|
||||
"defaults": {
|
||||
"mount_template_display_id": 2404,
|
||||
"pet_template_display_id": 5448
|
||||
},
|
||||
"mounts": [],
|
||||
"pets": [
|
||||
{
|
||||
"folder": "src/Data/patch-Z/Creature/Catslime",
|
||||
"name": "Cat Slime",
|
||||
"name_ru": "Котослизень",
|
||||
"display_scale": 1.0
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"models": {
|
||||
"catslime": {
|
||||
"model_data_id": 50000,
|
||||
"display_info_id": 50000,
|
||||
"creature_entry": 5000000,
|
||||
"summon_spell_id": 110000,
|
||||
"skill_line_ability_id": 30000
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -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.' }
|
||||
if ($cargo) {
|
||||
& $cargo.Source build --release --manifest-path (Join-Path $repoRoot 'tool\Cargo.toml')
|
||||
if ($LASTEXITCODE -ne 0) { throw 'MPQ builder compilation failed.' }
|
||||
} elseif (-not (Test-Path -LiteralPath $tool -PathType Leaf)) {
|
||||
throw 'Cargo was not found and the MPQ builder is not compiled.'
|
||||
}
|
||||
|
||||
& $tool (Join-Path $repoRoot 'src') $PackagePath
|
||||
|
||||
@@ -0,0 +1,264 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// Appends compact MoonWell spell overrides to the stock 3.3.5 DBCs at open time.
|
||||
|
||||
#include "Host.hpp"
|
||||
#include "core/Logger.hpp"
|
||||
#include "mpq/MpqStore.hpp"
|
||||
|
||||
#include <cctype>
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <cstring>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <vector>
|
||||
|
||||
namespace moonwell::host
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr std::string_view kOverridesPath = "WXLSpellOverrides.tsv";
|
||||
constexpr std::string_view kSpellPath = "DBFilesClient\\Spell.dbc";
|
||||
constexpr std::string_view kSkillPath = "DBFilesClient\\SkillLineAbility.dbc";
|
||||
constexpr uint32_t kSpellFields = 234;
|
||||
constexpr uint32_t kSpellRecordSize = kSpellFields * 4;
|
||||
constexpr uint32_t kSkillFields = 14;
|
||||
constexpr uint32_t kSkillRecordSize = kSkillFields * 4;
|
||||
|
||||
struct Override
|
||||
{
|
||||
uint32_t spellId = 0;
|
||||
uint32_t templateSpellId = 0;
|
||||
uint32_t creatureEntry = 0;
|
||||
uint32_t skillLineAbilityId = 0;
|
||||
std::string name;
|
||||
};
|
||||
|
||||
std::once_flag g_loadOnce;
|
||||
bool g_ready = false;
|
||||
std::vector<uint8_t> g_spellDbc;
|
||||
std::vector<uint8_t> g_skillDbc;
|
||||
|
||||
uint32_t ReadU32(const uint8_t* data)
|
||||
{
|
||||
uint32_t value = 0;
|
||||
std::memcpy(&value, data, sizeof(value));
|
||||
return value;
|
||||
}
|
||||
|
||||
void WriteU32(uint8_t* data, uint32_t value)
|
||||
{
|
||||
std::memcpy(data, &value, sizeof(value));
|
||||
}
|
||||
|
||||
std::string_view Trim(std::string_view value)
|
||||
{
|
||||
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.front())))
|
||||
value.remove_prefix(1);
|
||||
while (!value.empty() && std::isspace(static_cast<unsigned char>(value.back())))
|
||||
value.remove_suffix(1);
|
||||
return value;
|
||||
}
|
||||
|
||||
bool ParseU32(std::string_view text, uint32_t& value)
|
||||
{
|
||||
text = Trim(text);
|
||||
const auto parsed = std::from_chars(text.data(), text.data() + text.size(), value);
|
||||
return parsed.ec == std::errc{} && parsed.ptr == text.data() + text.size();
|
||||
}
|
||||
|
||||
bool SamePath(std::string_view left, std::string_view right)
|
||||
{
|
||||
if (left.size() != right.size()) return false;
|
||||
for (size_t i = 0; i < left.size(); ++i)
|
||||
{
|
||||
const unsigned char a = static_cast<unsigned char>(left[i] == '/' ? '\\' : left[i]);
|
||||
const unsigned char b = static_cast<unsigned char>(right[i] == '/' ? '\\' : right[i]);
|
||||
if (std::tolower(a) != std::tolower(b)) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool ParseOverrides(const std::vector<uint8_t>& bytes, std::vector<Override>& out)
|
||||
{
|
||||
const std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
|
||||
size_t lineStart = 0;
|
||||
while (lineStart < text.size())
|
||||
{
|
||||
size_t lineEnd = text.find('\n', lineStart);
|
||||
if (lineEnd == std::string_view::npos) lineEnd = text.size();
|
||||
std::string_view line = Trim(text.substr(lineStart, lineEnd - lineStart));
|
||||
lineStart = lineEnd + 1;
|
||||
if (line.empty() || line.front() == '#') continue;
|
||||
|
||||
std::string_view fields[5];
|
||||
size_t fieldStart = 0;
|
||||
bool valid = true;
|
||||
for (size_t i = 0; i < 4; ++i)
|
||||
{
|
||||
const size_t tab = line.find('\t', fieldStart);
|
||||
if (tab == std::string_view::npos) { valid = false; break; }
|
||||
fields[i] = line.substr(fieldStart, tab - fieldStart);
|
||||
fieldStart = tab + 1;
|
||||
}
|
||||
if (!valid) return false;
|
||||
fields[4] = Trim(line.substr(fieldStart));
|
||||
|
||||
Override item;
|
||||
if (!ParseU32(fields[0], item.spellId) ||
|
||||
!ParseU32(fields[1], item.templateSpellId) ||
|
||||
!ParseU32(fields[2], item.creatureEntry) ||
|
||||
!ParseU32(fields[3], item.skillLineAbilityId) || fields[4].empty())
|
||||
return false;
|
||||
item.name.assign(fields[4]);
|
||||
out.push_back(std::move(item));
|
||||
}
|
||||
return !out.empty();
|
||||
}
|
||||
|
||||
bool ValidateWdbc(const std::vector<uint8_t>& bytes, uint32_t fields, uint32_t recordSize,
|
||||
uint32_t& count, uint32_t& stringSize, size_t& recordsEnd)
|
||||
{
|
||||
if (bytes.size() < 20 || std::memcmp(bytes.data(), "WDBC", 4) != 0) return false;
|
||||
count = ReadU32(bytes.data() + 4);
|
||||
if (ReadU32(bytes.data() + 8) != fields || ReadU32(bytes.data() + 12) != recordSize)
|
||||
return false;
|
||||
stringSize = ReadU32(bytes.data() + 16);
|
||||
recordsEnd = 20ull + static_cast<size_t>(count) * recordSize;
|
||||
return recordsEnd <= bytes.size() && recordsEnd + stringSize == bytes.size();
|
||||
}
|
||||
|
||||
const uint8_t* FindSpell(const std::vector<uint8_t>& bytes, uint32_t count, uint32_t id)
|
||||
{
|
||||
for (uint32_t i = 0; i < count; ++i)
|
||||
{
|
||||
const uint8_t* row = bytes.data() + 20 + static_cast<size_t>(i) * kSpellRecordSize;
|
||||
if (ReadU32(row) == id) return row;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
const uint8_t* FindSkillRow(const std::vector<uint8_t>& bytes, uint32_t count, uint32_t spellId)
|
||||
{
|
||||
for (uint32_t i = 0; i < count; ++i)
|
||||
{
|
||||
const uint8_t* row = bytes.data() + 20 + static_cast<size_t>(i) * kSkillRecordSize;
|
||||
if (ReadU32(row + 4) == 778 && ReadU32(row + 8) == spellId) return row;
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool BuildSpellDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
|
||||
std::vector<uint8_t>& out)
|
||||
{
|
||||
uint32_t count = 0, stringSize = 0;
|
||||
size_t recordsEnd = 0;
|
||||
if (!ValidateWdbc(base, kSpellFields, kSpellRecordSize, count, stringSize, recordsEnd))
|
||||
return false;
|
||||
|
||||
std::vector<uint8_t> records;
|
||||
records.reserve(overrides.size() * kSpellRecordSize);
|
||||
std::vector<uint8_t> strings;
|
||||
for (const Override& item : overrides)
|
||||
{
|
||||
const uint8_t* source = FindSpell(base, count, item.templateSpellId);
|
||||
if (!source) return false;
|
||||
const size_t recordStart = records.size();
|
||||
records.insert(records.end(), source, source + kSpellRecordSize);
|
||||
uint8_t* row = records.data() + recordStart;
|
||||
WriteU32(row, item.spellId);
|
||||
WriteU32(row + 110 * 4, item.creatureEntry);
|
||||
|
||||
const uint32_t nameOffset = stringSize + static_cast<uint32_t>(strings.size());
|
||||
strings.insert(strings.end(), item.name.begin(), item.name.end());
|
||||
strings.push_back(0);
|
||||
const std::string description = "Призывает или отпускает спутника: " + item.name + ".";
|
||||
const uint32_t descriptionOffset = stringSize + static_cast<uint32_t>(strings.size());
|
||||
strings.insert(strings.end(), description.begin(), description.end());
|
||||
strings.push_back(0);
|
||||
for (uint32_t field = 136; field <= 151; ++field) WriteU32(row + field * 4, nameOffset);
|
||||
for (uint32_t field = 170; field <= 185; ++field) WriteU32(row + field * 4, descriptionOffset);
|
||||
for (uint32_t field = 187; field <= 202; ++field) WriteU32(row + field * 4, 0);
|
||||
}
|
||||
|
||||
out.reserve(base.size() + records.size() + strings.size());
|
||||
out.insert(out.end(), base.begin(), base.begin() + 20);
|
||||
WriteU32(out.data() + 4, count + static_cast<uint32_t>(overrides.size()));
|
||||
WriteU32(out.data() + 16, stringSize + static_cast<uint32_t>(strings.size()));
|
||||
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
|
||||
out.insert(out.end(), records.begin(), records.end());
|
||||
out.insert(out.end(), base.begin() + recordsEnd, base.end());
|
||||
out.insert(out.end(), strings.begin(), strings.end());
|
||||
return true;
|
||||
}
|
||||
|
||||
bool BuildSkillDbc(const std::vector<uint8_t>& base, const std::vector<Override>& overrides,
|
||||
std::vector<uint8_t>& out)
|
||||
{
|
||||
uint32_t count = 0, stringSize = 0;
|
||||
size_t recordsEnd = 0;
|
||||
if (!ValidateWdbc(base, kSkillFields, kSkillRecordSize, count, stringSize, recordsEnd))
|
||||
return false;
|
||||
|
||||
std::vector<uint8_t> records;
|
||||
records.reserve(overrides.size() * kSkillRecordSize);
|
||||
for (const Override& item : overrides)
|
||||
{
|
||||
const uint8_t* source = FindSkillRow(base, count, item.templateSpellId);
|
||||
if (!source) return false;
|
||||
const size_t recordStart = records.size();
|
||||
records.insert(records.end(), source, source + kSkillRecordSize);
|
||||
uint8_t* row = records.data() + recordStart;
|
||||
WriteU32(row, item.skillLineAbilityId);
|
||||
WriteU32(row + 8, item.spellId);
|
||||
}
|
||||
|
||||
out.reserve(base.size() + records.size());
|
||||
out.insert(out.end(), base.begin(), base.begin() + 20);
|
||||
WriteU32(out.data() + 4, count + static_cast<uint32_t>(overrides.size()));
|
||||
out.insert(out.end(), base.begin() + 20, base.begin() + recordsEnd);
|
||||
out.insert(out.end(), records.begin(), records.end());
|
||||
out.insert(out.end(), base.begin() + recordsEnd, base.end());
|
||||
return true;
|
||||
}
|
||||
|
||||
void Load()
|
||||
{
|
||||
const std::string root = wxl::host::ClientRoot();
|
||||
wxl::host::mpq::MpqStore store;
|
||||
std::vector<uint8_t> overrideBytes, spellBase, skillBase;
|
||||
if (root.empty() || !store.Mount(root) || !store.ReadAll(kOverridesPath, overrideBytes))
|
||||
return;
|
||||
|
||||
std::vector<Override> overrides;
|
||||
if (!ParseOverrides(overrideBytes, overrides) ||
|
||||
!store.ReadAll(kSpellPath, spellBase) || !store.ReadAll(kSkillPath, skillBase) ||
|
||||
!BuildSpellDbc(spellBase, overrides, g_spellDbc) ||
|
||||
!BuildSkillDbc(skillBase, overrides, g_skillDbc))
|
||||
{
|
||||
WLOG_ERROR("moonwell-spells: failed to build custom companion DBCs");
|
||||
return;
|
||||
}
|
||||
g_ready = true;
|
||||
WLOG_INFO("moonwell-spells: appended %zu companion spell(s)", overrides.size());
|
||||
}
|
||||
|
||||
bool Provide(std::string_view name, std::vector<uint8_t>& out)
|
||||
{
|
||||
if (!SamePath(name, kSpellPath) && !SamePath(name, kSkillPath)) return false;
|
||||
std::call_once(g_loadOnce, &Load);
|
||||
if (!g_ready) return false;
|
||||
out = SamePath(name, kSpellPath) ? g_spellDbc : g_skillDbc;
|
||||
return true;
|
||||
}
|
||||
|
||||
struct Registrar
|
||||
{
|
||||
Registrar() { wxl::host::RegisterProvider("moonwell-companion-spells", &Provide); }
|
||||
};
|
||||
|
||||
Registrar g_registrar;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
// FileDataID resolver for selected retail assets shipped in MoonWell patches.
|
||||
|
||||
#include "Host.hpp"
|
||||
#include "core/Logger.hpp"
|
||||
#include "mpq/MpqStore.hpp"
|
||||
|
||||
#include <charconv>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <system_error>
|
||||
#include <unordered_map>
|
||||
#include <vector>
|
||||
|
||||
namespace moonwell::host
|
||||
{
|
||||
namespace
|
||||
{
|
||||
constexpr std::string_view kFileDataMap = "WXLFileData.csv";
|
||||
|
||||
std::once_flag g_loadOnce;
|
||||
std::unordered_map<uint32_t, std::string> g_paths;
|
||||
|
||||
std::string_view Trim(std::string_view value)
|
||||
{
|
||||
while (!value.empty() && (value.front() == ' ' || value.front() == '\t' ||
|
||||
value.front() == '\r' || value.front() == '\n'))
|
||||
value.remove_prefix(1);
|
||||
while (!value.empty() && (value.back() == ' ' || value.back() == '\t' ||
|
||||
value.back() == '\r' || value.back() == '\n'))
|
||||
value.remove_suffix(1);
|
||||
return value;
|
||||
}
|
||||
|
||||
void Load()
|
||||
{
|
||||
const std::string root = wxl::host::ClientRoot();
|
||||
wxl::host::mpq::MpqStore store;
|
||||
std::vector<uint8_t> bytes;
|
||||
if (root.empty() || !store.Mount(root) || !store.ReadAll(kFileDataMap, bytes))
|
||||
{
|
||||
WLOG_WARN("moonwell: FileDataID map '%.*s' was not found",
|
||||
int(kFileDataMap.size()), kFileDataMap.data());
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string_view text(reinterpret_cast<const char*>(bytes.data()), bytes.size());
|
||||
size_t lineStart = 0;
|
||||
while (lineStart < text.size())
|
||||
{
|
||||
size_t lineEnd = text.find('\n', lineStart);
|
||||
if (lineEnd == std::string_view::npos) lineEnd = text.size();
|
||||
std::string_view line = Trim(text.substr(lineStart, lineEnd - lineStart));
|
||||
lineStart = lineEnd + 1;
|
||||
|
||||
if (line.empty() || line.front() == '#') continue;
|
||||
const size_t comma = line.find(',');
|
||||
if (comma == std::string_view::npos) continue;
|
||||
|
||||
const std::string_view idText = Trim(line.substr(0, comma));
|
||||
std::string_view pathText = Trim(line.substr(comma + 1));
|
||||
uint32_t fileDataId = 0;
|
||||
const auto parsed = std::from_chars(idText.data(), idText.data() + idText.size(), fileDataId);
|
||||
if (parsed.ec != std::errc{} || parsed.ptr != idText.data() + idText.size() ||
|
||||
fileDataId == 0 || pathText.empty())
|
||||
continue;
|
||||
|
||||
std::string path(pathText);
|
||||
for (char& c : path) if (c == '/') c = '\\';
|
||||
g_paths[fileDataId] = std::move(path);
|
||||
}
|
||||
|
||||
WLOG_INFO("moonwell: loaded %zu FileDataID path(s) from %.*s",
|
||||
g_paths.size(), int(kFileDataMap.size()), kFileDataMap.data());
|
||||
}
|
||||
|
||||
bool Resolve(uint32_t fileDataId, std::string& outPath)
|
||||
{
|
||||
std::call_once(g_loadOnce, &Load);
|
||||
const auto found = g_paths.find(fileDataId);
|
||||
if (found == g_paths.end()) return false;
|
||||
outPath = found->second;
|
||||
return true;
|
||||
}
|
||||
|
||||
struct Registrar
|
||||
{
|
||||
Registrar() { wxl::host::RegisterResolver("moonwell-filedata", &Resolve); }
|
||||
};
|
||||
|
||||
Registrar g_registrar;
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -0,0 +1,95 @@
|
||||
{
|
||||
"fileDataID": 3730958,
|
||||
"textures": [
|
||||
{
|
||||
"fileDataID": 3732460,
|
||||
"file": "catslime_3732460.blp"
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732461,
|
||||
"file": "catslime_3732461.blp"
|
||||
}
|
||||
],
|
||||
"skins": [
|
||||
{
|
||||
"fileDataID": 3732472,
|
||||
"file": "catslime00.skin"
|
||||
}
|
||||
],
|
||||
"lodSkins": [
|
||||
{
|
||||
"fileDataID": 3732473,
|
||||
"file": "catslime_lod01.skin"
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732474,
|
||||
"file": "catslime_lod02.skin"
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732475,
|
||||
"file": "catslime_lod03.skin"
|
||||
}
|
||||
],
|
||||
"anims": [
|
||||
{
|
||||
"fileDataID": 3732462,
|
||||
"file": "catslime0097-00.anim",
|
||||
"animID": 97,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732463,
|
||||
"file": "catslime0096-00.anim",
|
||||
"animID": 96,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732464,
|
||||
"file": "catslime0098-00.anim",
|
||||
"animID": 98,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732465,
|
||||
"file": "catslime0100-00.anim",
|
||||
"animID": 100,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732466,
|
||||
"file": "catslime0099-00.anim",
|
||||
"animID": 99,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732467,
|
||||
"file": "catslime0101-00.anim",
|
||||
"animID": 101,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732468,
|
||||
"file": "catslime0069-00.anim",
|
||||
"animID": 69,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732469,
|
||||
"file": "catslime0123-00.anim",
|
||||
"animID": 123,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732470,
|
||||
"file": "catslime0061-00.anim",
|
||||
"animID": 61,
|
||||
"subAnimID": 0
|
||||
},
|
||||
{
|
||||
"fileDataID": 3732471,
|
||||
"file": "catslime0075-00.anim",
|
||||
"animID": 75,
|
||||
"subAnimID": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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
|
||||
Generated
+3
@@ -915,6 +915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1059,6 +1060,8 @@ dependencies = [
|
||||
name = "tool"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"walkdir",
|
||||
"wow-mpq",
|
||||
]
|
||||
|
||||
@@ -6,3 +6,5 @@ edition = "2024"
|
||||
[dependencies]
|
||||
walkdir = "2.5.0"
|
||||
wow-mpq = "0.6.4"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
@@ -0,0 +1,813 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
use std::error::Error;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
const MODEL_FIELDS: u32 = 28;
|
||||
const MODEL_RECORD_SIZE: u32 = 112;
|
||||
const DISPLAY_FIELDS: u32 = 16;
|
||||
const DISPLAY_RECORD_SIZE: u32 = 64;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Registry {
|
||||
pub base_dbc_dir: String,
|
||||
#[serde(default = "default_model_id")]
|
||||
pub model_data_id_start: u32,
|
||||
#[serde(default = "default_display_id")]
|
||||
pub display_info_id_start: u32,
|
||||
#[serde(default = "default_creature_entry")]
|
||||
pub creature_entry_id_start: u32,
|
||||
#[serde(default = "default_summon_spell_id")]
|
||||
pub summon_spell_id_start: u32,
|
||||
#[serde(default = "default_skill_line_ability_id")]
|
||||
pub skill_line_ability_id_start: u32,
|
||||
pub defaults: Defaults,
|
||||
#[serde(default)]
|
||||
pub mounts: Vec<EntrySpec>,
|
||||
#[serde(default)]
|
||||
pub pets: Vec<EntrySpec>,
|
||||
}
|
||||
|
||||
fn default_model_id() -> u32 {
|
||||
50_000
|
||||
}
|
||||
fn default_display_id() -> u32 {
|
||||
50_000
|
||||
}
|
||||
fn default_creature_entry() -> u32 {
|
||||
5_000_000
|
||||
}
|
||||
fn default_summon_spell_id() -> u32 {
|
||||
110_000
|
||||
}
|
||||
fn default_skill_line_ability_id() -> u32 {
|
||||
30_000
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct Defaults {
|
||||
pub mount_template_display_id: u32,
|
||||
pub pet_template_display_id: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
pub enum EntrySpec {
|
||||
Folder(String),
|
||||
Detailed(EntryOptions),
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
pub struct EntryOptions {
|
||||
pub folder: String,
|
||||
pub key: Option<String>,
|
||||
pub archive: Option<String>,
|
||||
pub model_file: Option<String>,
|
||||
pub template_display_id: Option<u32>,
|
||||
pub display_scale: Option<f32>,
|
||||
pub model_scale: Option<f32>,
|
||||
pub collision_width: Option<f32>,
|
||||
pub collision_height: Option<f32>,
|
||||
pub mount_height: Option<f32>,
|
||||
pub flags: Option<u32>,
|
||||
pub name: Option<String>,
|
||||
pub name_ru: Option<String>,
|
||||
}
|
||||
|
||||
impl EntrySpec {
|
||||
fn options(&self) -> EntryOptions {
|
||||
match self {
|
||||
EntrySpec::Folder(folder) => EntryOptions {
|
||||
folder: folder.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
EntrySpec::Detailed(options) => EntryOptions {
|
||||
folder: options.folder.clone(),
|
||||
key: options.key.clone(),
|
||||
archive: options.archive.clone(),
|
||||
model_file: options.model_file.clone(),
|
||||
template_display_id: options.template_display_id,
|
||||
display_scale: options.display_scale,
|
||||
model_scale: options.model_scale,
|
||||
collision_width: options.collision_width,
|
||||
collision_height: options.collision_height,
|
||||
mount_height: options.mount_height,
|
||||
flags: options.flags,
|
||||
name: options.name.clone(),
|
||||
name_ru: options.name_ru.clone(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, Serialize)]
|
||||
struct LockFile {
|
||||
#[serde(default)]
|
||||
models: BTreeMap<String, LockedIds>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, Serialize)]
|
||||
struct LockedIds {
|
||||
model_data_id: u32,
|
||||
display_info_id: u32,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
creature_entry: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
summon_spell_id: Option<u32>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
skill_line_ability_id: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct PreparedEntry {
|
||||
key: String,
|
||||
folder: PathBuf,
|
||||
archive: String,
|
||||
model_file: String,
|
||||
file_data: Vec<(u32, String)>,
|
||||
assets: Vec<(PathBuf, String)>,
|
||||
template_display_id: u32,
|
||||
ids: LockedIds,
|
||||
options: EntryOptions,
|
||||
summon: bool,
|
||||
name: String,
|
||||
name_ru: String,
|
||||
}
|
||||
|
||||
pub struct GeneratedCreatures {
|
||||
pub assets: Vec<(PathBuf, String)>,
|
||||
pub file_data_csv: Vec<u8>,
|
||||
pub creature_model_data: Vec<u8>,
|
||||
pub creature_display_info: Vec<u8>,
|
||||
pub spell_overrides: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Wdbc {
|
||||
field_count: u32,
|
||||
record_size: u32,
|
||||
records: Vec<Vec<u8>>,
|
||||
strings: Vec<u8>,
|
||||
}
|
||||
|
||||
impl Wdbc {
|
||||
fn read(path: &Path, fields: u32, record_size: u32) -> Result<Self, Box<dyn Error>> {
|
||||
let bytes = fs::read(path)?;
|
||||
if bytes.len() < 20 || &bytes[0..4] != b"WDBC" {
|
||||
return Err(format!("{} is not a WDBC file", path.display()).into());
|
||||
}
|
||||
let count = read_u32(&bytes, 4)? as usize;
|
||||
let actual_fields = read_u32(&bytes, 8)?;
|
||||
let actual_size = read_u32(&bytes, 12)?;
|
||||
let string_size = read_u32(&bytes, 16)? as usize;
|
||||
if actual_fields != fields || actual_size != record_size {
|
||||
return Err(format!(
|
||||
"{} has unexpected layout: fields={}, record_size={}",
|
||||
path.display(),
|
||||
actual_fields,
|
||||
actual_size
|
||||
)
|
||||
.into());
|
||||
}
|
||||
let records_end = 20usize
|
||||
.checked_add(
|
||||
count
|
||||
.checked_mul(record_size as usize)
|
||||
.ok_or("DBC size overflow")?,
|
||||
)
|
||||
.ok_or("DBC size overflow")?;
|
||||
if records_end
|
||||
.checked_add(string_size)
|
||||
.ok_or("DBC size overflow")?
|
||||
!= bytes.len()
|
||||
{
|
||||
return Err(format!("{} has an invalid header size", path.display()).into());
|
||||
}
|
||||
let mut records = Vec::with_capacity(count + 16);
|
||||
for record in bytes[20..records_end].chunks_exact(record_size as usize) {
|
||||
records.push(record.to_vec());
|
||||
}
|
||||
Ok(Self {
|
||||
field_count: fields,
|
||||
record_size,
|
||||
records,
|
||||
strings: bytes[records_end..].to_vec(),
|
||||
})
|
||||
}
|
||||
|
||||
fn record(&self, id: u32) -> Option<&Vec<u8>> {
|
||||
self.records
|
||||
.iter()
|
||||
.find(|row| read_u32(row, 0).ok() == Some(id))
|
||||
}
|
||||
|
||||
fn add_string(&mut self, value: &str) -> u32 {
|
||||
if value.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
let offset = self.strings.len() as u32;
|
||||
self.strings.extend_from_slice(value.as_bytes());
|
||||
self.strings.push(0);
|
||||
offset
|
||||
}
|
||||
|
||||
fn build(mut self) -> Vec<u8> {
|
||||
self.records
|
||||
.sort_by_key(|row| read_u32(row, 0).unwrap_or(0));
|
||||
let mut bytes = Vec::with_capacity(
|
||||
20 + self.records.len() * self.record_size as usize + self.strings.len(),
|
||||
);
|
||||
bytes.extend_from_slice(b"WDBC");
|
||||
bytes.extend_from_slice(&(self.records.len() as u32).to_le_bytes());
|
||||
bytes.extend_from_slice(&self.field_count.to_le_bytes());
|
||||
bytes.extend_from_slice(&self.record_size.to_le_bytes());
|
||||
bytes.extend_from_slice(&(self.strings.len() as u32).to_le_bytes());
|
||||
for row in self.records {
|
||||
bytes.extend_from_slice(&row);
|
||||
}
|
||||
bytes.extend_from_slice(&self.strings);
|
||||
bytes
|
||||
}
|
||||
}
|
||||
|
||||
fn read_u32(bytes: &[u8], offset: usize) -> Result<u32, Box<dyn Error>> {
|
||||
let value = bytes
|
||||
.get(offset..offset + 4)
|
||||
.ok_or("record field is out of bounds")?;
|
||||
Ok(u32::from_le_bytes(value.try_into()?))
|
||||
}
|
||||
|
||||
fn write_u32(bytes: &mut [u8], field: usize, value: u32) -> Result<(), Box<dyn Error>> {
|
||||
let offset = field.checked_mul(4).ok_or("record field overflow")?;
|
||||
bytes
|
||||
.get_mut(offset..offset + 4)
|
||||
.ok_or("record field is out of bounds")?
|
||||
.copy_from_slice(&value.to_le_bytes());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn write_f32(bytes: &mut [u8], field: usize, value: f32) -> Result<(), Box<dyn Error>> {
|
||||
write_u32(bytes, field, value.to_bits())
|
||||
}
|
||||
|
||||
fn normalized_archive(value: &str) -> String {
|
||||
value.trim_matches(['/', '\\']).replace('\\', "/")
|
||||
}
|
||||
|
||||
fn resolve_path(project_root: &Path, value: &str) -> PathBuf {
|
||||
let path = PathBuf::from(value);
|
||||
if path.is_absolute() {
|
||||
path
|
||||
} else {
|
||||
project_root.join(path)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_key(folder: &Path) -> Result<String, Box<dyn Error>> {
|
||||
Ok(folder
|
||||
.file_name()
|
||||
.ok_or("creature folder has no name")?
|
||||
.to_string_lossy()
|
||||
.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn default_archive(folder: &Path) -> Result<String, Box<dyn Error>> {
|
||||
Ok(format!(
|
||||
"Creature/{}",
|
||||
folder
|
||||
.file_name()
|
||||
.ok_or("creature folder has no name")?
|
||||
.to_string_lossy()
|
||||
))
|
||||
}
|
||||
|
||||
fn find_manifest(folder: &Path) -> Result<PathBuf, Box<dyn Error>> {
|
||||
let manifests: Vec<PathBuf> = fs::read_dir(folder)?
|
||||
.filter_map(Result::ok)
|
||||
.map(|entry| entry.path())
|
||||
.filter(|path| {
|
||||
path.file_name()
|
||||
.is_some_and(|name| name.to_string_lossy().ends_with(".manifest.json"))
|
||||
})
|
||||
.collect();
|
||||
match manifests.as_slice() {
|
||||
[manifest] => Ok(manifest.clone()),
|
||||
[] => Err(format!("no *.manifest.json in {}", folder.display()).into()),
|
||||
_ => Err(format!("more than one *.manifest.json in {}", folder.display()).into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn find_model(folder: &Path, explicit: Option<&str>) -> Result<String, Box<dyn Error>> {
|
||||
if let Some(model) = explicit {
|
||||
return Ok(model.to_string());
|
||||
}
|
||||
let models: Vec<String> = fs::read_dir(folder)?
|
||||
.filter_map(Result::ok)
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("m2"))
|
||||
})
|
||||
.map(|entry| entry.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
match models.as_slice() {
|
||||
[model] => Ok(model.clone()),
|
||||
[] => Err(format!("no .m2 file in {}", folder.display()).into()),
|
||||
_ => Err(format!(
|
||||
"more than one .m2 file in {}; set model_file",
|
||||
folder.display()
|
||||
)
|
||||
.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_manifest_files(value: &Value, out: &mut Vec<(u32, String)>) {
|
||||
match value {
|
||||
Value::Object(object) => {
|
||||
if let (Some(id), Some(file)) = (
|
||||
object.get("fileDataID").and_then(Value::as_u64),
|
||||
object.get("file").and_then(Value::as_str),
|
||||
) && id <= u32::MAX as u64
|
||||
{
|
||||
out.push((id as u32, file.to_string()));
|
||||
}
|
||||
for child in object.values() {
|
||||
collect_manifest_files(child, out);
|
||||
}
|
||||
}
|
||||
Value::Array(array) => {
|
||||
for child in array {
|
||||
collect_manifest_files(child, out);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn next_free(start: u32, used: &HashSet<u32>) -> u32 {
|
||||
let mut value = start;
|
||||
while used.contains(&value) {
|
||||
value = value.checked_add(1).expect("custom ID range exhausted");
|
||||
}
|
||||
value
|
||||
}
|
||||
|
||||
fn allocate_ids(lock: &mut LockFile, key: &str, registry: &Registry, summon: bool) -> LockedIds {
|
||||
let used_models: HashSet<u32> = lock.models.values().map(|ids| ids.model_data_id).collect();
|
||||
let used_displays: HashSet<u32> = lock
|
||||
.models
|
||||
.values()
|
||||
.map(|ids| ids.display_info_id)
|
||||
.collect();
|
||||
let used_creatures: HashSet<u32> = lock
|
||||
.models
|
||||
.values()
|
||||
.filter_map(|ids| ids.creature_entry)
|
||||
.collect();
|
||||
let used_spells: HashSet<u32> = lock
|
||||
.models
|
||||
.values()
|
||||
.filter_map(|ids| ids.summon_spell_id)
|
||||
.collect();
|
||||
let used_skill_rows: HashSet<u32> = lock
|
||||
.models
|
||||
.values()
|
||||
.filter_map(|ids| ids.skill_line_ability_id)
|
||||
.collect();
|
||||
let mut ids = lock.models.get(key).cloned().unwrap_or(LockedIds {
|
||||
model_data_id: next_free(registry.model_data_id_start, &used_models),
|
||||
display_info_id: next_free(registry.display_info_id_start, &used_displays),
|
||||
creature_entry: None,
|
||||
summon_spell_id: None,
|
||||
skill_line_ability_id: None,
|
||||
});
|
||||
if summon {
|
||||
if ids.creature_entry.is_none() {
|
||||
ids.creature_entry = Some(next_free(registry.creature_entry_id_start, &used_creatures));
|
||||
}
|
||||
if ids.summon_spell_id.is_none() {
|
||||
ids.summon_spell_id = Some(next_free(registry.summon_spell_id_start, &used_spells));
|
||||
}
|
||||
if ids.skill_line_ability_id.is_none() {
|
||||
ids.skill_line_ability_id = Some(next_free(
|
||||
registry.skill_line_ability_id_start,
|
||||
&used_skill_rows,
|
||||
));
|
||||
}
|
||||
}
|
||||
lock.models.insert(key.to_string(), ids.clone());
|
||||
ids
|
||||
}
|
||||
|
||||
fn validate_text(value: &str, field: &str, key: &str) -> Result<(), Box<dyn Error>> {
|
||||
if value.contains(['\t', '\r', '\n']) {
|
||||
return Err(format!("{} for '{}' contains a tab or newline", field, key).into());
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn prepare_entry(
|
||||
project_root: &Path,
|
||||
spec: &EntrySpec,
|
||||
template_default: u32,
|
||||
summon: bool,
|
||||
lock: &mut LockFile,
|
||||
registry: &Registry,
|
||||
) -> Result<PreparedEntry, Box<dyn Error>> {
|
||||
let options = spec.options();
|
||||
let folder = resolve_path(project_root, &options.folder).canonicalize()?;
|
||||
if !folder.is_dir() {
|
||||
return Err(format!("creature folder not found: {}", folder.display()).into());
|
||||
}
|
||||
let key = options.key.clone().unwrap_or(default_key(&folder)?);
|
||||
let name = options.name.clone().unwrap_or_else(|| key.clone());
|
||||
let name_ru = options.name_ru.clone().unwrap_or_else(|| name.clone());
|
||||
validate_text(&name, "name", &key)?;
|
||||
validate_text(&name_ru, "name_ru", &key)?;
|
||||
let archive = normalized_archive(
|
||||
options
|
||||
.archive
|
||||
.as_deref()
|
||||
.unwrap_or(&default_archive(&folder)?),
|
||||
);
|
||||
let model_file = find_model(&folder, options.model_file.as_deref())?;
|
||||
let manifest_path = find_manifest(&folder)?;
|
||||
let manifest: Value = serde_json::from_slice(&fs::read(&manifest_path)?)?;
|
||||
let main_id = manifest
|
||||
.get("fileDataID")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| format!("{} has no root fileDataID", manifest_path.display()))?;
|
||||
if main_id > u32::MAX as u64 {
|
||||
return Err("model FileDataID is too large".into());
|
||||
}
|
||||
|
||||
let mut dependencies = Vec::new();
|
||||
collect_manifest_files(&manifest, &mut dependencies);
|
||||
let mut file_data = vec![(main_id as u32, format!("{archive}/{model_file}"))];
|
||||
let mut assets = vec![(folder.join(&model_file), format!("{archive}/{model_file}"))];
|
||||
for (file_data_id, file) in dependencies {
|
||||
let source = folder.join(&file);
|
||||
if !source.is_file() {
|
||||
return Err(format!("manifest dependency not found: {}", source.display()).into());
|
||||
}
|
||||
file_data.push((file_data_id, format!("{archive}/{file}")));
|
||||
assets.push((source, format!("{archive}/{file}")));
|
||||
}
|
||||
let ids = allocate_ids(lock, &key, registry, summon);
|
||||
Ok(PreparedEntry {
|
||||
key,
|
||||
folder,
|
||||
archive,
|
||||
model_file,
|
||||
file_data,
|
||||
assets,
|
||||
template_display_id: options.template_display_id.unwrap_or(template_default),
|
||||
ids,
|
||||
options,
|
||||
summon,
|
||||
name,
|
||||
name_ru,
|
||||
})
|
||||
}
|
||||
|
||||
fn generate_dbc(
|
||||
base_dir: &Path,
|
||||
entries: &[PreparedEntry],
|
||||
) -> Result<(Vec<u8>, Vec<u8>), Box<dyn Error>> {
|
||||
let mut models = Wdbc::read(
|
||||
&base_dir.join("CreatureModelData.dbc"),
|
||||
MODEL_FIELDS,
|
||||
MODEL_RECORD_SIZE,
|
||||
)?;
|
||||
let mut displays = Wdbc::read(
|
||||
&base_dir.join("CreatureDisplayInfo.dbc"),
|
||||
DISPLAY_FIELDS,
|
||||
DISPLAY_RECORD_SIZE,
|
||||
)?;
|
||||
let base_models = models.clone();
|
||||
let base_displays = displays.clone();
|
||||
|
||||
for entry in entries {
|
||||
if base_models.record(entry.ids.model_data_id).is_some()
|
||||
|| base_displays.record(entry.ids.display_info_id).is_some()
|
||||
{
|
||||
return Err(format!("custom IDs for '{}' collide with base DBC", entry.key).into());
|
||||
}
|
||||
let template_display = base_displays
|
||||
.record(entry.template_display_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"template display {} not found for '{}'",
|
||||
entry.template_display_id, entry.key
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let template_model_id = read_u32(&template_display, 4)?;
|
||||
let mut model = base_models
|
||||
.record(template_model_id)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"model {} for template display {} not found",
|
||||
template_model_id, entry.template_display_id
|
||||
)
|
||||
})?
|
||||
.clone();
|
||||
let mut display = template_display;
|
||||
|
||||
let model_stem = Path::new(&entry.model_file)
|
||||
.file_stem()
|
||||
.ok_or("model file has no stem")?
|
||||
.to_string_lossy();
|
||||
let dbc_model_path = format!("{}\\{}.mdx", entry.archive.replace('/', "\\"), model_stem);
|
||||
let string_offset = models.add_string(&dbc_model_path);
|
||||
write_u32(&mut model, 0, entry.ids.model_data_id)?;
|
||||
write_u32(&mut model, 2, string_offset)?;
|
||||
if let Some(value) = entry.options.flags {
|
||||
write_u32(&mut model, 1, value)?;
|
||||
}
|
||||
if let Some(value) = entry.options.model_scale {
|
||||
write_f32(&mut model, 4, value)?;
|
||||
}
|
||||
if let Some(value) = entry.options.collision_width {
|
||||
write_f32(&mut model, 14, value)?;
|
||||
}
|
||||
if let Some(value) = entry.options.collision_height {
|
||||
write_f32(&mut model, 15, value)?;
|
||||
}
|
||||
if let Some(value) = entry.options.mount_height {
|
||||
write_f32(&mut model, 16, value)?;
|
||||
}
|
||||
|
||||
write_u32(&mut display, 0, entry.ids.display_info_id)?;
|
||||
write_u32(&mut display, 1, entry.ids.model_data_id)?;
|
||||
if let Some(value) = entry.options.display_scale {
|
||||
write_f32(&mut display, 4, value)?;
|
||||
}
|
||||
// Modern models carry their textures through TXID/FileDataID. Template skin names must not override them.
|
||||
for field in 6..=9 {
|
||||
write_u32(&mut display, field, 0)?;
|
||||
}
|
||||
|
||||
models.records.push(model);
|
||||
displays.records.push(display);
|
||||
}
|
||||
Ok((models.build(), displays.build()))
|
||||
}
|
||||
|
||||
fn sql_string(value: &str) -> String {
|
||||
format!("'{}'", value.replace('\\', "\\\\").replace('\'', "''"))
|
||||
}
|
||||
|
||||
fn generate_server_sql(entries: &[PreparedEntry]) -> Result<String, Box<dyn Error>> {
|
||||
let mut sql = String::from(
|
||||
"-- Generated by MoonWell custom creature builder.\n\
|
||||
-- Apply to acore_world, then restart ac-worldserver.\n\
|
||||
START TRANSACTION;\n\n",
|
||||
);
|
||||
for entry in entries.iter().filter(|entry| entry.summon) {
|
||||
let creature = entry
|
||||
.ids
|
||||
.creature_entry
|
||||
.ok_or("pet has no creature entry")?;
|
||||
let spell = entry
|
||||
.ids
|
||||
.summon_spell_id
|
||||
.ok_or("pet has no summon spell ID")?;
|
||||
let skill_row = entry
|
||||
.ids
|
||||
.skill_line_ability_id
|
||||
.ok_or("pet has no skill-line row ID")?;
|
||||
let name_en = sql_string(&entry.name);
|
||||
let name_ru = sql_string(&entry.name_ru);
|
||||
let description_en = sql_string(&format!(
|
||||
"Summons or dismisses your companion: {}.",
|
||||
entry.name
|
||||
));
|
||||
let description_ru = sql_string(&format!(
|
||||
"Призывает или отпускает спутника: {}.",
|
||||
entry.name_ru
|
||||
));
|
||||
|
||||
sql.push_str(&format!(
|
||||
"-- {}\n\
|
||||
INSERT INTO creature_template\n\
|
||||
(entry,name,minlevel,maxlevel,faction,speed_walk,speed_run,scale,unit_class,unit_flags,unit_flags2,type,MovementType,RegenHealth,VerifiedBuild)\n\
|
||||
VALUES ({creature},{name_en},1,1,188,1,1.14286,1,1,0,2048,12,0,1,12340)\n\
|
||||
ON DUPLICATE KEY UPDATE name=VALUES(name),faction=VALUES(faction),type=VALUES(type),unit_flags2=VALUES(unit_flags2);\n\
|
||||
INSERT INTO creature_model_info\n\
|
||||
(DisplayID,BoundingRadius,CombatReach,Gender,DisplayID_Other_Gender,VerifiedBuild)\n\
|
||||
VALUES ({},0.25,0.5,2,0,12340)\n\
|
||||
ON DUPLICATE KEY UPDATE BoundingRadius=VALUES(BoundingRadius),CombatReach=VALUES(CombatReach),Gender=VALUES(Gender),DisplayID_Other_Gender=VALUES(DisplayID_Other_Gender),VerifiedBuild=VALUES(VerifiedBuild);\n\
|
||||
INSERT INTO creature_template_model\n\
|
||||
(CreatureID,Idx,CreatureDisplayID,DisplayScale,Probability,VerifiedBuild)\n\
|
||||
VALUES ({creature},0,{},1,1,12340)\n\
|
||||
ON DUPLICATE KEY UPDATE CreatureDisplayID=VALUES(CreatureDisplayID),DisplayScale=VALUES(DisplayScale),Probability=VALUES(Probability);\n\n",
|
||||
entry.key, entry.ids.display_info_id, entry.ids.display_info_id
|
||||
));
|
||||
sql.push_str(&format!(
|
||||
"INSERT INTO spell_dbc\n\
|
||||
(ID,Attributes,AttributesEx3,CastingTimeIndex,InterruptFlags,ProcChance,DurationIndex,RangeIndex,EquippedItemClass,Effect_1,EffectDieSides_1,EffectBasePoints_1,ImplicitTargetA_1,EffectMultipleValue_1,EffectMiscValue_1,EffectMiscValueB_1,SpellVisualID_1,SpellIconID,Name_Lang_enUS,Name_Lang_ruRU,Description_Lang_enUS,Description_Lang_ruRU,StartRecoveryCategory,StartRecoveryTime,EffectChainAmplitude_1,EffectChainAmplitude_2,EffectChainAmplitude_3,SchoolMask)\n\
|
||||
VALUES ({spell},262416,536870912,1,31,101,21,1,-1,28,1,1,32,1000,{creature},41,353,2686,{name_en},{name_ru},{description_en},{description_ru},133,1500,1,1,1,1)\n\
|
||||
ON DUPLICATE KEY UPDATE EffectMiscValue_1=VALUES(EffectMiscValue_1),Name_Lang_enUS=VALUES(Name_Lang_enUS),Name_Lang_ruRU=VALUES(Name_Lang_ruRU),Description_Lang_enUS=VALUES(Description_Lang_enUS),Description_Lang_ruRU=VALUES(Description_Lang_ruRU);\n\
|
||||
INSERT INTO skilllineability_dbc\n\
|
||||
(ID,SkillLine,Spell,RaceMask,ClassMask,ExcludeRace,ExcludeClass,MinSkillLineRank,SupercededBySpell,AcquireMethod,TrivialSkillLineRankHigh,TrivialSkillLineRankLow,CharacterPoints_1,CharacterPoints_2)\n\
|
||||
VALUES ({skill_row},778,{spell},0,0,0,0,1,0,0,0,0,0,0)\n\
|
||||
ON DUPLICATE KEY UPDATE SkillLine=VALUES(SkillLine),Spell=VALUES(Spell),MinSkillLineRank=VALUES(MinSkillLineRank);\n\n"
|
||||
));
|
||||
}
|
||||
sql.push_str("COMMIT;\n");
|
||||
Ok(sql)
|
||||
}
|
||||
|
||||
pub fn prepare(project_root: &Path) -> Result<Option<GeneratedCreatures>, Box<dyn Error>> {
|
||||
let registry_path = project_root.join("custom-creatures.json");
|
||||
if !registry_path.is_file() {
|
||||
return Ok(None);
|
||||
}
|
||||
let registry: Registry = serde_json::from_slice(&fs::read(®istry_path)?)?;
|
||||
let lock_path = project_root.join("custom-creatures.lock.json");
|
||||
let mut lock: LockFile = if lock_path.is_file() {
|
||||
serde_json::from_slice(&fs::read(&lock_path)?)?
|
||||
} else {
|
||||
LockFile::default()
|
||||
};
|
||||
let original_lock = serde_json::to_vec(&lock)?;
|
||||
|
||||
let mut entries = Vec::new();
|
||||
let mut keys = HashSet::new();
|
||||
for spec in ®istry.mounts {
|
||||
let entry = prepare_entry(
|
||||
project_root,
|
||||
spec,
|
||||
registry.defaults.mount_template_display_id,
|
||||
false,
|
||||
&mut lock,
|
||||
®istry,
|
||||
)?;
|
||||
if !keys.insert(entry.key.clone()) {
|
||||
return Err(format!("duplicate creature key '{}'", entry.key).into());
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
for spec in ®istry.pets {
|
||||
let entry = prepare_entry(
|
||||
project_root,
|
||||
spec,
|
||||
registry.defaults.pet_template_display_id,
|
||||
true,
|
||||
&mut lock,
|
||||
®istry,
|
||||
)?;
|
||||
if !keys.insert(entry.key.clone()) {
|
||||
return Err(format!("duplicate creature key '{}'", entry.key).into());
|
||||
}
|
||||
entries.push(entry);
|
||||
}
|
||||
|
||||
let updated_lock = serde_json::to_vec(&lock)?;
|
||||
if updated_lock != original_lock || !lock_path.exists() {
|
||||
let mut pretty = serde_json::to_vec_pretty(&lock)?;
|
||||
pretty.push(b'\n');
|
||||
fs::write(&lock_path, pretty)?;
|
||||
println!("Updated creature ID lock: {}", lock_path.display());
|
||||
}
|
||||
|
||||
let mut all_ids = BTreeMap::new();
|
||||
let mut active_model_ids = HashSet::new();
|
||||
let mut active_display_ids = HashSet::new();
|
||||
let mut active_creature_entries = HashSet::new();
|
||||
let mut active_spell_ids = HashSet::new();
|
||||
let mut active_skill_rows = HashSet::new();
|
||||
let mut assets = Vec::new();
|
||||
for entry in &entries {
|
||||
if !active_model_ids.insert(entry.ids.model_data_id) {
|
||||
return Err(format!(
|
||||
"duplicate modelDataID {} in creature lock",
|
||||
entry.ids.model_data_id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
if !active_display_ids.insert(entry.ids.display_info_id) {
|
||||
return Err(format!(
|
||||
"duplicate displayInfoID {} in creature lock",
|
||||
entry.ids.display_info_id
|
||||
)
|
||||
.into());
|
||||
}
|
||||
for (value, used, label) in [
|
||||
(
|
||||
entry.ids.creature_entry,
|
||||
&mut active_creature_entries,
|
||||
"creature entry",
|
||||
),
|
||||
(
|
||||
entry.ids.summon_spell_id,
|
||||
&mut active_spell_ids,
|
||||
"summon spell ID",
|
||||
),
|
||||
(
|
||||
entry.ids.skill_line_ability_id,
|
||||
&mut active_skill_rows,
|
||||
"skill-line row ID",
|
||||
),
|
||||
] {
|
||||
if let Some(value) = value
|
||||
&& !used.insert(value)
|
||||
{
|
||||
return Err(format!("duplicate {label} {value} in creature lock").into());
|
||||
}
|
||||
}
|
||||
println!(
|
||||
"Creature {}: modelDataID={}, displayInfoID={}, source={}",
|
||||
entry.key,
|
||||
entry.ids.model_data_id,
|
||||
entry.ids.display_info_id,
|
||||
entry.folder.display()
|
||||
);
|
||||
for (id, path) in &entry.file_data {
|
||||
if let Some(existing) = all_ids.get(id) {
|
||||
if existing != path {
|
||||
return Err(format!(
|
||||
"FileDataID {} maps to both '{}' and '{}'",
|
||||
id, existing, path
|
||||
)
|
||||
.into());
|
||||
}
|
||||
} else {
|
||||
all_ids.insert(*id, path.clone());
|
||||
}
|
||||
}
|
||||
assets.extend(entry.assets.clone());
|
||||
}
|
||||
let mut csv = String::from("# FileDataID,archive path\n");
|
||||
for (id, path) in all_ids {
|
||||
csv.push_str(&format!("{id},{}\n", path.replace('/', "\\")));
|
||||
}
|
||||
let mut spell_overrides =
|
||||
String::from("# spellId\ttemplateSpellId\tcreatureEntry\tskillLineAbilityId\tname\n");
|
||||
for entry in entries.iter().filter(|entry| entry.summon) {
|
||||
spell_overrides.push_str(&format!(
|
||||
"{}\t70613\t{}\t{}\t{}\n",
|
||||
entry
|
||||
.ids
|
||||
.summon_spell_id
|
||||
.ok_or("pet has no summon spell ID")?,
|
||||
entry
|
||||
.ids
|
||||
.creature_entry
|
||||
.ok_or("pet has no creature entry")?,
|
||||
entry
|
||||
.ids
|
||||
.skill_line_ability_id
|
||||
.ok_or("pet has no skill-line row ID")?,
|
||||
entry.name_ru
|
||||
));
|
||||
}
|
||||
|
||||
let base_dir = resolve_path(project_root, ®istry.base_dbc_dir);
|
||||
let (model_dbc, display_dbc) = generate_dbc(&base_dir, &entries)?;
|
||||
// Keep server-only artifacts outside the client package directory. Both run.ps1 and deploy.ps1
|
||||
// mirror the complete package directory into the client and launcher manifest.
|
||||
let server_dir = project_root.join("build").join("server-dbc");
|
||||
fs::create_dir_all(&server_dir)?;
|
||||
fs::write(server_dir.join("CreatureModelData.dbc"), &model_dbc)?;
|
||||
fs::write(server_dir.join("CreatureDisplayInfo.dbc"), &display_dbc)?;
|
||||
fs::write(
|
||||
server_dir.join("custom-creatures.sql"),
|
||||
generate_server_sql(&entries)?,
|
||||
)?;
|
||||
let mut id_map = String::from(
|
||||
"key,type,modelDataID,displayInfoID,creatureEntry,summonSpellID,skillLineAbilityID\n",
|
||||
);
|
||||
for (kind, specs) in [("mount", ®istry.mounts), ("pet", ®istry.pets)] {
|
||||
for spec in specs {
|
||||
let options = spec.options();
|
||||
let folder = resolve_path(project_root, &options.folder).canonicalize()?;
|
||||
let key = options.key.unwrap_or(default_key(&folder)?);
|
||||
let ids = lock.models.get(&key).ok_or("lock entry disappeared")?;
|
||||
id_map.push_str(&format!(
|
||||
"{key},{kind},{},{},{},{},{}\n",
|
||||
ids.model_data_id,
|
||||
ids.display_info_id,
|
||||
ids.creature_entry
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
ids.summon_spell_id
|
||||
.map_or_else(String::new, |value| value.to_string()),
|
||||
ids.skill_line_ability_id
|
||||
.map_or_else(String::new, |value| value.to_string())
|
||||
));
|
||||
}
|
||||
}
|
||||
fs::write(server_dir.join("custom-creatures.csv"), id_map)?;
|
||||
|
||||
Ok(Some(GeneratedCreatures {
|
||||
assets,
|
||||
file_data_csv: csv.into_bytes(),
|
||||
creature_model_data: model_dbc,
|
||||
creature_display_info: display_dbc,
|
||||
spell_overrides: spell_overrides.into_bytes(),
|
||||
}))
|
||||
}
|
||||
+78
-3
@@ -7,6 +7,9 @@ use walkdir::WalkDir;
|
||||
use wow_mpq::compression::flags;
|
||||
use wow_mpq::{ArchiveBuilder, FormatVersion, ListfileOption};
|
||||
|
||||
mod creatures;
|
||||
use creatures::GeneratedCreatures;
|
||||
|
||||
/// Checks if a directory name looks like a WoW locale folder (e.g. ruRU, enUS, deDE).
|
||||
fn is_locale_dir(name: &str) -> bool {
|
||||
if name.len() != 4 {
|
||||
@@ -19,7 +22,11 @@ fn is_locale_dir(name: &str) -> bool {
|
||||
&& b[3].is_ascii_uppercase()
|
||||
}
|
||||
|
||||
fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>> {
|
||||
fn build_mpq(
|
||||
input_dir: &Path,
|
||||
output_path: &Path,
|
||||
creatures: Option<&GeneratedCreatures>,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let mut output = output_path.to_path_buf();
|
||||
output.set_extension("MPQ");
|
||||
|
||||
@@ -36,12 +43,21 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>>
|
||||
.default_compression(flags::ZLIB)
|
||||
.listfile_option(ListfileOption::Generate);
|
||||
|
||||
let loose_sources: std::collections::HashSet<PathBuf> = creatures
|
||||
.into_iter()
|
||||
.flat_map(|generated| generated.assets.iter())
|
||||
.filter_map(|(source, _)| source.canonicalize().ok())
|
||||
.collect();
|
||||
|
||||
for entry in WalkDir::new(&base)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().is_file())
|
||||
{
|
||||
let full_path = entry.path().canonicalize()?;
|
||||
if loose_sources.contains(&full_path) {
|
||||
continue;
|
||||
}
|
||||
let rel_path = match full_path.strip_prefix(&base) {
|
||||
Ok(p) => p,
|
||||
Err(_) => {
|
||||
@@ -50,15 +66,59 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>>
|
||||
}
|
||||
};
|
||||
let archive_path = rel_path.to_string_lossy().replace('\\', "/");
|
||||
if archive_path.ends_with(".manifest.json") || archive_path == "WXLFileData.csv" {
|
||||
continue;
|
||||
}
|
||||
println!(" Adding: {}", archive_path);
|
||||
builder = builder.add_file(&full_path, &archive_path);
|
||||
}
|
||||
|
||||
if let Some(generated) = creatures {
|
||||
println!(" Adding generated: WXLFileData.csv");
|
||||
builder = builder.add_file_data(generated.file_data_csv.clone(), "WXLFileData.csv");
|
||||
println!(" Adding generated: WXLSpellOverrides.tsv");
|
||||
builder = builder.add_file_data(generated.spell_overrides.clone(), "WXLSpellOverrides.tsv");
|
||||
println!(" Adding generated: DBFilesClient/CreatureModelData.dbc");
|
||||
builder = builder.add_file_data(
|
||||
generated.creature_model_data.clone(),
|
||||
"DBFilesClient/CreatureModelData.dbc",
|
||||
);
|
||||
println!(" Adding generated: DBFilesClient/CreatureDisplayInfo.dbc");
|
||||
builder = builder.add_file_data(
|
||||
generated.creature_display_info.clone(),
|
||||
"DBFilesClient/CreatureDisplayInfo.dbc",
|
||||
);
|
||||
}
|
||||
|
||||
builder.build(&output)?;
|
||||
println!("Created: {}", output.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn stage_loose_assets(
|
||||
output_dir: &Path,
|
||||
creatures: &GeneratedCreatures,
|
||||
) -> Result<(), Box<dyn Error>> {
|
||||
let loose_root = output_dir.join("Data").join("Patch-WXL.MPQ");
|
||||
for (source, archive_path) in &creatures.assets {
|
||||
let destination = loose_root.join(archive_path.replace('/', "\\"));
|
||||
if let Some(parent) = destination.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::copy(source, &destination)?;
|
||||
println!("Staging loose modern asset: {}", archive_path);
|
||||
if source
|
||||
.extension()
|
||||
.is_some_and(|extension| extension.eq_ignore_ascii_case("m2"))
|
||||
{
|
||||
let mdx_alias = destination.with_extension("mdx");
|
||||
fs::copy(source, &mdx_alias)?;
|
||||
println!("Staging WoW 3.3.5 model alias: {}", mdx_alias.display());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn main() -> Result<(), Box<dyn Error>> {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
|
||||
@@ -72,6 +132,16 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
|
||||
let src_dir = PathBuf::from(&args[1]);
|
||||
let output_dir = PathBuf::from(&args[2]);
|
||||
let project_root = src_dir
|
||||
.canonicalize()?
|
||||
.parent()
|
||||
.ok_or("src_dir has no parent")?
|
||||
.to_path_buf();
|
||||
let generated_creatures = creatures::prepare(&project_root)?;
|
||||
|
||||
if let Some(generated) = generated_creatures.as_ref() {
|
||||
stage_loose_assets(&output_dir, generated)?;
|
||||
}
|
||||
|
||||
let data_dir = src_dir.join("Data");
|
||||
if !data_dir.exists() {
|
||||
@@ -97,12 +167,17 @@ fn main() -> Result<(), Box<dyn Error>> {
|
||||
}
|
||||
let sub_name = sub.file_name().to_string_lossy().to_string();
|
||||
let output_mpq = output_dir.join("Data").join(&name).join(&sub_name);
|
||||
build_mpq(&sub_path, &output_mpq)?;
|
||||
build_mpq(&sub_path, &output_mpq, None)?;
|
||||
}
|
||||
} else {
|
||||
// Regular patch dir, e.g. patch-4/ or patch-Z/
|
||||
let output_mpq = output_dir.join("Data").join(&name);
|
||||
build_mpq(&path, &output_mpq)?;
|
||||
let creature_files = if name.eq_ignore_ascii_case("patch-Z") {
|
||||
generated_creatures.as_ref()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
build_mpq(&path, &output_mpq, creature_files)?;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
Submodule vendor/warcraftxl updated: 9d981125e1...72fb680877
Reference in New Issue
Block a user