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                            definition.edit_fields(|fields| raw_extra_relationships.relationships.iter()
265                                .filter(|relation| relation.table_name == name)
266                                .for_each(|relation| {
267                                    if let Some(field) = fields.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                        let mut unused_patches = DefinitionPatch::new();
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                                unused_patches.insert(raw_field.name.to_string(), hashmap);
288                            }
289                        }
290
291                        definition.set_patches(unused_patches);
292
293                        // Lookup patches are collected apart, as the processed fields they're based on borrow the definition.
294                        let mut lookup_patches = DefinitionPatch::new();
295
296                        // Update the patches with description data if found. We only support single-key tables for this.
297                        if raw_definition.fields.iter().any(|x| x.name == "description") &&
298                            definition.fields().iter().all(|x| x.name() != "description") &&
299                            definition.localised_fields().iter().all(|x| x.name() != "description"){
300                            let fields_processed = definition.fields_processed();
301                            let mut data = vec![];
302
303                            // Calculate the key field. Here we may have problems with keys set by patches, so we do some... guessing.
304                            let key_field = fields_processed.iter().find(|x| x.is_key(Some(definition.patches())));
305                            let raw_key_field = raw_definition.fields.iter().find(|x| x.primary_key == "1");
306                            let key_field = if let Some(raw_key_field) = raw_key_field {
307                                Some(raw_key_field)
308                            } else if let Some(key_field) = key_field {
309                                raw_definition.fields.iter().find(|x| x.name == key_field.name())
310                            } else {
311                                None
312                            };
313
314                            if let Some(raw_key_field) = key_field {
315                                if raw_definition.fields.iter().any(|x| x.name == "description") {
316                                    if let Ok(raw_table) = RawTable::read(raw_definition, ass_kit_path, *raw_db_version) {
317                                        for row in raw_table.rows {
318                                            if let Some(key_field) = row.fields.iter().find(|field| field.field_name == raw_key_field.name) {
319                                                if let Some(description_field) = row.fields.iter().find(|field| field.field_name == "description") {
320                                                    data.push(format!("{};;;;;{}", key_field.field_data, description_field.field_data));
321                                                }
322                                            }
323                                        }
324                                    }
325                                }
326                            }
327
328                            if !data.is_empty() {
329                                let key_field = fields_processed.iter().find(|x| x.is_key(Some(definition.patches()))).unwrap();
330                                let mut hashmap = HashMap::new();
331                                hashmap.insert("lookup_hardcoded".to_owned(), data.join(":::::"));
332
333                                lookup_patches.insert(key_field.name().to_string(), hashmap);
334                            }
335                        }
336
337                        // In older games, names_tables type and gender need hardcoded lookups to bypass an issue with mismatching types between ak and game files.
338                        if table_name == "names_tables" {
339                            let fields_processed = definition.fields_processed();
340
341                            if let Some(field) = fields_processed.iter().find(|x| x.name() == "type") {
342                                if *field.field_type() == FieldType::I32 {
343                                    let mut hashmap = HashMap::new();
344                                    let data = [
345                                        String::from("0;;;;;forename"),
346                                        String::from("1;;;;;family_name"),
347                                        String::from("2;;;;;clan_name"),
348                                        String::from("3;;;;;other")
349                                    ];
350
351                                    hashmap.insert("lookup_hardcoded".to_owned(), data.join(":::::"));
352                                    lookup_patches.insert(field.name().to_string(), hashmap);
353                                }
354                            }
355
356                            if let Some(field) = fields_processed.iter().find(|x| x.name() == "gender") {
357                                if *field.field_type() == FieldType::I32 {
358                                    let mut hashmap = HashMap::new();
359                                    let data = [
360                                        String::from("0;;;;;m"),
361                                        String::from("1;;;;;f"),
362                                        String::from("2;;;;;b"),
363                                    ];
364
365                                    hashmap.insert("lookup_hardcoded".to_owned(), data.join(":::::"));
366                                    lookup_patches.insert(field.name().to_string(), hashmap);
367                                }
368                            }
369                        }
370
371                        definition.edit_patches(|patches| patches.extend(lookup_patches));
372                    }
373                }
374            }
375        }
376
377        unfound_fields
378    }).collect::<Vec<String>>();
379
380    // Sort and remove the known non-exported ones.
381    unfound_fields.sort();
382    unfound_fields.retain(|table| !game_info.ak_lost_fields().contains(table));
383
384    info!("Update from raw: fields still not found :{unfound_fields:#?}");
385
386    schema.save(schema_path)?;
387
388    let mut unfound_hash: HashMap<String, Vec<String>> = HashMap::new();
389    for un in &unfound_fields {
390        let split = un.split('/').collect::<Vec<_>>();
391        if split.len() == 2 {
392            match unfound_hash.get_mut(split[0]) {
393                Some(fields) => fields.push(split[1].to_string()),
394                None => { unfound_hash.insert(split[0].to_string(), vec![split[1].to_string()]); }
395            }
396        }
397    }
398
399    Ok(Some(unfound_hash))
400}
401
402//---------------------------------------------------------------------------//
403// Utility functions to process raw files from the Assembly Kit.
404//---------------------------------------------------------------------------//
405
406/// Returns paths to all table definition files in an Assembly Kit directory.
407///
408/// This function scans the provided directory and returns paths to XML files containing
409/// table structure definitions, filtering by Assembly Kit version.
410///
411/// # Arguments
412///
413/// * `current_path` - Directory containing Assembly Kit definition files
414/// * `version` - Assembly Kit version (0, 1, or 2)
415///
416/// # Returns
417///
418/// Returns a sorted vector of paths to definition files, or an error if the directory cannot be read.
419///
420/// # File Selection
421///
422/// - **Version 0** (Empire/Napoleon): `.xsd` files
423/// - **Version 1/2** (Shogun 2+): Files starting with `TWaD_` (excluding ignored metadata files)
424pub fn get_raw_definition_paths(current_path: &Path, version: i16) -> Result<Vec<PathBuf>> {
425
426    let mut file_list: Vec<PathBuf> = vec![];
427    match read_dir(current_path) {
428        Ok(files_in_current_path) => {
429            for file in files_in_current_path {
430                match file {
431                    Ok(file) => {
432                        let file_path = file.path();
433                        let file_name = file_path.file_stem().unwrap().to_str().unwrap();
434                        if (
435                            (version == 1 || version == 2) &&
436                            file_path.is_file() &&
437                            file_name.starts_with(RAW_DEFINITION_NAME_PREFIX_V2) &&
438                            !file_name.starts_with("TWaD_TExc") &&
439                            !RAW_DEFINITION_IGNORED_FILES_V2.contains(&file_name)
440                        ) || (
441                            version == 0 &&
442                            file_path.is_file() &&
443                            file_path.extension().unwrap() == RAW_DEFINITION_EXTENSION_V0
444                        ) {
445                            file_list.push(file_path);
446                        }
447                    }
448                    Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
449                }
450            }
451        }
452        Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
453    }
454
455    // Sort the files alphabetically.
456    file_list.sort();
457    Ok(file_list)
458}
459
460
461/// Returns paths to all table data files in an Assembly Kit directory.
462///
463/// This function scans for XML files containing sample table data (as opposed to definitions).
464///
465/// # Arguments
466///
467/// * `current_path` - Directory containing Assembly Kit data files
468/// * `version` - Assembly Kit version (0, 1, or 2)
469///
470/// # Returns
471///
472/// Returns a sorted vector of paths to data files, or an error if the directory cannot be read.
473///
474/// # File Selection
475///
476/// - **Version 0** (Empire/Napoleon): XML files without `.xsd` extension
477/// - **Version 1/2** (Shogun 2+): XML files that don't start with `TWaD_`
478pub fn get_raw_data_paths(current_path: &Path, version: i16) -> Result<Vec<PathBuf>> {
479
480    let mut file_list: Vec<PathBuf> = vec![];
481    match read_dir(current_path) {
482        Ok(files_in_current_path) => {
483            for file in files_in_current_path {
484                match file {
485                    Ok(file) => {
486                        let file_path = file.path();
487                        let file_name = file_path.file_stem().unwrap().to_str().unwrap();
488                        if version == 1 || version == 2 {
489                            if file_path.is_file() && !file_name.starts_with(RAW_DEFINITION_NAME_PREFIX_V2) {
490                                file_list.push(file_path);
491                            }
492                        }
493
494                        else if version == 0 &&
495                            file_path.is_file() &&
496                            !file_name.ends_with(RAW_DEFINITION_EXTENSION_V0) {
497                            file_list.push(file_path);
498                        }
499                    }
500                    Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
501                }
502            }
503        }
504        Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
505    }
506
507    // Sort the files alphabetically.
508    file_list.sort();
509    Ok(file_list)
510}
511
512/// Returns the path to the localisable fields metadata file.
513///
514/// This file (`TExc_LocalisableFields.xml`) contains the list of fields that should be
515/// extracted to `.loc` translation files.
516///
517/// # Arguments
518///
519/// * `current_path` - Directory containing Assembly Kit files
520/// * `version` - Assembly Kit version (1 or 2; version 0 is not supported)
521///
522/// # Returns
523///
524/// Returns the path to the localisable fields file, or an error if not found.
525///
526/// # Note
527///
528/// This file is optional in some Assembly Kits (notably absent in Warhammer 2).
529pub fn get_raw_localisable_fields_path(current_path: &Path, version: i16) -> Result<PathBuf> {
530    match read_dir(current_path) {
531        Ok(files_in_current_path) => {
532            for file in files_in_current_path {
533                match file {
534                    Ok(file) => {
535                        let file_path = file.path();
536                        let file_name = file_path.file_stem().unwrap().to_str().unwrap();
537                        if (version == 1 || version == 2) && file_path.is_file() && file_name == LOCALISABLE_FILES_FILE_NAME_V2 {
538                            return Ok(file_path)
539                        }
540                    }
541                    Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
542                }
543            }
544        }
545        Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
546    }
547
548    // If we didn't find the file, return an error.
549    Err(RLibError::AssemblyKitLocalisableFieldsNotFound)
550}
551
552/// Returns the path to the extra relationships metadata file.
553///
554/// This file (`TWaD_relationships.xml`) contains foreign key relationship information
555/// that isn't embedded in the table definition files themselves.
556///
557/// # Arguments
558///
559/// * `current_path` - Directory containing Assembly Kit files
560/// * `version` - Assembly Kit version (1 or 2; version 0 is not supported)
561///
562/// # Returns
563///
564/// Returns the path to the relationships file, or an error if not found.
565///
566/// # Note
567///
568/// This file is optional and may not be present in all Assembly Kits.
569pub fn get_raw_extra_relationships_path(current_path: &Path, version: i16) -> Result<PathBuf> {
570    match read_dir(current_path) {
571        Ok(files_in_current_path) => {
572            for file in files_in_current_path {
573                match file {
574                    Ok(file) => {
575                        let file_path = file.path();
576                        let file_name = file_path.file_stem().unwrap().to_str().unwrap();
577                        if (version == 1 || version == 2) && file_path.is_file() && file_name == EXTRA_RELATIONSHIPS_TABLE_NAME {
578                            return Ok(file_path)
579                        }
580                    }
581                    Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
582                }
583            }
584        }
585        Err(_) => return Err(RLibError::ReadFileFolderError(current_path.to_string_lossy().to_string())),
586    }
587
588    // If we didn't find the file, return an error.
589    Err(RLibError::AssemblyKitExtraRelationshipsNotFound)
590}