diff --git a/CUSTOM_CREATURES.md b/CUSTOM_CREATURES.md new file mode 100644 index 0000000..d91528f --- /dev/null +++ b/CUSTOM_CREATURES.md @@ -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. diff --git a/assets/dbc/3.3.5a/CreatureDisplayInfo.dbc b/assets/dbc/3.3.5a/CreatureDisplayInfo.dbc new file mode 100644 index 0000000..ac0fd4d Binary files /dev/null and b/assets/dbc/3.3.5a/CreatureDisplayInfo.dbc differ diff --git a/assets/dbc/3.3.5a/CreatureModelData.dbc b/assets/dbc/3.3.5a/CreatureModelData.dbc new file mode 100644 index 0000000..05faab4 Binary files /dev/null and b/assets/dbc/3.3.5a/CreatureModelData.dbc differ diff --git a/custom-creatures.json b/custom-creatures.json new file mode 100644 index 0000000..d7b18b4 --- /dev/null +++ b/custom-creatures.json @@ -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 + } + ] +} diff --git a/custom-creatures.lock.json b/custom-creatures.lock.json new file mode 100644 index 0000000..634473d --- /dev/null +++ b/custom-creatures.lock.json @@ -0,0 +1,8 @@ +{ + "models": { + "catslime": { + "model_data_id": 50000, + "display_info_id": 50000 + } + } +} diff --git a/src/Data/patch-Z/Creature/Catslime/catslime.manifest.json b/src/Data/patch-Z/Creature/Catslime/catslime.manifest.json new file mode 100644 index 0000000..86215c3 --- /dev/null +++ b/src/Data/patch-Z/Creature/Catslime/catslime.manifest.json @@ -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 + } + ] +} diff --git a/src/Data/patch-Z/WXLFileData.csv b/src/Data/patch-Z/WXLFileData.csv deleted file mode 100644 index c35bcb0..0000000 --- a/src/Data/patch-Z/WXLFileData.csv +++ /dev/null @@ -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 diff --git a/tool/Cargo.lock b/tool/Cargo.lock index 1026b8f..7d4fd0d 100644 --- a/tool/Cargo.lock +++ b/tool/Cargo.lock @@ -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", ] diff --git a/tool/Cargo.toml b/tool/Cargo.toml index 8a24e63..96c7237 100644 --- a/tool/Cargo.toml +++ b/tool/Cargo.toml @@ -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" diff --git a/tool/src/creatures.rs b/tool/src/creatures.rs new file mode 100644 index 0000000..50019fd --- /dev/null +++ b/tool/src/creatures.rs @@ -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, + #[serde(default)] + pub pets: Vec, +} + +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, + pub archive: Option, + pub model_file: Option, + pub template_display_id: Option, + pub display_scale: Option, + pub model_scale: Option, + pub collision_width: Option, + pub collision_height: Option, + pub mount_height: Option, + pub flags: Option, +} + +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, +} + +#[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, + pub creature_model_data: Vec, + pub creature_display_info: Vec, +} + +#[derive(Clone)] +struct Wdbc { + field_count: u32, + record_size: u32, + records: Vec>, + strings: Vec, +} + +impl Wdbc { + fn read(path: &Path, fields: u32, record_size: u32) -> Result> { + 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> { + 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 { + 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> { + 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> { + 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> { + 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> { + Ok(folder + .file_name() + .ok_or("creature folder has no name")? + .to_string_lossy() + .to_ascii_lowercase()) +} + +fn default_archive(folder: &Path) -> Result> { + Ok(format!( + "Creature/{}", + folder + .file_name() + .ok_or("creature folder has no name")? + .to_string_lossy() + )) +} + +fn find_manifest(folder: &Path) -> Result> { + let manifests: Vec = 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> { + if let Some(model) = explicit { + return Ok(model.to_string()); + } + let models: Vec = 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 = lock.models.values().map(|ids| ids.model_data_id).collect(); + let used_displays: HashSet = 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> { + 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, Vec), Box> { + 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, Box> { + 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, + })) +} diff --git a/tool/src/main.rs b/tool/src/main.rs index 0401541..2c6eba5 100644 --- a/tool/src/main.rs +++ b/tool/src/main.rs @@ -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> { +fn build_mpq( + input_dir: &Path, + output_path: &Path, + creatures: Option<&GeneratedCreatures>, +) -> Result<(), Box> { let mut output = output_path.to_path_buf(); output.set_extension("MPQ"); @@ -36,6 +43,7 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box> .default_compression(flags::ZLIB) .listfile_option(ListfileOption::Generate); + let mut archive_names = std::collections::HashSet::new(); for entry in WalkDir::new(&base) .into_iter() .filter_map(|e| e.ok()) @@ -50,10 +58,35 @@ fn build_mpq(input_dir: &Path, output_path: &Path) -> Result<(), Box> } }; 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); + archive_names.insert(archive_path.to_ascii_lowercase()); 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)?; println!("Created: {}", output.display()); Ok(()) @@ -72,6 +105,12 @@ fn main() -> Result<(), Box> { 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)?; let data_dir = src_dir.join("Data"); if !data_dir.exists() { @@ -97,12 +136,17 @@ fn main() -> Result<(), Box> { } 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)?; } }