Automate custom mount and pet DBC generation
This commit is contained in:
@@ -0,0 +1,64 @@
|
|||||||
|
# Автоматический импорт 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 каждой модели и добавляет все зависимости в `patch-Z.MPQ`;
|
||||||
|
2. генерирует `WXLFileData.csv` для WarcraftXL;
|
||||||
|
3. дописывает модели в `CreatureModelData.dbc` и `CreatureDisplayInfo.dbc`;
|
||||||
|
4. сохраняет стабильные ID в `custom-creatures.lock.json`;
|
||||||
|
5. кладёт серверные копии DBC и таблицу ID в `build/server-dbc`.
|
||||||
|
|
||||||
|
Перестановка моделей в списке не меняет уже выданные ID. Lock-файл необходимо хранить в Git.
|
||||||
|
|
||||||
|
## Дополнительные параметры
|
||||||
|
|
||||||
|
Если значений по умолчанию недостаточно, вместо строки используется объект:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"folder": "assets/creatures/my_mount",
|
||||||
|
"key": "my_mount",
|
||||||
|
"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;
|
||||||
|
параметры объекта позволяют их переопределить.
|
||||||
|
|
||||||
|
Сгенерированные DBC сами по себе не создают серверное существо или заклинание. Сервер должен
|
||||||
|
загрузить DBC из `build/server-dbc`, а полученный `displayInfoID` нужно назначить записи питомца,
|
||||||
|
транспорта или соответствующему spell.
|
||||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"base_dbc_dir": "assets/dbc/3.3.5a",
|
||||||
|
"model_data_id_start": 50000,
|
||||||
|
"display_info_id_start": 50000,
|
||||||
|
"defaults": {
|
||||||
|
"mount_template_display_id": 2404,
|
||||||
|
"pet_template_display_id": 5448
|
||||||
|
},
|
||||||
|
"mounts": [],
|
||||||
|
"pets": [
|
||||||
|
{
|
||||||
|
"folder": "src/Data/patch-Z/Creature/Catslime",
|
||||||
|
"display_scale": 1.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"models": {
|
||||||
|
"catslime": {
|
||||||
|
"model_data_id": 50000,
|
||||||
|
"display_info_id": 50000
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
# FileDataID,archive path
|
|
||||||
3730958,Creature/Catslime/catslime.m2
|
|
||||||
3732460,Creature/Catslime/catslime_3732460.blp
|
|
||||||
3732461,Creature/Catslime/catslime_3732461.blp
|
|
||||||
3732462,Creature/Catslime/catslime0097-00.anim
|
|
||||||
3732463,Creature/Catslime/catslime0096-00.anim
|
|
||||||
3732464,Creature/Catslime/catslime0098-00.anim
|
|
||||||
3732465,Creature/Catslime/catslime0100-00.anim
|
|
||||||
3732466,Creature/Catslime/catslime0099-00.anim
|
|
||||||
3732467,Creature/Catslime/catslime0101-00.anim
|
|
||||||
3732468,Creature/Catslime/catslime0069-00.anim
|
|
||||||
3732469,Creature/Catslime/catslime0123-00.anim
|
|
||||||
3732470,Creature/Catslime/catslime0061-00.anim
|
|
||||||
3732471,Creature/Catslime/catslime0075-00.anim
|
|
||||||
3732472,Creature/Catslime/catslime00.skin
|
|
||||||
3732473,Creature/Catslime/catslime_lod01.skin
|
|
||||||
3732474,Creature/Catslime/catslime_lod02.skin
|
|
||||||
3732475,Creature/Catslime/catslime_lod03.skin
|
|
||||||
|
Generated
+3
@@ -915,6 +915,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde_core",
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1059,6 +1060,8 @@ dependencies = [
|
|||||||
name = "tool"
|
name = "tool"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"walkdir",
|
"walkdir",
|
||||||
"wow-mpq",
|
"wow-mpq",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -6,3 +6,5 @@ edition = "2024"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
walkdir = "2.5.0"
|
walkdir = "2.5.0"
|
||||||
wow-mpq = "0.6.4"
|
wow-mpq = "0.6.4"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
|||||||
@@ -0,0 +1,618 @@
|
|||||||
|
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,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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,
|
||||||
|
}
|
||||||
|
|
||||||
|
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>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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 allocate_ids(lock: &mut LockFile, key: &str, model_start: u32, display_start: u32) -> LockedIds {
|
||||||
|
if let Some(ids) = lock.models.get(key) {
|
||||||
|
return ids.clone();
|
||||||
|
}
|
||||||
|
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 mut model = model_start;
|
||||||
|
while used_models.contains(&model) {
|
||||||
|
model += 1;
|
||||||
|
}
|
||||||
|
let mut display = display_start;
|
||||||
|
while used_displays.contains(&display) {
|
||||||
|
display += 1;
|
||||||
|
}
|
||||||
|
let ids = LockedIds {
|
||||||
|
model_data_id: model,
|
||||||
|
display_info_id: display,
|
||||||
|
};
|
||||||
|
lock.models.insert(key.to_string(), ids.clone());
|
||||||
|
ids
|
||||||
|
}
|
||||||
|
|
||||||
|
fn prepare_entry(
|
||||||
|
project_root: &Path,
|
||||||
|
spec: &EntrySpec,
|
||||||
|
template_default: u32,
|
||||||
|
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 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.model_data_id_start,
|
||||||
|
registry.display_info_id_start,
|
||||||
|
);
|
||||||
|
Ok(PreparedEntry {
|
||||||
|
key,
|
||||||
|
folder,
|
||||||
|
archive,
|
||||||
|
model_file,
|
||||||
|
file_data,
|
||||||
|
assets,
|
||||||
|
template_display_id: options.template_display_id.unwrap_or(template_default),
|
||||||
|
ids,
|
||||||
|
options,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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()))
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
&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,
|
||||||
|
&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 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());
|
||||||
|
}
|
||||||
|
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 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)?;
|
||||||
|
let mut id_map = String::from("key,type,modelDataID,displayInfoID\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
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
}))
|
||||||
|
}
|
||||||
+47
-3
@@ -7,6 +7,9 @@ use walkdir::WalkDir;
|
|||||||
use wow_mpq::compression::flags;
|
use wow_mpq::compression::flags;
|
||||||
use wow_mpq::{ArchiveBuilder, FormatVersion, ListfileOption};
|
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).
|
/// Checks if a directory name looks like a WoW locale folder (e.g. ruRU, enUS, deDE).
|
||||||
fn is_locale_dir(name: &str) -> bool {
|
fn is_locale_dir(name: &str) -> bool {
|
||||||
if name.len() != 4 {
|
if name.len() != 4 {
|
||||||
@@ -19,7 +22,11 @@ fn is_locale_dir(name: &str) -> bool {
|
|||||||
&& b[3].is_ascii_uppercase()
|
&& 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();
|
let mut output = output_path.to_path_buf();
|
||||||
output.set_extension("MPQ");
|
output.set_extension("MPQ");
|
||||||
|
|
||||||
@@ -36,6 +43,7 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>>
|
|||||||
.default_compression(flags::ZLIB)
|
.default_compression(flags::ZLIB)
|
||||||
.listfile_option(ListfileOption::Generate);
|
.listfile_option(ListfileOption::Generate);
|
||||||
|
|
||||||
|
let mut archive_names = std::collections::HashSet::new();
|
||||||
for entry in WalkDir::new(&base)
|
for entry in WalkDir::new(&base)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter_map(|e| e.ok())
|
.filter_map(|e| e.ok())
|
||||||
@@ -50,10 +58,35 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box<dyn Error>>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let archive_path = rel_path.to_string_lossy().replace('\\', "/");
|
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);
|
println!(" Adding: {}", archive_path);
|
||||||
|
archive_names.insert(archive_path.to_ascii_lowercase());
|
||||||
builder = builder.add_file(&full_path, &archive_path);
|
builder = builder.add_file(&full_path, &archive_path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(generated) = creatures {
|
||||||
|
for (source, archive_path) in &generated.assets {
|
||||||
|
if archive_names.insert(archive_path.to_ascii_lowercase()) {
|
||||||
|
println!(" Adding generated creature asset: {}", archive_path);
|
||||||
|
builder = builder.add_file(source, archive_path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!(" Adding generated: WXLFileData.csv");
|
||||||
|
builder = builder.add_file_data(generated.file_data_csv.clone(), "WXLFileData.csv");
|
||||||
|
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)?;
|
builder.build(&output)?;
|
||||||
println!("Created: {}", output.display());
|
println!("Created: {}", output.display());
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -72,6 +105,12 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
|
|
||||||
let src_dir = PathBuf::from(&args[1]);
|
let src_dir = PathBuf::from(&args[1]);
|
||||||
let output_dir = PathBuf::from(&args[2]);
|
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)?;
|
||||||
|
|
||||||
let data_dir = src_dir.join("Data");
|
let data_dir = src_dir.join("Data");
|
||||||
if !data_dir.exists() {
|
if !data_dir.exists() {
|
||||||
@@ -97,12 +136,17 @@ fn main() -> Result<(), Box<dyn Error>> {
|
|||||||
}
|
}
|
||||||
let sub_name = sub.file_name().to_string_lossy().to_string();
|
let sub_name = sub.file_name().to_string_lossy().to_string();
|
||||||
let output_mpq = output_dir.join("Data").join(&name).join(&sub_name);
|
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 {
|
} else {
|
||||||
// Regular patch dir, e.g. patch-4/ or patch-Z/
|
// Regular patch dir, e.g. patch-4/ or patch-Z/
|
||||||
let output_mpq = output_dir.join("Data").join(&name);
|
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)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user