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}