Skip to main content

rpfm_lib/games/
manifest.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//! Game manifest file parsing for vanilla PackFile discovery.
12//!
13//! This module handles parsing of the `manifest.txt` file found in Total War game
14//! data directories. The manifest lists all official game files with their sizes,
15//! allowing RPFM to identify which PackFiles are vanilla (from Creative Assembly)
16//! versus user-created mods.
17//!
18//! # Manifest File Format
19//!
20//! The `manifest.txt` is a tab-delimited file in the game's `/data` directory:
21//! ```text
22//! data/local_en.pack<TAB>12345678
23//! data/units.pack<TAB>98765432<TAB>1
24//! ```
25//!
26//! Columns:
27//! 1. **Relative path** - File path relative to `/data` directory
28//! 2. **Size** - File size in bytes
29//! 3. **Base game flag** (optional, newer games only) - `1` if base game, `0` if DLC
30//!
31//! # Usage
32//!
33//! The manifest is primarily used to:
34//! - Identify vanilla PackFiles for loading as game data
35//! - Distinguish between base game and DLC content
36//! - Validate file integrity (via size checking)
37//! - Filter out mod files when building dependency trees
38//!
39//! # Fallback Behavior
40//!
41//! Not all Total War games have manifest files (Empire, Napoleon don't). For these games,
42//! RPFM falls back to hardcoded lists in the game's install data.
43//!
44//! # Example
45//!
46//! ```ignore
47//! use rpfm_lib::games::manifest::Manifest;
48//! use rpfm_lib::games::supported_games::{SupportedGames, KEY_WARHAMMER_3};
49//! use std::path::Path;
50//!
51//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
52//! let games = SupportedGames::default();
53//! let game = games.game(&KEY_WARHAMMER_3).unwrap();
54//! let game_path = Path::new("/path/to/game");
55//!
56//! // Read manifest from game
57//! let manifest = Manifest::read_from_game_path(game, game_path)?;
58//!
59//! // Check if a file is listed in manifest
60//! let is_vanilla = manifest.is_path_in_manifest(Path::new("data/local_en.pack"));
61//! # Ok(())
62//! # }
63//! ```
64
65use csv::ReaderBuilder;
66use getset::*;
67use serde_derive::Deserialize;
68
69use std::fs::canonicalize;
70use std::path::{Path, PathBuf};
71
72use crate::error::{RLibError, Result};
73use super::GameInfo;
74
75/// Name of the manifest file in game data directories
76const MANIFEST_FILE_NAME: &str = "manifest.txt";
77
78//-------------------------------------------------------------------------------//
79//                              Enums & Structs
80//-------------------------------------------------------------------------------//
81
82/// Complete parsed manifest file from a game's data directory.
83///
84/// Contains all entries from `manifest.txt`, representing the full list of
85/// official game files.
86///
87/// # Structure
88///
89/// This is a wrapper around a vector of [`ManifestEntry`] items, one per line
90/// in the manifest file.
91#[derive(Deserialize)]
92pub struct Manifest(pub Vec<ManifestEntry>);
93
94/// Single file entry from a game manifest.
95///
96/// Represents one line in `manifest.txt`, describing a single game file.
97///
98/// # Format Compatibility
99///
100/// Different Total War games use slightly different manifest formats:
101/// - **Older games**: 2 columns (path, size)
102/// - **Newer games**: 3 columns (path, size, base_game_flag)
103///
104/// The parser handles both formats automatically.
105#[derive(Default, Getters, Deserialize)]
106#[getset(get = "pub")]
107pub struct ManifestEntry {
108
109    /// File path relative to the game's `/data` directory.
110    ///
111    /// Example: `"local_en.pack"` or `"boot.pack"`
112    relative_path: String,
113
114    /// File size in bytes.
115    ///
116    /// Can be used for file integrity validation.
117    size: u64,
118
119    /// Base game vs DLC flag (newer games only).
120    ///
121    /// - `Some(1)`: File is part of base game (always present)
122    /// - `Some(0)`: File is from DLC (may be missing)
123    /// - `None`: Game doesn't use this field (older manifest format)
124    belongs_to_base_game: Option<u8>,
125}
126
127//-------------------------------------------------------------------------------//
128//                             Implementations
129//-------------------------------------------------------------------------------//
130
131
132/// Implementation of `Manifest`.
133impl Manifest {
134
135    /// Reads and parses the manifest file for a specific game installation.
136    ///
137    /// This is a convenience wrapper that constructs the manifest path from game
138    /// information and reads it.
139    ///
140    /// # Arguments
141    ///
142    /// * `game` - Game configuration containing path information
143    /// * `game_path` - Root directory of the game installation
144    ///
145    /// # Returns
146    ///
147    /// Returns the parsed [`Manifest`] containing all file entries.
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if:
152    /// - The game's data path cannot be determined
153    /// - The manifest file doesn't exist
154    /// - The manifest file is malformed or cannot be parsed
155    ///
156    /// # Example
157    ///
158    /// ```ignore
159    /// # use rpfm_lib::games::manifest::Manifest;
160    /// # use rpfm_lib::games::supported_games::{SupportedGames, KEY_WARHAMMER_3};
161    /// # use std::path::Path;
162    /// # fn example() -> Result<(), Box<dyn std::error::Error>> {
163    /// # let games = SupportedGames::default();
164    /// # let game = games.game(&KEY_WARHAMMER_3).unwrap();
165    /// let game_path = Path::new("/path/to/game");
166    /// let manifest = Manifest::read_from_game_path(game, game_path)?;
167    /// # Ok(())
168    /// # }
169    /// ```
170    pub fn read_from_game_path(game: &GameInfo, game_path: &Path) -> Result<Self> {
171        let manifest_path = game.data_path(game_path)?.join(MANIFEST_FILE_NAME);
172        Self::read(&manifest_path)
173    }
174
175    /// Reads and parses a manifest file from a specific path.
176    ///
177    /// Parses a `manifest.txt` file in tab-delimited format. Handles both 2-column
178    /// (older games) and 3-column (newer games) formats automatically.
179    ///
180    /// # Arguments
181    ///
182    /// * `manifest_path` - Path to the `manifest.txt` file
183    ///
184    /// # Returns
185    ///
186    /// Returns the parsed [`Manifest`] containing all file entries.
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if:
191    /// - The file cannot be opened or read
192    /// - The format is invalid (wrong number of columns)
193    /// - Field values cannot be parsed (e.g., non-numeric size)
194    ///
195    /// # Format Details
196    ///
197    /// Expected format (tab-delimited):
198    /// ```text
199    /// path/to/file.pack<TAB>1234567
200    /// path/to/other.pack<TAB>9876543<TAB>1
201    /// ```
202    pub fn read(manifest_path: &Path) -> Result<Self> {
203
204        let mut reader = ReaderBuilder::new()
205            .delimiter(b'\t')
206            .quoting(false)
207            .has_headers(false)
208            .flexible(true)
209            .from_path(manifest_path)?;
210
211        // Due to "flexible" not actually working when doing serde-backed deserialization (took some time to figure this out)
212        // the deserialization has to be done manually.
213        let mut entries = vec![];
214        for record in reader.records() {
215            let record = record?;
216
217            // We only know these manifest formats.
218            if record.len() != 2 && record.len() != 3 {
219                return Err(RLibError::ManifestFileParseError("Mismatch column count".to_owned()));
220            } else {
221                let mut manifest_entry = ManifestEntry {
222                    relative_path: record.get(0).ok_or_else(|| RLibError::ManifestFileParseError("Error reading relative path".to_owned()))?.to_owned(),
223                    size: record.get(1).ok_or_else(|| RLibError::ManifestFileParseError("Error reading size".to_owned()))?.parse()?,
224                    ..Default::default()
225                };
226
227                // In newer games, a third field has been added.
228                if record.len() == 3 {
229                    manifest_entry.belongs_to_base_game = record.get(2).ok_or_else(|| RLibError::ManifestFileParseError("Error reading if file belongs to the base game".to_owned()))?.parse().ok();
230                }
231                else {
232                    manifest_entry.belongs_to_base_game = None;
233                }
234
235                entries.push(manifest_entry);
236            }
237        }
238
239        let manifest = Self(entries);
240        Ok(manifest)
241    }
242
243    /// Checks if a file path is listed in the manifest.
244    ///
245    /// Performs case-insensitive comparison and handles path separator differences
246    /// (backslash vs forward slash).
247    ///
248    /// # Arguments
249    ///
250    /// * `path` - Path to check (can be absolute or relative)
251    ///
252    /// # Returns
253    ///
254    /// Returns `true` if the path ends with any relative path in the manifest.
255    ///
256    /// # Example
257    ///
258    /// ```no_run
259    /// # use rpfm_lib::games::manifest::Manifest;
260    /// # use std::path::Path;
261    /// # fn example(manifest: &Manifest) {
262    /// let is_vanilla = manifest.is_path_in_manifest(
263    ///     Path::new("C:/Games/Warhammer3/data/local_en.pack")
264    /// );
265    /// # }
266    /// ```
267    pub fn is_path_in_manifest(&self, path: &Path) -> bool {
268        let insensitivized_path = path.to_str().unwrap().to_lowercase().replace('\\', "/");
269        self.0.iter().any(|x| insensitivized_path.ends_with(&x.relative_path.to_lowercase()))
270    }
271}
272
273impl ManifestEntry {
274
275    /// Validates and canonicalizes a path based on manifest entry metadata.
276    ///
277    /// Determines if a file path should be used based on whether it's from the base
278    /// game or DLC, and whether the file actually exists on disk.
279    ///
280    /// # Arguments
281    ///
282    /// * `path` - File path to validate
283    ///
284    /// # Returns
285    ///
286    /// Returns `Some(canonical_path)` if the file should be used:
287    /// - Base game files (`belongs_to_base_game == 1`): Always returned
288    /// - DLC files (`belongs_to_base_game == 0`): Only if file exists on disk
289    /// - Unknown origin (`belongs_to_base_game == None`): Only if file exists
290    ///
291    /// Returns `None` if the file is a missing DLC file.
292    ///
293    /// # Path Canonicalization
294    ///
295    /// If the path is valid, it's canonicalized to an absolute path before returning.
296    pub fn path_from_manifest_entry(&self, path: PathBuf) -> Option<PathBuf> {
297        match self.belongs_to_base_game() {
298            Some(value) => {
299                if *value == 1 || path.is_file() {
300                    canonicalize(path).ok()
301                } else {
302                    None
303                }
304            },
305            None => canonicalize(path).ok(),
306        }
307    }
308}