rpfm_lib/files/anim_fragment_battle/mod.rs
1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Animation fragment battle file format support.
12//!
13//! This module handles animation fragment files (`.bin`/`.frg`) which define battle animations
14//! for units in Total War games. These files replaced the older text-based animation tables
15//! with a more efficient binary format.
16//!
17//! # File Format
18//!
19//! Animation fragments use game-specific binary formats with different versions:
20//! - **Version 2 (Warhammer 2)**: Basic animation metadata with file references
21//! - **Version 2 (Three Kingdoms)**: Enhanced with mount/unmount tables and locomotion graphs
22//! - **Version 4 (Warhammer 3)**: Advanced format with animation references and cavalry tech flags
23//!
24//! # File Extensions
25//!
26//! - `.bin` - Modern binary animation fragment files (Warhammer 2+)
27//! - `.frg` - Legacy fragment file extension (older games)
28//!
29//! # File Organization
30//!
31//! Animation fragments are stored in:
32//! ```text
33//! animations/{skeleton_name}/battle/{animation_type}.bin
34//! ```
35//!
36//! # Supported Games
37//!
38//! Full support for:
39//! - Total War: Warhammer II (version 2)
40//! - Total War: Three Kingdoms (version 2, enhanced)
41//! - A Total War Saga: Troy (version 2)
42//! - Total War: Warhammer III (version 4)
43//! - Total War: Pharaoh / Pharaoh Dynasties (version 2)
44//!
45//! Older games (pre-Warhammer 2) are not supported.
46//!
47//! # Animation Entry Structure
48//!
49//! Each entry contains:
50//! - Animation ID and metadata (blend time, selection weight)
51//! - Weapon bone flags (for weapon attachment points)
52//! - File references to animation data and metadata
53//! - Optional single-frame variant flag
54//!
55//! # Table Conversion
56//!
57//! Animation fragments can be converted to/from [`TableInMemory`]
58//! for editing as TSV files.
59//!
60//! [`TableInMemory`]: crate::files::table::local::TableInMemory
61//!
62//! # Usage
63//!
64//! ```ignore
65//! use rpfm_lib::files::anim_fragment_battle::AnimFragmentBattle;
66//! use rpfm_lib::files::Decodeable;
67//!
68//! // Decode an animation fragment
69//! let fragment = AnimFragmentBattle::decode(&mut data, &Some(extra_data))?;
70//!
71//! // Access entries
72//! for entry in fragment.entries() {
73//! println!("Animation {}: blend={}, weight={}",
74//! entry.animation_id(),
75//! entry.blend_in_time(),
76//! entry.selection_weight()
77//! );
78//! }
79//!
80//! // Convert to table for TSV export
81//! let table = fragment.to_table()?;
82//! ```
83
84use bitflags::bitflags;
85use getset::*;
86use serde_derive::{Serialize, Deserialize};
87
88use std::collections::HashMap;
89use std::io::Cursor;
90
91use crate::binary::{ReadBytes, WriteBytes};
92use crate::error::{RLibError, Result};
93use crate::files::{DecodeableExtraData, Decodeable, EncodeableExtraData, Encodeable, table::{DecodedData, local::TableInMemory, Table}};
94use crate::games::supported_games::{KEY_PHARAOH, KEY_PHARAOH_DYNASTIES, KEY_THREE_KINGDOMS, KEY_TROY, KEY_WARHAMMER_2};
95use crate::schema::*;
96use crate::utils::check_size_mismatch;
97
98/// Base directory path for animation files.
99pub const BASE_PATH: &str = "animations/";
100
101/// Middle path component for battle animations.
102pub const MID_PATH: &str = "/battle/";
103
104/// Modern file extension for animation fragment files.
105pub const EXTENSION_NEW: &str = ".bin";
106
107/// Legacy file extension for animation fragment files.
108pub const EXTENSION_OLD: &str = ".frg";
109
110mod versions;
111
112#[cfg(test)] mod anim_fragment_battle_test;
113
114//---------------------------------------------------------------------------//
115// Enum & Structs
116//---------------------------------------------------------------------------//
117
118/// Represents a battle animation fragment file.
119///
120/// Contains animation entries and metadata for a specific skeleton type.
121/// The structure varies by game version, with newer games supporting more features.
122///
123/// # Version Differences
124///
125/// - **Version 2 (Warhammer 2/Troy/Pharaoh)**: Basic structure with min/max IDs
126/// - **Version 2 (Three Kingdoms)**: Adds mount tables and locomotion graph support
127/// - **Version 4 (Warhammer 3)**: Enhanced with subversion and cavalry tech flags
128#[derive(PartialEq, Clone, Debug, Default, Getters, MutGetters, Setters, Serialize, Deserialize)]
129#[getset(get = "pub", get_mut = "pub", set = "pub")]
130pub struct AnimFragmentBattle {
131 // Common fields across all versions
132
133 /// File format version (2 or 4).
134 version: u32,
135
136 /// List of animation entries in this fragment.
137 entries: Vec<Entry>,
138
139 /// Name of the skeleton this animation fragment applies to.
140 skeleton_name: String,
141
142 // Warhammer 3 specific (version 4)
143
144 /// Format subversion (version 4 only).
145 subversion: u32,
146
147 // Warhammer 3 / Three Kingdoms specific
148
149 /// Name of the animation table.
150 table_name: String,
151
152 /// Name of the mount animation table.
153 mount_table_name: String,
154
155 /// Name of the unmount animation table.
156 unmount_table_name: String,
157
158 /// Locomotion graph identifier.
159 locomotion_graph: String,
160
161 /// Whether this uses simple flight mechanics.
162 is_simple_flight: bool,
163
164 /// Whether this uses new cavalry technology.
165 is_new_cavalry_tech: bool,
166
167 // Warhammer 2 specific (version 2)
168
169 /// Minimum animation ID in this fragment (version 2 only).
170 min_id: u32,
171
172 /// Maximum animation ID in this fragment (version 2 only).
173 max_id: u32,
174}
175
176/// Represents a single animation entry within a fragment.
177///
178/// Contains all metadata and file references for one animation. The structure
179/// varies between version 2 and version 4 formats.
180///
181/// # Version Differences
182///
183/// - **Version 2**: Uses direct filename and metadata strings
184/// - **Version 4**: Uses `anim_refs` for multiple animation file references
185#[derive(PartialEq, Clone, Debug, Default, Getters, MutGetters, Setters, Serialize, Deserialize)]
186#[getset(get = "pub", get_mut = "pub", set = "pub")]
187pub struct Entry {
188 // Common fields across all versions
189
190 /// Unique animation identifier.
191 animation_id: u32,
192
193 /// Blend-in time in seconds for smooth transitions.
194 blend_in_time: f32,
195
196 /// Selection weight for animation variation selection (higher = more likely).
197 selection_weight: f32,
198
199 /// Weapon attachment bone flags.
200 weapon_bone: WeaponBone,
201
202 // Warhammer 3 specific (version 4)
203
204 /// Animation file references (version 4 only).
205 ///
206 /// Contains paths to animation data, metadata, and sound files.
207 anim_refs: Vec<AnimRef>,
208
209 // Warhammer 2 specific (version 2)
210
211 /// Slot identifier (version 2 only).
212 slot_id: u32,
213
214 /// Animation filename (version 2 only).
215 filename: String,
216
217 /// Metadata file path (version 2 only).
218 metadata: String,
219
220 /// Sound metadata file path (version 2 only).
221 metadata_sound: String,
222
223 /// Skeleton type identifier (version 2 only).
224 skeleton_type: String,
225
226 /// Unknown field (purpose not identified, version 2 only).
227 uk_3: u32,
228
229 /// Unknown field (purpose not identified, version 2 only).
230 uk_4: String,
231
232 // Common to version 2 and 4
233
234 /// Whether this is a single-frame animation variant.
235 single_frame_variant: bool,
236}
237
238/// Animation file reference (version 4 only).
239///
240/// Contains paths to the three files that make up an animation:
241/// - Animation data file (skeletal animation)
242/// - Metadata file (timing, events, etc.)
243/// - Sound file (audio cues and effects)
244#[derive(PartialEq, Clone, Debug, Default, Getters, MutGetters, Setters, Serialize, Deserialize)]
245#[getset(get = "pub", get_mut = "pub", set = "pub")]
246pub struct AnimRef {
247 /// Path to the animation data file.
248 file_path: String,
249
250 /// Path to the animation metadata file.
251 meta_file_path: String,
252
253 /// Path to the sound file.
254 snd_file_path: String,
255}
256
257bitflags! {
258 /// Weapon attachment bone flags.
259 ///
260 /// Defines which bones (attachment points) on the skeleton are used for
261 /// weapon positioning during this animation. Multiple bones can be active
262 /// simultaneously (e.g., for dual-wielding).
263 ///
264 /// # Bone Mapping
265 ///
266 /// Each bit corresponds to a specific weapon attachment point:
267 /// - `WEAPON_BONE_1`: Primary weapon hand (typically right hand)
268 /// - `WEAPON_BONE_2`: Secondary weapon hand (typically left hand)
269 /// - `WEAPON_BONE_3`: Back-mounted weapon (holstered)
270 /// - `WEAPON_BONE_4`: Additional attachment point
271 /// - `WEAPON_BONE_5`: Additional attachment point
272 /// - `WEAPON_BONE_6`: Additional attachment point
273 ///
274 /// The exact bone mapping depends on the skeleton definition.
275 #[derive(PartialEq, Clone, Copy, Debug, Default, Serialize, Deserialize)]
276 pub struct WeaponBone: u32 {
277 /// Primary weapon bone (bit 0).
278 const WEAPON_BONE_1 = 0b0000_0000_0000_0001;
279
280 /// Secondary weapon bone (bit 1).
281 const WEAPON_BONE_2 = 0b0000_0000_0000_0010;
282
283 /// Tertiary weapon bone (bit 2).
284 const WEAPON_BONE_3 = 0b0000_0000_0000_0100;
285
286 /// Fourth weapon bone (bit 3).
287 const WEAPON_BONE_4 = 0b0000_0000_0000_1000;
288
289 /// Fifth weapon bone (bit 4).
290 const WEAPON_BONE_5 = 0b0000_0000_0001_0000;
291
292 /// Sixth weapon bone (bit 5).
293 const WEAPON_BONE_6 = 0b0000_0000_0010_0000;
294 }
295}
296
297//---------------------------------------------------------------------------//
298// Implementation of AnimFragment
299//---------------------------------------------------------------------------//
300
301impl AnimFragmentBattle {
302
303 /// Returns table schema definitions for animation fragments.
304 ///
305 /// Provides two [`Definition`]s:
306 /// 1. Main entry definition with all animation entry fields
307 /// 2. Animation reference sub-definition (for version 4 `anim_refs` field)
308 ///
309 /// These definitions are used when converting animation fragments to/from
310 /// [`TableInMemory`] for TSV export/import.
311 ///
312 /// [`TableInMemory`]: crate::files::table::local::TableInMemory
313 ///
314 /// # Returns
315 ///
316 /// A tuple of `(entry_definition, anim_ref_definition)`.
317 pub fn definitions() -> (Definition, Definition) {
318 let anim_refs_definition = Definition::new_with_fields(0, &[
319 Field { name: "file_path".to_string(), field_type: FieldType::StringU8, ..Default::default() },
320 Field { name: "meta_file_path".to_string(), field_type: FieldType::StringU8, ..Default::default() },
321 Field { name: "snd_file_path".to_string(), field_type: FieldType::StringU8, ..Default::default() },
322 ], &[], None);
323
324 let definition = Definition::new_with_fields(0, &[
325 Field { name: "animation_id".to_string(), field_type: FieldType::I32, is_key: true, ..Default::default() },
326 Field { name: "blend_in_time".to_string(), field_type: FieldType::F32, ..Default::default() },
327 Field { name: "selection_weight".to_string(), field_type: FieldType::F32, ..Default::default() },
328 Field { name: "weapon_bone".to_string(), field_type: FieldType::I32, is_bitwise: 6, ..Default::default() },
329 Field { name: "anim_refs".to_string(), field_type: FieldType::SequenceU32(Box::new(anim_refs_definition.clone())), ..Default::default() },
330 Field { name: "slot_id".to_string(), field_type: FieldType::I32, is_key: true, ..Default::default() },
331 Field { name: "filename".to_string(), field_type: FieldType::StringU8, ..Default::default() },
332 Field { name: "metadata".to_string(), field_type: FieldType::StringU8, ..Default::default() },
333 Field { name: "metadata_sound".to_string(), field_type: FieldType::StringU8, ..Default::default() },
334 Field { name: "skeleton_type".to_string(), field_type: FieldType::StringU8, ..Default::default() },
335 Field { name: "uk_3".to_string(), field_type: FieldType::I32, ..Default::default() },
336 Field { name: "uk_4".to_string(), field_type: FieldType::StringU8, ..Default::default() },
337 Field { name: "single_frame_variant".to_string(), field_type: FieldType::Boolean, ..Default::default() },
338 ], &[], None);
339
340 (definition, anim_refs_definition)
341 }
342
343 /// Converts a table to a list of animation entries.
344 ///
345 /// Parses a [`TableInMemory`] (typically loaded from TSV)
346 /// and extracts animation entry data.
347 ///
348 /// [`TableInMemory`]: crate::files::table::local::TableInMemory
349 ///
350 /// # Parameters
351 ///
352 /// - `table`: The table containing animation entry data
353 ///
354 /// # Returns
355 ///
356 /// A vector of [`Entry`] structs parsed from the table rows.
357 ///
358 /// # Errors
359 ///
360 /// Returns an error if the table structure doesn't match the expected schema
361 /// or if data conversion fails.
362 pub fn from_table(table: &TableInMemory) -> Result<Vec<Entry>> {
363 let mut entries = vec![];
364
365 let definition = table.definition();
366 let fields_processed = definition.fields_processed();
367
368 for row in table.data().iter() {
369 let mut entry = Entry::default();
370
371 if let DecodedData::I32(data) = row[0] {
372 entry.set_animation_id(data as u32);
373 }
374
375 if let DecodedData::F32(data) = row[1] {
376 entry.set_blend_in_time(data);
377 }
378
379 if let DecodedData::F32(data) = row[2] {
380 entry.set_selection_weight(data);
381 }
382
383 if let DecodedData::Boolean(data_1) = row[3] {
384 if let DecodedData::Boolean(data_2) = row[4] {
385 if let DecodedData::Boolean(data_3) = row[5] {
386 if let DecodedData::Boolean(data_4) = row[6] {
387 if let DecodedData::Boolean(data_5) = row[7] {
388 if let DecodedData::Boolean(data_6) = row[8] {
389 let mut bits = WeaponBone::empty();
390
391 if data_1 { bits |= WeaponBone::WEAPON_BONE_1; }
392 if data_2 { bits |= WeaponBone::WEAPON_BONE_2; }
393 if data_3 { bits |= WeaponBone::WEAPON_BONE_3; }
394 if data_4 { bits |= WeaponBone::WEAPON_BONE_4; }
395 if data_5 { bits |= WeaponBone::WEAPON_BONE_5; }
396 if data_6 { bits |= WeaponBone::WEAPON_BONE_6; }
397
398 entry.set_weapon_bone(bits);
399 }
400 }
401 }
402 }
403 }
404 }
405
406 if let DecodedData::SequenceU32(ref data) = row[9] {
407 if let FieldType::SequenceU32(ref definition) = fields_processed[9].field_type() {
408 let mut data = Cursor::new(data);
409 let data = TableInMemory::decode(&mut data, definition, &HashMap::new(), None, false, fields_processed[9].name())?;
410 let mut entries = vec![];
411
412 for row in data.data().iter() {
413 let mut entry = AnimRef::default();
414
415 if let DecodedData::StringU8(ref data) = row[0] {
416 entry.set_file_path(data.to_string());
417 }
418
419 if let DecodedData::StringU8(ref data) = row[1] {
420 entry.set_meta_file_path(data.to_string());
421 }
422
423 if let DecodedData::StringU8(ref data) = row[2] {
424 entry.set_snd_file_path(data.to_string());
425 }
426
427 entries.push(entry);
428 }
429
430 entry.set_anim_refs(entries);
431 }
432 }
433
434 if let DecodedData::I32(data) = row[10] {
435 entry.set_slot_id(data as u32);
436 }
437
438 if let DecodedData::StringU8(ref data) = row[11] {
439 entry.set_filename(data.to_string());
440 }
441
442 if let DecodedData::StringU8(ref data) = row[12] {
443 entry.set_metadata(data.to_string());
444 }
445
446 if let DecodedData::StringU8(ref data) = row[13] {
447 entry.set_metadata_sound(data.to_string());
448 }
449
450 if let DecodedData::StringU8(ref data) = row[14] {
451 entry.set_skeleton_type(data.to_string());
452 }
453
454 if let DecodedData::I32(data) = row[15] {
455 entry.set_uk_3(data as u32);
456 }
457
458 if let DecodedData::StringU8(ref data) = row[16] {
459 entry.set_uk_4(data.to_string());
460 }
461
462 if let DecodedData::Boolean(data) = row[17] {
463 entry.set_single_frame_variant(data);
464 }
465
466 entries.push(entry);
467 }
468
469 Ok(entries)
470 }
471
472 /// Converts this animation fragment to a table.
473 ///
474 /// Creates a [`TableInMemory`] from the animation entries, which can then be
475 /// exported as TSV for editing.
476 ///
477 /// [`TableInMemory`]: crate::files::table::local::TableInMemory
478 ///
479 /// # Returns
480 ///
481 /// A table containing all animation entries with the appropriate schema.
482 ///
483 /// # Errors
484 ///
485 /// Returns an error if table construction or data conversion fails.
486 pub fn to_table(&self) -> Result<TableInMemory> {
487 let (definition, anim_refs_definition) = Self::definitions();
488 let mut table = TableInMemory::new(&definition, None, "");
489
490 let data = self.entries()
491 .iter()
492 .map(|entry| {
493 let mut row = Vec::with_capacity(19);
494 row.push(DecodedData::I32(*entry.animation_id() as i32));
495 row.push(DecodedData::F32(*entry.blend_in_time()));
496 row.push(DecodedData::F32(*entry.selection_weight()));
497 row.push(DecodedData::Boolean(entry.weapon_bone().contains(WeaponBone::WEAPON_BONE_1)));
498 row.push(DecodedData::Boolean(entry.weapon_bone().contains(WeaponBone::WEAPON_BONE_2)));
499 row.push(DecodedData::Boolean(entry.weapon_bone().contains(WeaponBone::WEAPON_BONE_3)));
500 row.push(DecodedData::Boolean(entry.weapon_bone().contains(WeaponBone::WEAPON_BONE_4)));
501 row.push(DecodedData::Boolean(entry.weapon_bone().contains(WeaponBone::WEAPON_BONE_5)));
502 row.push(DecodedData::Boolean(entry.weapon_bone().contains(WeaponBone::WEAPON_BONE_6)));
503
504 let mut anim_refs_subtable = TableInMemory::new(&anim_refs_definition, None, "anim_refs");
505 let mut anim_ref_rows = Vec::with_capacity(entry.anim_refs().len());
506 for anim_ref in entry.anim_refs() {
507 let anim_ref_row = vec![
508 DecodedData::StringU8(anim_ref.file_path().to_string()),
509 DecodedData::StringU8(anim_ref.meta_file_path().to_string()),
510 DecodedData::StringU8(anim_ref.snd_file_path().to_string()),
511 ];
512 anim_ref_rows.push(anim_ref_row)
513 }
514 anim_refs_subtable.set_data(&anim_ref_rows).unwrap();
515 let mut writer = vec![];
516 writer.write_u32(anim_ref_rows.len() as u32).unwrap();
517 let _ = anim_refs_subtable.encode(&mut writer);
518
519 row.push(DecodedData::SequenceU32(writer));
520
521 row.push(DecodedData::I32(*entry.slot_id() as i32));
522 row.push(DecodedData::StringU8(entry.filename().to_string()));
523 row.push(DecodedData::StringU8(entry.metadata().to_string()));
524 row.push(DecodedData::StringU8(entry.metadata_sound().to_string()));
525 row.push(DecodedData::StringU8(entry.skeleton_type().to_string()));
526 row.push(DecodedData::I32(*entry.uk_3() as i32));
527 row.push(DecodedData::StringU8(entry.uk_4().to_string()));
528 row.push(DecodedData::Boolean(*entry.single_frame_variant()));
529
530 row
531 }).collect::<Vec<_>>();
532
533 table.set_data(&data)?;
534 Ok(table)
535 }
536}
537
538impl Decodeable for AnimFragmentBattle {
539
540 fn decode<R: ReadBytes>(data: &mut R, extra_data: &Option<DecodeableExtraData>) -> Result<Self> {
541 let extra_data = extra_data.as_ref().ok_or(RLibError::DecodingMissingExtraData)?;
542 let game_info = extra_data.game_info.ok_or_else(|| RLibError::DecodingMissingExtraDataField("game_info".to_owned()))?;
543
544 let version = data.read_u32()?;
545
546 let mut fragment = Self::default();
547 fragment.version = version;
548
549 match version {
550 2 => match game_info.key() {
551 KEY_WARHAMMER_2 | KEY_TROY | KEY_PHARAOH | KEY_PHARAOH_DYNASTIES => fragment.read_v2_wh2(data)?,
552 KEY_THREE_KINGDOMS => fragment.read_v2_3k(data)?,
553 _ => Err(RLibError::DecodingMatchedCombatUnsupportedVersion(fragment.version as usize))?,
554 },
555 4 => fragment.read_v4(data)?,
556 _ => Err(RLibError::DecodingAnimFragmentUnsupportedVersion(version as usize))?,
557 }
558
559 // If we are not in the last byte, it means we didn't parse the entire file, which means this file is corrupt.
560 check_size_mismatch(data.stream_position()? as usize, data.len()? as usize)?;
561
562 Ok(fragment)
563 }
564}
565
566impl Encodeable for AnimFragmentBattle {
567
568 fn encode<W: WriteBytes>(&mut self, buffer: &mut W, extra_data: &Option<EncodeableExtraData>) -> Result<()> {
569 let extra_data = extra_data.as_ref().ok_or(RLibError::DecodingMissingExtraData)?;
570 let game_info = extra_data.game_info.ok_or_else(|| RLibError::DecodingMissingExtraDataField("game_info".to_owned()))?;
571
572 buffer.write_u32(self.version)?;
573
574 match self.version {
575 2 => match game_info.key() {
576 KEY_WARHAMMER_2 | KEY_TROY | KEY_PHARAOH | KEY_PHARAOH_DYNASTIES => self.write_v2_wh2(buffer)?,
577 KEY_THREE_KINGDOMS => self.write_v2_3k(buffer)?,
578 _ => Err(RLibError::DecodingMatchedCombatUnsupportedVersion(self.version as usize))?,
579 },
580 4 => self.write_v4(buffer)?,
581 _ => Err(RLibError::DecodingAnimFragmentUnsupportedVersion(self.version as usize))?,
582 };
583
584 Ok(())
585 }
586}