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}