Skip to main content

rpfm_lib/integrations/assembly_kit/
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//! Assembly Kit integration for importing official table definitions.
12//!
13//! This module provides functionality to parse and import table structure information from
14//! Creative Assembly's official Assembly Kit tools. This allows RPFM's schemas to stay
15//! synchronized with the official game data formats.
16//!
17//! # Assembly Kit Versions
18//!
19//! Different Total War games use different Assembly Kit formats:
20//!
21//! - **Version 0**: Empire Total War and Napoleon Total War
22//!   - Uses `.xsd` XML schema files
23//!   - Simpler structure without relationship metadata
24//!
25//! - **Version 1**: Shogun 2 Total War
26//!   - Uses XML files with `TWaD_` prefix
27//!   - Includes localisable field information
28//!
29//! - **Version 2**: Rome 2 and later (including Warhammer series, Three Kingdoms, Troy, etc.)
30//!   - Enhanced XML format with full relationship metadata
31//!   - Separate relationship and localisable fields files
32//!   - Field-level metadata (descriptions, highlight flags for unused fields)
33//!
34//! # Main Functionality
35//!
36//! ## Schema Updates
37//!
38//! The primary function [`update_schema_from_raw_files()`] processes Assembly Kit files to:
39//! - Update field types, keys, and default values
40//! - Import foreign key relationships
41//! - Detect localisable (translatable) fields
42//! - Mark unused fields (via highlight flags)
43//! - Extract hardcoded lookup data from description fields
44//!
45//! ## File Parsing
46//!
47//! The module can parse several Assembly Kit file types:
48//! - **Table definitions** (`TWaD_*.xml` or `*.xsd`): Field structure and types
49//! - **Localisable fields** (`TExc_LocalisableFields.xml`): Translation-ready fields
50//! - **Relationships** (`TWaD_relationships.xml`): Foreign key relationships
51//! - **Table data**: Sample data for generating hardcoded lookups
52//!
53//! # Submodules
54//!
55//! - [`table_definition`]: XML parsing for table structure definitions
56//! - [`table_data`]: XML parsing for table sample data
57//! - [`localisable_fields`]: XML parsing for localisable field lists
58//!
59//! # Example Usage
60//!
61//! ```ignore
62//! use rpfm_lib::integrations::assembly_kit::update_schema_from_raw_files;
63//! use rpfm_lib::schema::Schema;
64//! use rpfm_lib::games::supported_games::{SupportedGames, KEY_WARHAMMER_3};
65//! use std::path::Path;
66//! use std::collections::HashMap;
67//!
68//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
69//! let mut schema = Schema::load(Path::new("schemas/warhammer_3.ron"), None)?;
70//! let supported_games = SupportedGames::default();
71//! let game_info = supported_games.game(&KEY_WARHAMMER_3).unwrap();
72//! let ass_kit_path = Path::new("C:/Program Files/Steam/steamapps/common/Total War WARHAMMER III/assembly_kit");
73//! let schema_path = Path::new("schemas/warhammer_3.ron");
74//! let tables_to_check = HashMap::new(); // Load vanilla tables here
75//!
76//! let unfound_fields = update_schema_from_raw_files(
77//!     &mut schema,
78//!     game_info,
79//!     ass_kit_path,
80//!     schema_path,
81//!     &[],
82//!     &tables_to_check,
83//! )?;
84//! # Ok(())
85//! # }
86//! ```
87
88use log::info;
89use rayon::prelude::*;
90use serde_xml_rs::from_reader;
91
92use std::collections::HashMap;
93use std::fs::{File, read_dir};
94use std::io::BufReader;
95use std::path::{Path, PathBuf};
96
97use crate::error::{Result, RLibError};
98use crate::games::GameInfo;
99use crate::files::db::DB;
100use crate::schema::*;
101
102use self::localisable_fields::RawLocalisableFields;
103use self::table_data::RawTable;
104use self::table_definition::{RawDefinition, RawRelationshipsTable};
105
106pub mod localisable_fields;
107pub mod table_data;
108pub mod table_definition;
109
110/// Filename of the localisable fields XML file in Assembly Kit v2+.
111const LOCALISABLE_FILES_FILE_NAME_V2: &str = "TExc_LocalisableFields";
112
113/// File prefix for table definition XMLs in Assembly Kit v2+.
114const RAW_DEFINITION_NAME_PREFIX_V2: &str = "TWaD_";
115
116/// Definition files to ignore (metadata files, not actual tables).
117const RAW_DEFINITION_IGNORED_FILES_V2: [&str; 5] = [
118    "TWaD_schema_validation",
119    "TWaD_relationships",
120    "TWaD_validation",
121    "TWaD_tables",
122    "TWaD_queries",
123];
124
125/// File extension for table definition files in Assembly Kit v0 (Empire/Napoleon).
126const RAW_DEFINITION_EXTENSION_V0: &str = "xsd";
127
128/// Files that should not be processed as tables.
129///
130/// These are excluded because:
131/// - `translated_texts.xml`: Contains only translation data, not table structure
132/// - `TWaD_form_descriptions.xml`: UI form description, not a data table
133/// - `GroupFormation.xsd`: Special formation data
134/// - `TExc_Effects.xsd`: Effects metadata
135const BLACKLISTED_TABLES: [&str; 4] = ["translated_texts.xml", "TWaD_form_descriptions.xml", "GroupFormation.xsd", "TExc_Effects.xsd"];
136
137/// Filename for the extra relationships metadata file.
138///
139/// This file contains foreign key relationships not embedded in the table definitions.
140const EXTRA_RELATIONSHIPS_TABLE_NAME: &str = "TWaD_relationships";
141
142#[cfg(test)] mod test_table_data;
143#[cfg(test)] mod test_table_definition;
144
145//---------------------------------------------------------------------------//
146// Functions to process the Raw DB Tables from the Assembly Kit.
147//---------------------------------------------------------------------------//
148
149/// Updates an existing schema with metadata from Assembly Kit files.
150///
151/// This function parses Assembly Kit XML files and updates the provided schema with:
152/// - Field types, keys, and constraints
153/// - Foreign key relationships
154/// - Localisable field information
155/// - Unused field markers (via highlight flags)
156/// - Hardcoded lookup data extracted from description columns
157///
158/// # Arguments
159///
160/// * `schema` - The schema to update (modified in place)
161/// * `game_info` - Game-specific information (includes Assembly Kit version)
162/// * `ass_kit_path` - Path to the Assembly Kit installation directory
163/// * `schema_path` - Path where the updated schema should be saved
164/// * `tables_to_skip` - List of table names to ignore during import
165/// * `tables_to_check` - Map of table names to vanilla DB files for version detection
166///
167/// # Returns
168///
169/// Returns `Some(HashMap)` containing tables and fields that couldn't be matched,
170/// or `None` if all fields were successfully imported.
171///
172/// # Important Notes
173///
174/// - This function **does not create new table definitions**, it only updates existing ones
175/// - Only the current version of each table is updated (not historical versions)
176/// - Localisable fields are properly separated into the `localised_fields` list
177/// - The schema is automatically saved to disk after updates
178/// - Fields listed in the game's `ak_lost_fields` are not reported as unfound
179///
180/// # Errors
181///
182/// Returns an error if:
183/// - The Assembly Kit version is unsupported
184/// - XML files cannot be parsed
185/// - The schema cannot be saved
186///
187/// # Example
188///
189/// ```ignore
190/// # use rpfm_lib::integrations::assembly_kit::update_schema_from_raw_files;
191/// # use rpfm_lib::schema::Schema;
192/// # use rpfm_lib::games::supported_games::{SupportedGames, KEY_WARHAMMER_3};
193/// # use std::path::Path;
194/// # use std::collections::HashMap;
195/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
196/// let mut schema = Schema::load(Path::new("schemas/warhammer_3.ron"), None)?;
197/// let supported_games = SupportedGames::default();
198/// let game_info = supported_games.game(&KEY_WARHAMMER_3).unwrap();
199///
200/// let unfound = update_schema_from_raw_files(
201///     &mut schema,
202///     game_info,
203///     Path::new("C:/Program Files/Steam/.../assembly_kit"),
204///     Path::new("schemas/warhammer_3.ron"),
205///     &[], // No tables to skip
206///     &HashMap::new(), // Vanilla tables
207/// )?;
208///
209/// if let Some(unfound_fields) = unfound {
210///     println!("Could not match {} tables", unfound_fields.len());
211/// }
212/// # Ok(())
213/// # }
214/// ```
215pub fn update_schema_from_raw_files(
216    schema: &mut Schema,
217    game_info: &GameInfo,
218    ass_kit_path: &Path,
219    schema_path: &Path,
220    tables_to_skip: &[&str],
221    tables_to_check: &HashMap<String, Vec<DB>>
222) -> Result<Option<HashMap<String, Vec<String>>>> {
223
224    // This has to do a different process depending on the `raw_db_version`.
225    let raw_db_version = game_info.raw_db_version();
226    let (raw_definitions, raw_localisable_fields, raw_extra_relationships) = match raw_db_version {
227        2 | 1 => {
228
229            // This one is notably missing in Warhammer 2, so it's optional.
230            let raw_localisable_fields: Option<RawLocalisableFields> =
231                if let Ok(file_path) = get_raw_localisable_fields_path(ass_kit_path, *raw_db_version) {
232                    let file = BufReader::new(File::open(file_path)?);
233                    from_reader(file).ok()
234                } else { None };
235
236            // Same, this is optional.
237            let raw_extra_relationships: Option<RawRelationshipsTable> =
238                if let Ok(file_path) = get_raw_extra_relationships_path(ass_kit_path, *raw_db_version) {
239                    let file = BufReader::new(File::open(file_path)?);
240                    from_reader(file).ok()
241                } else { None };
242
243            (RawDefinition::read_all(ass_kit_path, *raw_db_version, tables_to_skip)?, raw_localisable_fields, raw_extra_relationships)
244        }
245
246        // For these ones, we expect the path to point to the folder with each game's table folder.
247        0 => (RawDefinition::read_all(ass_kit_path, *raw_db_version, tables_to_skip)?, None, None),
248        _ => return Err(RLibError::AssemblyKitUnsupportedVersion(*raw_db_version)),
249    };
250
251    let mut unfound_fields = schema.definitions_mut().par_iter_mut().flat_map(|(table_name, definitions)| {
252        let name = &table_name[0..table_name.len() - 7];
253        let mut unfound_fields = vec![];
254        if let Some(raw_definition) = raw_definitions.iter().filter(|x| x.name.is_some()).find(|x| &(x.name.as_ref().unwrap())[0..x.name.as_ref().unwrap().len() - 4] == name) {
255
256            // We need to get the version from the vanilla files to know what definition to update.
257            if let Some(vanilla_tables) = tables_to_check.get(table_name) {
258                for vanilla_table in vanilla_tables {
259                    if let Some(definition) = definitions.iter_mut().find(|x| x.version() == vanilla_table.definition().version()) {
260                        definition.update_from_raw_definition(raw_definition, &mut unfound_fields);
261
262                        // Check in the extra relationships for missing relations.
263                        if let Some(ref raw_extra_relationships) = raw_extra_relationships {
264                            raw_extra_relationships.relationships.iter()
265                                .filter(|relation| relation.table_name == name)
266                                .for_each(|relation| {
267                                    if let Some(field) = definition.fields_mut().iter_mut().find(|x| x.name() == relation.column_name) {
268                                        field.set_is_reference(Some((relation.foreign_table_name.to_owned(), relation.foreign_column_name.to_owned())));
269                                    }
270                                }
271                            );
272                        }
273
274                        if let Some(ref raw_localisable_fields) = raw_localisable_fields {
275                            definition.update_from_raw_localisable_fields(raw_definition, &raw_localisable_fields.fields)
276                        }
277
278                        // Not the best way to do it, but it works.
279                        definition.patches_mut().clear();
280
281                        // Add unused field info.
282                        for raw_field in &raw_definition.fields {
283                            if raw_field.highlight_flag.is_some() && raw_field.highlight_flag.clone().unwrap() == "#c8c8c8" {
284                                let mut hashmap = HashMap::new();
285                                hashmap.insert("unused".to_owned(), "true".to_owned());
286
287                                definition.patches_mut().insert(raw_field.name.to_string(), hashmap);
288                            }
289                        }
290
291                        // Update the patches with description data if found. We only support single-key tables for this.
292                        if raw_definition.fields.iter().any(|x| x.name == "description") &&
293                            definition.fields().iter().all(|x| x.name() != "description") &&
294                            definition.localised_fields().iter().all(|x| x.name() != "description"){
295                            let fields_processed = definition.fields_processed();
296                            let mut data = vec![];
297
298                            // Calculate the key field. Here we may have problems with keys set by patches, so we do some... guessing.
299                            let key_field = fields_processed.iter().find(|x| x.is_key(Some(definition.patches())));
300                            let raw_key_field = raw_definition.fields.iter().find(|x| x.primary_key == "1");
301                            let key_field = if let Some(raw_key_field) = raw_key_field {
302                                Some(raw_key_field)
303                            } else if let Some(key_field) = key_field {
304                                raw_definition.fields.iter().find(|x| x.name == key_field.name())
305                            } else {
306                                None
307                            };
308
309                            if let Some(raw_key_field) = key_field {
310                                if raw_definition.fields.iter().any(|x| x.name == "description") {
311                                    if let Ok(raw_table) = RawTable::read(raw_definition, ass_kit_path, *raw_db_version) {
312                                        for row in raw_table.rows {
313                                            if let Some(key_field) = row.fields.iter().find(|field| field.field_name == raw_key_field.name) {
314                                                if let Some(description_field) = row.fields.iter().find(|field| field.field_name == "description") {
315                                                    data.push(format!("{};;;;;{}", key_field.field_data, description_field.field_data));
316                                                }
317                                            }
318                                        }
319                                    }
320                                }
321                            }
322
323                            if !data.is_empty() {
324                                let key_field = fields_processed.iter().find(|x| x.is_key(Some(definition.patches()))).unwrap();
325                                let mut hashmap = HashMap::new();
326                                hashmap.insert("lookup_hardcoded".to_owned(), data.join(":::::"));
327
328                                definition.patches_mut().insert(key_field.name().to_string(), hashmap);
329                            }
330                        }
331
332                        // In older games, names_tables type and gender need hardcoded lookups to bypass an issue with mismatching types between ak and game files.
333                        if table_name == "names_tables" {
334                            let fields_processed = definition.fields_processed();
335
336                            if let Some(field) = fields_processed.iter().find(|x| x.name() == "type") {
337                                if *field.field_type() == FieldType::I32 {
338                                    let mut hashmap = HashMap::new();
339                                    let data = [
340                                        String::from("0;;;;;forename"),
341                                        String::from("1;;;;;family_name"),
342                                        String::from("2;;;;;clan_name"),
343                                        String::from("3;;;;;other")
344                                    ];
345
346                                    hashmap.insert("lookup_hardcoded".to_owned(), data.join(":::::"));
347                                    definition.patches_mut().insert(field.name().to_string(), hashmap);
348                                }
349                            }
350
351                            if let Some(field) = fields_processed.iter().find(|x| x.name() == "gender") {
352                                if *field.field_type() == FieldType::I32 {
353                                    let mut hashmap = HashMap::new();
354                                    let data = [
355                                        String::from("0;;;;;m"),
356                                        String::from("1;;;;;f"),
357                                        String::from("2;;;;;b"),
358                                    ];
359
360                                    hashmap.insert("lookup_hardcoded".to_owned(), data.join(":::::"));
361                                    definition.patches_mut().insert(field.name().to_string(), hashmap);
362                                }
363                            }
364                        }
365                    }
366                }
367            }
368        }
369
370        unfound_fields
371    }).collect::<Vec<String>>();
372
373    // Sort and remove the known non-exported ones.
374    unfound_fields.sort();
375    unfound_fields.retain(|table| !game_info.ak_lost_fields().contains(table));
376
377    info!("Update from raw: fields still not found :{unfound_fields:#?}");
378
379    schema.save(schema_path)?;
380
381    let mut unfound_hash: HashMap<String, Vec<String>> = HashMap::new();
382    for un in &unfound_fields {
383        let split = un.split('/').collect::<Vec<_>>();
384        if split.len() == 2 {
385            match unfound_hash.get_mut(split[0]) {
386                Some(fields) => fields.push(split[1].to_string()),
387                None => { unfound_hash.insert(split[0].to_string(), vec![split[1].to_string()]); }
388            }
389        }
390    }
391
392    Ok(Some(unfound_hash))
393}
394
395//---------------------------------------------------------------------------//
396// Utility functions to process raw files from the Assembly Kit.
397//---------------------------------------------------------------------------//
398
399/// Returns paths to all table definition files in an Assembly Kit directory.
400///
401/// This function scans the provided directory and returns paths to XML files containing
402/// table structure definitions, filtering by Assembly Kit version.
403///
404/// # Arguments
405///
406/// * `current_path` - Directory containing Assembly Kit definition files
407/// * `version` - Assembly Kit version (0, 1, or 2)
408///
409/// # Returns
410///
411/// Returns a sorted vector of paths to definition files, or an error if the directory cannot be read.
412///
413/// # File Selection
414///
415/// - **Version 0** (Empire/Napoleon): `.xsd` files
416/// - **Version 1/2** (Shogun 2+): Files starting with `TWaD_` (excluding ignored metadata files)
417pub fn get_raw_definition_paths(current_path: &Path, version: i16) -> Result<Vec<PathBuf>> {
418
419    let mut file_list: Vec<PathBuf> = vec![];
420    match read_dir(current_path) {
421        Ok(files_in_current_path) => {
422            for file in files_in_current_path {
423                match file {
424                    Ok(file) => {
425                        let file_path = file.path();
426                        let file_name = file_path.file_stem().unwrap().to_str().unwrap();
427                        if (
428                            (version == 1 || version == 2) &&
429                            file_path.is_file() &&
430                            file_name.starts_with(RAW_DEFINITION_NAME_PREFIX_V2) &&
431                            !file_name.starts_with("TWaD_TExc") &&
432                            !RAW_DEFINITION_IGNORED_FILES_V2.contains(&file_name)
433                        ) || (
434                            version == 0 &&
435                            file_path.is_file() &&
436                            file_path.extension().unwrap() == RAW_DEFINITION_EXTENSION_V0
437                        ) {
438                            file_list.push(file_path);
439                        }
440                    }
441                    Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
442                }
443            }
444        }
445        Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
446    }
447
448    // Sort the files alphabetically.
449    file_list.sort();
450    Ok(file_list)
451}
452
453
454/// Returns paths to all table data files in an Assembly Kit directory.
455///
456/// This function scans for XML files containing sample table data (as opposed to definitions).
457///
458/// # Arguments
459///
460/// * `current_path` - Directory containing Assembly Kit data files
461/// * `version` - Assembly Kit version (0, 1, or 2)
462///
463/// # Returns
464///
465/// Returns a sorted vector of paths to data files, or an error if the directory cannot be read.
466///
467/// # File Selection
468///
469/// - **Version 0** (Empire/Napoleon): XML files without `.xsd` extension
470/// - **Version 1/2** (Shogun 2+): XML files that don't start with `TWaD_`
471pub fn get_raw_data_paths(current_path: &Path, version: i16) -> Result<Vec<PathBuf>> {
472
473    let mut file_list: Vec<PathBuf> = vec![];
474    match read_dir(current_path) {
475        Ok(files_in_current_path) => {
476            for file in files_in_current_path {
477                match file {
478                    Ok(file) => {
479                        let file_path = file.path();
480                        let file_name = file_path.file_stem().unwrap().to_str().unwrap();
481                        if version == 1 || version == 2 {
482                            if file_path.is_file() && !file_name.starts_with(RAW_DEFINITION_NAME_PREFIX_V2) {
483                                file_list.push(file_path);
484                            }
485                        }
486
487                        else if version == 0 &&
488                            file_path.is_file() &&
489                            !file_name.ends_with(RAW_DEFINITION_EXTENSION_V0) {
490                            file_list.push(file_path);
491                        }
492                    }
493                    Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
494                }
495            }
496        }
497        Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
498    }
499
500    // Sort the files alphabetically.
501    file_list.sort();
502    Ok(file_list)
503}
504
505/// Returns the path to the localisable fields metadata file.
506///
507/// This file (`TExc_LocalisableFields.xml`) contains the list of fields that should be
508/// extracted to `.loc` translation files.
509///
510/// # Arguments
511///
512/// * `current_path` - Directory containing Assembly Kit files
513/// * `version` - Assembly Kit version (1 or 2; version 0 is not supported)
514///
515/// # Returns
516///
517/// Returns the path to the localisable fields file, or an error if not found.
518///
519/// # Note
520///
521/// This file is optional in some Assembly Kits (notably absent in Warhammer 2).
522pub fn get_raw_localisable_fields_path(current_path: &Path, version: i16) -> Result<PathBuf> {
523    match read_dir(current_path) {
524        Ok(files_in_current_path) => {
525            for file in files_in_current_path {
526                match file {
527                    Ok(file) => {
528                        let file_path = file.path();
529                        let file_name = file_path.file_stem().unwrap().to_str().unwrap();
530                        if (version == 1 || version == 2) && file_path.is_file() && file_name == LOCALISABLE_FILES_FILE_NAME_V2 {
531                            return Ok(file_path)
532                        }
533                    }
534                    Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
535                }
536            }
537        }
538        Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
539    }
540
541    // If we didn't find the file, return an error.
542    Err(RLibError::AssemblyKitLocalisableFieldsNotFound)
543}
544
545/// Returns the path to the extra relationships metadata file.
546///
547/// This file (`TWaD_relationships.xml`) contains foreign key relationship information
548/// that isn't embedded in the table definition files themselves.
549///
550/// # Arguments
551///
552/// * `current_path` - Directory containing Assembly Kit files
553/// * `version` - Assembly Kit version (1 or 2; version 0 is not supported)
554///
555/// # Returns
556///
557/// Returns the path to the relationships file, or an error if not found.
558///
559/// # Note
560///
561/// This file is optional and may not be present in all Assembly Kits.
562pub fn get_raw_extra_relationships_path(current_path: &Path, version: i16) -> Result<PathBuf> {
563    match read_dir(current_path) {
564        Ok(files_in_current_path) => {
565            for file in files_in_current_path {
566                match file {
567                    Ok(file) => {
568                        let file_path = file.path();
569                        let file_name = file_path.file_stem().unwrap().to_str().unwrap();
570                        if (version == 1 || version == 2) && file_path.is_file() && file_name == EXTRA_RELATIONSHIPS_TABLE_NAME {
571                            return Ok(file_path)
572                        }
573                    }
574                    Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
575                }
576            }
577        }
578        Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
579    }
580
581    // If we didn't find the file, return an error.
582    Err(RLibError::AssemblyKitExtraRelationshipsNotFound)
583}