rpfm_lib/files/matched_combat/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//! This module contains the implementation of the Matched Combat file format for Total War games.
12//!
13//! Matched combat files define synchronized combat animations between units, specifying which
14//! animation files to use, what conditions must be met, and how units transition during the
15//! animation sequence. These files control cinematic melee combat interactions where units
16//! perform matched animations together (e.g., sword duels, executions, grapples).
17//!
18//! # File Format
19//!
20//! Matched combat files (`.bin`) contain entries that define combat animation sequences.
21//! Each entry specifies:
22//!
23//! - Animation participants (multiple units involved in the sequence)
24//! - Entity-specific animation files and metadata
25//! - Selection weights for animation variety
26//! - Filters to determine when animations can be used (unit types, equipment, etc.)
27//! - State transitions (alive to dead, alive to alive, etc.)
28//! - Team assignments for participants
29//!
30//! # Versions
31//!
32//! The format has multiple versions with game-specific variations:
33//!
34//! - Version 1 (Three Kingdoms): Basic matched combat system
35//! - Version 1 (Warhammer 3): Extended with mount animations
36//! - Version 3: Further expanded format
37//!
38//! # File Locations
39//!
40//! Matched combat files are typically found in:
41//! - `animations/matched_combat/*.bin`
42//! - `animations/database/matched/*.bin`
43//! - `animations/database/trigger/*.bin`
44//!
45//! # Usage Example
46//!
47//! ```rust,ignore
48//! use rpfm_lib::files::{Decodeable, matched_combat::*};
49//!
50//! // Decode a matched combat file
51//! let mut data = std::io::Cursor::new(file_data);
52//! let extra_data = Some(DecodeableExtraData {
53//! game_info: Some(game_info),
54//! ..Default::default()
55//! });
56//! let matched = MatchedCombat::decode(&mut data, &extra_data)?;
57//!
58//! // Access combat entries
59//! for entry in matched.entries() {
60//! println!("Combat ID: {}", entry.id());
61//! for participant in entry.participants() {
62//! println!("Team: {}", participant.team());
63//! }
64//! }
65//! ```
66
67use getset::{Getters, Setters};
68use serde_derive::{Serialize, Deserialize};
69
70use crate::binary::{ReadBytes, WriteBytes};
71use crate::error::{RLibError, Result};
72use crate::files::{DecodeableExtraData, Decodeable, EncodeableExtraData, Encodeable};
73use crate::games::supported_games::{KEY_THREE_KINGDOMS, KEY_WARHAMMER_3};
74use crate::utils::check_size_mismatch;
75
76/// Matched combat files go under these folders.
77pub const BASE_PATHS: [&str; 3] = ["animations/matched_combat", "animations/database/matched", "animations/database/trigger"];
78
79/// Extension of MatchedCombat files.
80pub const EXTENSION: &str = ".bin";
81
82mod versions;
83
84#[cfg(test)] mod matched_combat_test;
85
86//---------------------------------------------------------------------------//
87// Enum & Structs
88//---------------------------------------------------------------------------//
89
90/// Represents a matched combat file decoded in memory.
91///
92/// Contains the format version and a list of combat animation entries.
93#[derive(PartialEq, Clone, Debug, Default, Getters, Setters, Serialize, Deserialize)]
94#[getset(get = "pub", set = "pub")]
95pub struct MatchedCombat {
96 /// File format version (1 or 3).
97 version: u32,
98
99 /// List of matched combat animation entries.
100 entries: Vec<MatchedEntry>,
101}
102
103/// A single matched combat animation entry.
104///
105/// Defines a synchronized combat sequence involving one or more participants,
106/// each with their own animations and state transitions.
107#[derive(PartialEq, Clone, Debug, Default, Getters, Setters, Serialize, Deserialize)]
108#[getset(get = "pub", set = "pub")]
109pub struct MatchedEntry {
110 /// Unique identifier for this matched combat entry.
111 id: String,
112
113 /// List of participants involved in this combat sequence.
114 ///
115 /// Typically 2 participants for duels, but can be more for group animations.
116 participants: Vec<Participant>,
117}
118
119/// A participant in a matched combat animation sequence.
120///
121/// Represents one unit/entity involved in the combat, including which animations
122/// it should play, what conditions must be met, and how its state changes.
123#[derive(PartialEq, Clone, Debug, Default, Getters, Setters, Serialize, Deserialize)]
124#[getset(get = "pub", set = "pub")]
125pub struct Participant {
126 /// Team identifier for this participant (e.g., 0 for attacker, 1 for defender).
127 team: u32,
128
129 /// Bundles of animation entities with selection weights.
130 ///
131 /// Multiple bundles allow for animation variety - the game randomly selects
132 /// one bundle based on weights.
133 entity_info: Vec<EntityBundle>,
134
135 /// State transition for this participant (e.g., alive to dead).
136 state: State,
137
138 /// Unknown field from Three Kingdoms files.
139 uk1: u32,
140
141 /// Unknown field from Three Kingdoms files.
142 uk2: u32,
143
144 /// Unknown field from Warhammer 3 files.
145 uk3: u32,
146
147 /// Unknown field from Warhammer 3 files.
148 uk4: u32,
149}
150
151/// A bundle of animation entities that can be selected together.
152///
153/// Allows the game to randomly choose between different animation variations
154/// based on selection weights, providing visual variety in combat.
155#[derive(PartialEq, Clone, Debug, Default, Getters, Setters, Serialize, Deserialize)]
156#[getset(get = "pub", set = "pub")]
157pub struct EntityBundle {
158 /// Animation entities in this bundle.
159 entities: Vec<Entity>,
160
161 /// Weight for random selection of this bundle (higher = more likely to be chosen).
162 selection_weight: f32,
163}
164
165/// An entity's animation data for matched combat.
166///
167/// Specifies the animation files, metadata, timing, equipment visibility,
168/// and conditions (filters) for when this animation can be used.
169#[derive(PartialEq, Clone, Debug, Default, Getters, Setters, Serialize, Deserialize)]
170#[getset(get = "pub", set = "pub")]
171pub struct Entity {
172 /// Path to the animation file for this entity.
173 animation_filename: String,
174
175 /// Paths to metadata files containing additional animation data.
176 metadata_filenames: Vec<String>,
177
178 /// Time in seconds for blending into this animation from the previous state.
179 blend_in_time: f32,
180
181 /// Equipment display flags controlling weapon/shield visibility during animation.
182 equipment_display: u32,
183
184 /// Filters that must match for this animation to be eligible for selection.
185 ///
186 /// Filters check conditions like unit type, equipment, size, etc.
187 filters: Vec<Filter>,
188
189 /// Unknown field.
190 uk: u32,
191
192 /// Animation filename for the mount (only in Warhammer 3 files).
193 ///
194 /// Used when the participant is mounted on a creature or horse.
195 mount_filename: String,
196}
197
198/// A filter condition for determining when an animation can be used.
199///
200/// Filters check properties of the participating units (e.g., unit type, equipment,
201/// size class) to ensure animations only play when appropriate.
202#[derive(PartialEq, Clone, Debug, Default, Getters, Setters, Serialize, Deserialize)]
203#[getset(get = "pub", set = "pub")]
204pub struct Filter {
205 /// If true, check for equality; if false, check for inequality.
206 equals: bool,
207
208 /// If true, combine with previous filter using OR; if false, use AND.
209 or: bool,
210
211 /// Type of filter (e.g., unit type, equipment slot, size class).
212 filter_type: u32,
213
214 /// Value to compare against (interpretation depends on filter_type).
215 value: String,
216}
217
218/// State transition for a participant during the matched combat sequence.
219///
220/// Defines how the participant's state changes from the start to the end of the animation
221/// (e.g., alive to dead for an execution animation, alive to alive for a non-lethal clash).
222#[derive(PartialEq, Clone, Debug, Default, Getters, Setters, Serialize, Deserialize)]
223#[getset(get = "pub", set = "pub")]
224pub struct State {
225 /// State at the beginning of the animation.
226 start: StateParticipant,
227
228 /// State at the end of the animation.
229 end: StateParticipant,
230}
231
232/// Possible states for a participant in a matched combat animation.
233///
234/// Indicates whether the participant is alive, dead, or in some other state
235/// at the beginning or end of the animation sequence.
236#[derive(PartialEq, Clone, Copy, Debug, Default, Serialize, Deserialize)]
237#[repr(u32)]
238pub enum StateParticipant {
239 /// Participant is alive and active.
240 #[default] Alive,
241
242 /// Participant is dead or dying.
243 Dead = 1,
244
245 /// Unknown state variant.
246 NoIdea1 = 2,
247
248 /// Unknown state variant.
249 NoIdea2 = 3,
250
251 /// Unknown state variant.
252 NoIdea3 = 4,
253
254 /// Unknown state variant.
255 NoIdea4 = 5,
256
257 /// Unknown state variant.
258 NoIdea5 = 6,
259}
260
261//---------------------------------------------------------------------------//
262// Implementation of MatchedCombat
263//---------------------------------------------------------------------------//
264
265impl Decodeable for MatchedCombat {
266
267 fn decode<R: ReadBytes>(data: &mut R, extra_data: &Option<DecodeableExtraData>) -> Result<Self> {
268 let extra_data = extra_data.as_ref().ok_or(RLibError::DecodingMissingExtraData)?;
269 let game_info = extra_data.game_info.ok_or_else(|| RLibError::DecodingMissingExtraDataField("game_info".to_owned()))?;
270
271 let mut matched = Self::default();
272 matched.version = data.read_u32()?;
273
274 match matched.version {
275 1 => match game_info.key() {
276 KEY_WARHAMMER_3 => matched.read_v1_wh3(data)?,
277 KEY_THREE_KINGDOMS => matched.read_v1_3k(data)?,
278 _ => Err(RLibError::DecodingMatchedCombatUnsupportedVersion(matched.version as usize))?,
279 }
280 3 => matched.read_v3(data)?,
281 _ => Err(RLibError::DecodingMatchedCombatUnsupportedVersion(matched.version as usize))?,
282 }
283
284 // If we are not in the last byte, it means we didn't parse the entire file, which means this file is corrupt.
285 check_size_mismatch(data.stream_position()? as usize, data.len()? as usize)?;
286
287 Ok(matched)
288 }
289}
290
291impl Encodeable for MatchedCombat {
292
293 fn encode<W: WriteBytes>(&mut self, buffer: &mut W, extra_data: &Option<EncodeableExtraData>) -> Result<()> {
294 let extra_data = extra_data.as_ref().ok_or(RLibError::EncodingMissingExtraData)?;
295 let game_info = extra_data.game_info.ok_or_else(|| RLibError::DecodingMissingExtraDataField("game_info".to_owned()))?;
296
297 buffer.write_u32(self.version)?;
298
299 match self.version {
300 1 => match game_info.key() {
301 KEY_WARHAMMER_3 => self.write_v1_wh3(buffer)?,
302 KEY_THREE_KINGDOMS => self.write_v1_3k(buffer)?,
303 _ => Err(RLibError::DecodingMatchedCombatUnsupportedVersion(self.version as usize))?,
304 }
305 3 => self.write_v3(buffer)?,
306 _ => Err(RLibError::DecodingMatchedCombatUnsupportedVersion(self.version as usize))?,
307 };
308
309 Ok(())
310 }
311}
312
313impl TryFrom<u32> for StateParticipant {
314 type Error = RLibError;
315
316 fn try_from(value: u32) -> std::result::Result<Self, Self::Error> {
317 match value {
318 _ if value == Self::Alive as u32 => Ok(Self::Alive),
319 _ if value == Self::Dead as u32 => Ok(Self::Dead),
320 _ if value == Self::NoIdea1 as u32 => Ok(Self::NoIdea1),
321 _ if value == Self::NoIdea2 as u32 => Ok(Self::NoIdea2),
322 _ if value == Self::NoIdea3 as u32 => Ok(Self::NoIdea3),
323 _ if value == Self::NoIdea4 as u32 => Ok(Self::NoIdea4),
324 _ if value == Self::NoIdea5 as u32 => Ok(Self::NoIdea5),
325 _ => Err(RLibError::InvalidStateParticipantValue(value)),
326 }
327 }
328}