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}