Skip to main content

rpfm_lib/files/animpack/
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//! AnimPack container file format support.
12//!
13//! AnimPacks (`.animpack` files) are container files that bundle animation-related game
14//! data into a single archive. They are primarily used to organize and distribute animation
15//! assets for units and characters in Total War games.
16//!
17//! # File Format
18//!
19//! AnimPacks use a simple binary format with a file count header followed by a list of
20//! embedded files. Each embedded file includes its path, size, and raw data.
21//!
22//! ```text
23//! [u32] file_count
24//! For each file:
25//!   [u8 + string] file_path (with backslashes)
26//!   [u32] file_size
27//!   [bytes] file_data
28//! ```
29//!
30//! # Contained File Types
31//!
32//! AnimPacks typically contain:
33//! - [`AnimsTable`] - Animation table indices
34//! - [`AnimFragmentBattle`] - Animation fragments
35//! - [`MatchedCombat`] - Matched combat definitions
36//! - Other animation-related binary files
37//!
38//! [`AnimsTable`]: crate::files::anims_table::AnimsTable
39//! [`AnimFragmentBattle`]: crate::files::anim_fragment_battle::AnimFragmentBattle
40//! [`MatchedCombat`]: crate::files::matched_combat::MatchedCombat
41//!
42//! # File Location
43//!
44//! AnimPacks are usually found in:
45//! ```text
46//! animations/*.animpack
47//! ```
48//!
49//! # Usage
50//!
51//! ```ignore
52//! use rpfm_lib::files::animpack::AnimPack;
53//! use rpfm_lib::files::{Container, Decodeable};
54//!
55//! // Decode an AnimPack from disk
56//! let mut extra_data = DecodeableExtraData::default();
57//! extra_data.set_disk_file_path(Some("animations/unit.animpack"));
58//! extra_data.set_data_size(file_size);
59//! extra_data.set_timestamp(timestamp);
60//!
61//! let animpack = AnimPack::decode(&mut reader, &Some(extra_data))?;
62//!
63//! // Access contained files
64//! for (path, file) in animpack.files() {
65//!     println!("File: {}", path);
66//! }
67//!
68//! // Extract a specific file
69//! if let Some(file) = animpack.file_by_path("battle/animations/humanoid01.bin") {
70//!     let data = file.encode(&None, false, false, true)?;
71//! }
72//! ```
73//!
74//! # Version Support
75//!
76//! Complete support for all known AnimPack versions across Total War games.
77
78use serde_derive::{Serialize, Deserialize};
79
80use std::collections::HashMap;
81use std::path::PathBuf;
82use std::str::FromStr;
83
84use crate::binary::{ReadBytes, WriteBytes};
85use crate::error::Result;
86use crate::files::*;
87
88/// File extension for AnimPack files.
89///
90/// AnimPacks use the `.animpack` extension to distinguish them from other
91/// container formats like PackFiles (`.pack`).
92pub const EXTENSION: &str = ".animpack";
93
94#[cfg(test)] mod animpack_test;
95
96//---------------------------------------------------------------------------//
97//                              Enum & Structs
98//---------------------------------------------------------------------------//
99
100/// Represents an AnimPack file decoded in memory.
101///
102/// AnimPacks are container files that bundle animation-related assets into a single
103/// archive. This struct holds the complete AnimPack structure including metadata and
104/// all contained files.
105///
106/// # Fields
107///
108/// - `disk_file_path`: Path to the AnimPack file on disk (empty if in-memory only)
109/// - `disk_file_offset`: Byte offset within the disk file (0 if standalone file)
110/// - `local_timestamp`: Last modified timestamp for change detection
111/// - `paths`: Lowercase path lookup cache for case-insensitive file searches
112/// - `files`: Map of file paths to their [`RFile`] data
113///
114/// # Binary Format
115///
116/// | Bytes          | Type                         | Data                                    |
117/// | -------------- | ---------------------------- | --------------------------------------- |
118/// | 4              | [u32]                        | File Count                              |
119/// | X * File Count | [File](#file-structure) List | List of files inside the AnimPack File  |
120///
121/// ## File Structure
122///
123/// | Bytes       | Type           | Data                  |
124/// | ----------- | -------------- | --------------------- |
125/// | *           | Sized StringU8 | File Path             |
126/// | 4           | [u32]          | File Length in bytes  |
127/// | File Length | &\[[u8]\]      | File Data             |
128///
129/// # Container Implementation
130///
131/// AnimPack implements the [`Container`] trait, providing:
132/// - File extraction and insertion
133/// - Case-insensitive path lookup via paths cache
134/// - Lazy loading support (when unencrypted)
135/// - Timestamp-based change detection
136///
137/// # Lazy Loading
138///
139/// When `lazy_load` is enabled in [`DecodeableExtraData`], file data is not read
140/// immediately but loaded on-demand. This reduces memory usage for large AnimPacks.
141/// Lazy loading requires:
142/// - Valid `disk_file_path` to a file on disk
143/// - Unencrypted data (encrypted files are always fully loaded)
144///
145/// # Example
146///
147/// ```ignore
148/// use rpfm_lib::files::animpack::AnimPack;
149/// use rpfm_lib::files::Container;
150///
151/// // Create a new empty AnimPack
152/// let mut animpack = AnimPack::default();
153///
154/// // Add a file
155/// animpack.insert(rfile, "battle/animations/unit.bin")?;
156///
157/// // Look up a file (case-insensitive)
158/// if let Some(file) = animpack.file_by_path("BATTLE/animations/UNIT.bin") {
159///     println!("Found file!");
160/// }
161/// ```
162#[derive(PartialEq, Clone, Debug, Default, Serialize, Deserialize)]
163pub struct AnimPack {
164
165    /// Path to this AnimPack file on disk.
166    ///
167    /// If the AnimPack has not been saved to disk or exists only in memory,
168    /// this is an empty string.
169    disk_file_path: String,
170
171    /// Byte offset of this AnimPack within its disk file.
172    ///
173    /// If the AnimPack is a standalone file (not embedded in another file),
174    /// this is 0.
175    disk_file_offset: u64,
176
177    /// Last modified timestamp of the disk file in seconds.
178    ///
179    /// Used to detect external modifications when lazy loading is enabled.
180    /// Set to 0 for in-memory AnimPacks.
181    local_timestamp: u64,
182
183    /// Case-insensitive path lookup cache.
184    ///
185    /// Maps lowercase file paths to a list of their original-cased variants.
186    /// This enables fast case-insensitive file lookups via [`Container::file_by_path()`].
187    paths: HashMap<String, Vec<String>>,
188
189    /// Map of file paths to their data.
190    ///
191    /// Keys are file paths as stored in the AnimPack (with forward slashes).
192    /// Values are [`RFile`] instances containing the file data (cached or lazy-loaded).
193    files: HashMap<String, RFile>,
194}
195
196//---------------------------------------------------------------------------//
197//                           Implementation of AnimPack
198//---------------------------------------------------------------------------//
199
200impl Container for AnimPack {
201
202    fn disk_file_path(&self) -> &str {
203       &self.disk_file_path
204    }
205
206    fn files(&self) -> &HashMap<String, RFile> {
207        &self.files
208    }
209
210    fn files_mut(&mut self) -> &mut HashMap<String, RFile> {
211        &mut self.files
212    }
213
214    fn disk_file_offset(&self) -> u64 {
215       self.disk_file_offset
216    }
217
218    fn paths_cache(&self) -> &HashMap<String, Vec<String>> {
219        &self.paths
220    }
221
222    fn paths_cache_mut(&mut self) -> &mut HashMap<String, Vec<String>> {
223        &mut self.paths
224    }
225
226    fn local_timestamp(&self) -> u64 {
227        self.local_timestamp
228    }
229}
230
231impl Decodeable for AnimPack {
232
233    /// Decodes an AnimPack from a binary data source.
234    ///
235    /// # Parameters
236    ///
237    /// - `data`: Binary reader implementing [`ReadBytes`]
238    /// - `extra_data`: Required decoding context (see below)
239    ///
240    /// # Required Extra Data Fields
241    ///
242    /// This implementation requires [`DecodeableExtraData`] with:
243    /// - `lazy_load`: Enable lazy loading (ignored if encrypted)
244    /// - `is_encrypted`: Whether the AnimPack data is encrypted
245    /// - `disk_file_path`: Path to file on disk (required for lazy loading)
246    /// - `disk_file_offset`: Offset within disk file (0 for standalone files)
247    /// - `data_size`: Total size of AnimPack data in bytes
248    /// - `timestamp`: Last modified timestamp in seconds (0 for in-memory)
249    ///
250    /// # Returns
251    ///
252    /// - `Ok(AnimPack)`: Successfully decoded AnimPack with all files
253    /// - `Err(_)`: I/O error, malformed data, or missing required extra data
254    ///
255    /// # Lazy Loading Behavior
256    ///
257    /// When `lazy_load` is true and data is unencrypted:
258    /// - File metadata is read immediately
259    /// - File data is loaded on-demand when accessed
260    /// - Requires valid `disk_file_path` to a file on disk
261    ///
262    /// When encrypted or lazy loading disabled:
263    /// - All file data is read into memory immediately
264    ///
265    /// # Example
266    ///
267    /// ```ignore
268    /// use std::fs::File;
269    /// use std::io::BufReader;
270    /// use rpfm_lib::binary::ReadBytes;
271    /// use rpfm_lib::files::{Decodeable, DecodeableExtraData, animpack::AnimPack};
272    /// use rpfm_lib::utils::last_modified_time_from_file;
273    ///
274    /// let path = "animations/unit.animpack";
275    /// let mut reader = BufReader::new(File::open(path)?);
276    ///
277    /// let mut extra_data = DecodeableExtraData::default();
278    /// extra_data.set_disk_file_path(Some(path));
279    /// extra_data.set_data_size(reader.len()?);
280    /// extra_data.set_timestamp(last_modified_time_from_file(reader.get_ref())?);
281    ///
282    /// let animpack = AnimPack::decode(&mut reader, &Some(extra_data))?;
283    /// ```
284    fn decode<R: ReadBytes>(data: &mut R, extra_data: &Option<DecodeableExtraData>) -> Result<Self> {
285        let extra_data = extra_data.as_ref().ok_or(RLibError::DecodingMissingExtraData)?;
286
287        // If we're reading from a file on disk, we require a valid path.
288        // If we're reading from a file on memory, we don't need a valid path.
289        let disk_file_path = match extra_data.disk_file_path {
290            Some(path) => {
291                let file_path = PathBuf::from_str(path).map_err(|_|RLibError::DecodingMissingExtraDataField("disk_file_path".to_owned()))?;
292                if file_path.is_file() {
293                    path.to_owned()
294                } else {
295                    return Err(RLibError::DecodingMissingExtraData)
296                }
297            }
298            None => String::new()
299        };
300
301        let disk_file_offset = extra_data.disk_file_offset;
302        let disk_file_size = extra_data.data_size;
303        let local_timestamp = extra_data.timestamp;
304        let is_encrypted = extra_data.is_encrypted;
305
306        // If we don't have a path, or the file is encrypted, we can't lazy-load.
307        let lazy_load = !disk_file_path.is_empty() && !is_encrypted && extra_data.lazy_load;
308        let file_count = data.read_u32()?;
309
310        let mut anim_pack = Self {
311            disk_file_path,
312            disk_file_offset,
313            local_timestamp,
314            paths: HashMap::new(),
315            files: if file_count < 50_000 { HashMap::with_capacity(file_count as usize) } else { HashMap::new() },
316        };
317
318        for _ in 0..file_count {
319            let path_in_container = data.read_sized_string_u8()?.replace('\\', "/");
320            let size = data.read_u32()?;
321
322            // Encrypted files cannot be lazy-loaded. They must be read in-place.
323            if !lazy_load || is_encrypted {
324                let data = data.read_slice(size as usize, false)?;
325                let file = RFile {
326                    path: path_in_container.to_owned(),
327                    timestamp: None,
328                    file_type: FileType::AnimPack,
329                    container_name: None,
330                    data: RFileInnerData::Cached(data),
331                };
332
333                anim_pack.files.insert(path_in_container, file);
334            }
335
336            // Unencrypted and files are not read, but lazy-loaded, unless specified otherwise.
337            else {
338                let data_pos = data.stream_position()? - disk_file_offset;
339                let file = RFile::new_from_container(&anim_pack, size as u64, false, None, data_pos, local_timestamp, &path_in_container)?;
340                data.seek(SeekFrom::Current(size as i64))?;
341
342                anim_pack.files.insert(path_in_container, file);
343            }
344        }
345
346        anim_pack.paths_cache_generate();
347
348        anim_pack.files.par_iter_mut().map(|(_, file)| file.guess_file_type()).collect::<Result<()>>()?;
349
350        check_size_mismatch(data.stream_position()? as usize - anim_pack.disk_file_offset as usize, disk_file_size as usize)?;
351        Ok(anim_pack)
352    }
353}
354
355impl Encodeable for AnimPack {
356
357    /// Encodes this AnimPack to a binary data stream.
358    ///
359    /// # Parameters
360    ///
361    /// - `buffer`: Binary writer implementing [`WriteBytes`]
362    /// - `extra_data`: Encoding options (not used, pass [`None`])
363    ///
364    /// # Returns
365    ///
366    /// - `Ok(())`: Successfully encoded AnimPack
367    /// - `Err(_)`: I/O error, file too large, or encoding error
368    ///
369    /// # Encoding Behavior
370    ///
371    /// - Files are sorted alphabetically by path (case-insensitive)
372    /// - Paths use forward slashes (`/`) not backslashes (`\`)
373    /// - Each file is encoded inline with its size prefix
374    /// - Files larger than 4GB (u32::MAX) return an error
375    ///
376    /// # Path Format
377    ///
378    /// **Important**: Encoded paths use forward slashes because animation sets created
379    /// by assed tool break if backslashes are used.
380    ///
381    /// # Example
382    ///
383    /// ```ignore
384    /// use std::fs::File;
385    /// use std::io::{BufWriter, Write};
386    /// use rpfm_lib::files::{Encodeable, animpack::AnimPack};
387    ///
388    /// let mut animpack = AnimPack::default();
389    /// // ... add files to animpack ...
390    ///
391    /// let mut encoded = vec![];
392    /// animpack.encode(&mut encoded, &None)?;
393    ///
394    /// // Write to disk
395    /// let mut writer = BufWriter::new(File::create("output.animpack")?);
396    /// writer.write_all(&encoded)?;
397    /// ```
398    fn encode<W: WriteBytes>(&mut self, buffer: &mut W, extra_data: &Option<EncodeableExtraData>) -> Result<()> {
399        buffer.write_u32(self.files.len() as u32)?;
400
401        // NOTE: This has to use /, not \, because for some reason the animsets made by Assed break if we use \.
402        let mut sorted_files = self.files.iter_mut().collect::<Vec<(&String, &mut RFile)>>();
403        sorted_files.sort_unstable_by_key(|(path, _)| path.to_lowercase());
404
405        for (path, file) in sorted_files {
406            buffer.write_sized_string_u8(path)?;
407
408            let data = file.encode(extra_data, false, false, true)?.unwrap();
409
410            // Error on files too big for the AnimPack.
411            if data.len() > u32::MAX as usize {
412                return Err(RLibError::DataTooBigForContainer("AnimPack".to_owned(), u32::MAX as u64, data.len(), path.to_owned()));
413            }
414
415            buffer.write_u32(data.len() as u32)?;
416            buffer.write_all(&data)?;
417        }
418
419        Ok(())
420    }
421}