Skip to main content

rpfm_extensions/startpos/
validation.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//! Validation of the tables a startpos is built from, to report why a build would fail before running it.
12//!
13//! When the game builds a startpos from broken data it either quits without a startpos, hangs forever, or silently
14//! drops rows, and logs nothing. This module finds those problems up front:
15//!
16//! - Every reference of the `start_pos_*` tables (as the schema defines them) must point to an existing row.
17//!   Broken references in [`FATAL_REFERENCES`] make the game quit or hang, so they're errors; the rest make it drop
18//!   the row or ignore the value, so they're warnings.
19//! - The campaign must exist.
20//! - Characters in the generals pool can't also be placed on the map with an army.
21//! - Progenitors can't adopt children under [`ADULT_AGE`]: the game gives the adopted child the progenitor's family
22//!   name through the child's character, which children don't have yet while the families are linked, and crashes.
23//! - Data the game drops: names whose `names.type` isn't their column's kind, technologies outside the faction's
24//!   technology tree, and the non-aggression pacts and military access of vassals with other factions.
25//!
26//! The outcomes of broken references were measured on Attila, one broken reference at a time.
27
28use std::collections::{HashMap, HashSet};
29use std::fmt;
30
31use rpfm_lib::schema::{Definition, Schema};
32
33use super::cai::technologies::faction_technologies;
34use super::tables::{Row, StartposTables};
35use super::world::{CampaignIndex, NAME_COLUMNS};
36
37#[cfg(test)] #[path = "validation_test.rs"] mod validation_test;
38
39/// Columns (table without the `_tables` suffix, column) whose broken references make the game quit or hang while
40/// building the startpos. `Surname`, `clan_name` and `other_name` are assumed to behave like `Name`.
41pub const FATAL_REFERENCES: [(&str, &str); 22] = [
42    ("start_pos_characters", "faction"),
43    ("start_pos_characters", "Type"),
44    ("start_pos_characters", "Name"),
45    ("start_pos_characters", "Surname"),
46    ("start_pos_characters", "clan_name"),
47    ("start_pos_characters", "other_name"),
48    ("start_pos_characters", "political_party"),
49    ("start_pos_characters", "override_general_unit"),
50    ("start_pos_characters", "death_type"),
51    ("start_pos_character_traits", "trait_level"),
52    ("start_pos_land_units", "unit_type"),
53    ("start_pos_naval_units", "unit_type"),
54    ("start_pos_horde_details", "primary_building"),
55    ("start_pos_factions", "faction"),
56    ("start_pos_factions", "government_type"),
57    ("start_pos_factions", "state_religion"),
58    ("start_pos_factions", "cai_personality_group"),
59    ("start_pos_factions", "cdir_military_generator_config"),
60    ("start_pos_regions", "region"),
61    ("start_pos_regions", "cultural_originator"),
62    ("start_pos_settlements", "region"),
63    ("start_pos_settlements", "primary_building"),
64];
65
66/// Age from which characters have a character while the game links their families.
67pub const ADULT_AGE: f64 = 16.0;
68
69/// Stance of the `start_pos_diplomacy` rows that make `faction1` a vassal of `faction2`.
70const VASSAL_STANCE: &str = "vassal";
71
72/// Prefix of the tables whose references are checked.
73const STARTPOS_TABLE_PREFIX: &str = "start_pos_";
74
75//---------------------------------------------------------------------------//
76//                              Types
77//---------------------------------------------------------------------------//
78
79/// How bad an issue is.
80#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
81pub enum Severity {
82
83    /// The build fails or hangs.
84    Error,
85
86    /// The build works, but drops or ignores data.
87    Warning,
88}
89
90/// A problem in the data a startpos is built from.
91#[derive(Debug, Clone, PartialEq, Eq)]
92pub struct StartposIssue {
93
94    /// How bad it is.
95    pub severity: Severity,
96
97    /// Table, without the `_tables` suffix.
98    pub table: String,
99
100    /// Key values of the row, joined by `|`, or the row's position if the table has no keys.
101    pub row: String,
102
103    /// Column with the problem, if it's about one.
104    pub column: Option<String>,
105
106    /// What's wrong.
107    pub message: String,
108}
109
110//---------------------------------------------------------------------------//
111//                              Implementations
112//---------------------------------------------------------------------------//
113
114impl fmt::Display for StartposIssue {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        let severity = match self.severity {
117            Severity::Error => "Error",
118            Severity::Warning => "Warning",
119        };
120        match &self.column {
121            Some(column) => write!(f, "{severity}: {}, row {}, column {column}: {}", self.table, self.row, self.message),
122            None => write!(f, "{severity}: {}, row {}: {}", self.table, self.row, self.message),
123        }
124    }
125}
126
127/// Returns the tables the `start_pos_*` tables of a schema reference, without the `_tables` suffix.
128pub fn referenced_tables(schema: &Schema) -> HashSet<String> {
129    schema.definitions().iter()
130        .filter(|(name, _)| name.starts_with(STARTPOS_TABLE_PREFIX))
131        .filter_map(|(_, definitions)| latest(definitions))
132        .flat_map(|definition| definition.fields_processed().iter().filter_map(|field| field.is_reference(Some(definition.patches()))))
133        .map(|(table, _)| table)
134        .collect()
135}
136
137/// Validates the tables a campaign's startpos is built from.
138///
139/// # Arguments
140///
141/// * `tables` - The `start_pos_*` tables, and the tables they reference. References to tables that aren't there
142///   aren't checked.
143/// * `schema` - Schema with the references of the tables.
144/// * `campaign` - Campaign key.
145///
146/// # Returns
147///
148/// The issues found, errors first.
149pub fn validate(tables: &StartposTables, schema: &Schema, campaign: &str) -> Vec<StartposIssue> {
150    let mut issues = vec![];
151    validate_campaign(tables, campaign, &mut issues);
152    validate_references(tables, schema, &mut issues);
153    validate_generals_pool(tables, &mut issues);
154    validate_adoptions(tables, &mut issues);
155    validate_name_kinds(tables, &mut issues);
156    validate_technologies(tables, campaign, &mut issues);
157    validate_vassal_pacts(tables, &mut issues);
158    issues.sort_by_key(|issue| issue.severity);
159    issues
160}
161
162/// Checks the campaign is in the `campaigns` table.
163fn validate_campaign(tables: &StartposTables, campaign: &str, issues: &mut Vec<StartposIssue>) {
164    let exists = tables.table("campaigns").is_ok_and(|table| table.rows().any(|row| row.str("campaign_name").is_ok_and(|name| name == campaign)));
165    if !exists {
166        issues.push(StartposIssue {
167            severity: Severity::Error,
168            table: "campaigns".to_owned(),
169            row: campaign.to_owned(),
170            column: None,
171            message: format!("the campaign {campaign} isn't in the table, so the game skips the build."),
172        });
173    }
174}
175
176/// Checks the references of every `start_pos_*` table.
177fn validate_references(tables: &StartposTables, schema: &Schema, issues: &mut Vec<StartposIssue>) {
178    let mut values = HashMap::<(String, String), Option<HashSet<String>>>::new();
179    let mut names = schema.definitions().keys().filter(|name| name.starts_with(STARTPOS_TABLE_PREFIX)).collect::<Vec<_>>();
180    names.sort();
181
182    for name in names {
183        let table_name = name.trim_end_matches("_tables");
184        let (Ok(table), Some(definition)) = (tables.table(table_name), latest(&schema.definitions()[name])) else { continue };
185        let fields = definition.fields_processed();
186        let keys = fields.iter().filter(|field| field.is_key(Some(definition.patches()))).map(|field| field.name().to_owned()).collect::<Vec<_>>();
187
188        for field in fields.iter() {
189            let Some((target_table, target_column)) = field.is_reference(Some(definition.patches())) else { continue };
190            if table.column(field.name()).is_err() {
191                continue;
192            }
193            let targets = values.entry((target_table.to_owned(), target_column.to_owned()))
194                .or_insert_with(|| column_values(tables, &target_table, &target_column));
195            let Some(targets) = targets else { continue };
196
197            let fatal = FATAL_REFERENCES.contains(&(table_name, field.name()));
198            for (position, row) in table.rows().enumerate() {
199                let Ok(value) = row.str(field.name()) else { continue };
200                if value.is_empty() || targets.contains(value.as_ref()) {
201                    continue;
202                }
203
204                issues.push(StartposIssue {
205                    severity: if fatal { Severity::Error } else { Severity::Warning },
206                    table: table_name.to_owned(),
207                    row: row_name(&row, &keys, position),
208                    column: Some(field.name().to_owned()),
209                    message: format!("{value} isn't in {target_table}.{target_column}. {}", if fatal {
210                        "The game quits or hangs while building the startpos."
211                    } else {
212                        "The game drops the row or ignores the value."
213                    }),
214                });
215            }
216        }
217    }
218}
219
220/// Checks no character in the generals pool is also placed on the map with an army.
221fn validate_generals_pool(tables: &StartposTables, issues: &mut Vec<StartposIssue>) {
222    let Ok(characters) = tables.table("start_pos_characters") else { return };
223    let placed = column_values(tables, "start_pos_character_to_settlements", "character").unwrap_or_default();
224    let generals = column_values(tables, "start_pos_land_units", "general").unwrap_or_default();
225
226    for (position, row) in characters.rows().enumerate() {
227        let (Ok(id), Ok(true)) = (row.str("ID"), row.bool("is_in_generals_pool")) else { continue };
228        if placed.contains(id.as_ref()) && generals.contains(id.as_ref()) {
229            issues.push(StartposIssue {
230                severity: Severity::Error,
231                table: "start_pos_characters".to_owned(),
232                row: row_name(&row, &["ID".to_owned()], position),
233                column: Some("is_in_generals_pool".to_owned()),
234                message: "the character is in the generals pool, but also placed on the map with an army. The game quits while building the startpos.".to_owned(),
235            });
236        }
237    }
238}
239
240/// Checks no progenitor adopts a child under [`ADULT_AGE`].
241fn validate_adoptions(tables: &StartposTables, issues: &mut Vec<StartposIssue>) {
242    let (Ok(relationships), Ok(characters)) = (tables.table("start_pos_family_relationships"), tables.table("start_pos_characters")) else { return };
243    let mut ages = HashMap::new();
244    let mut progenitors = HashSet::new();
245    for row in characters.rows() {
246        let Ok(id) = row.str("ID") else { continue };
247        if let Ok(age) = row.number("Age") {
248            ages.insert(id.to_string(), age);
249        }
250        if row.bool("progenitor").unwrap_or_default() {
251            progenitors.insert(id.into_owned());
252        }
253    }
254
255    for (position, row) in relationships.rows().enumerate() {
256        let (Ok(child), Ok(parent)) = (row.str("character"), row.str("related_to")) else { continue };
257        let is_adoption = row.str("relationship").is_ok_and(|relationship| relationship == "child") && row.bool("adopted").unwrap_or_default();
258        let Some(age) = ages.get(child.as_ref()).filter(|age| **age < ADULT_AGE) else { continue };
259        if is_adoption && progenitors.contains(parent.as_ref()) {
260            issues.push(StartposIssue {
261                severity: Severity::Error,
262                table: "start_pos_family_relationships".to_owned(),
263                row: row_name(&row, &["character".to_owned(), "related_to".to_owned()], position),
264                column: Some("adopted".to_owned()),
265                message: format!("the progenitor {parent} adopts {child}, who is {age} years old. The game crashes when a progenitor adopts a child under {ADULT_AGE}."),
266            });
267        }
268    }
269}
270
271/// Checks the names of the characters have their column's kind (`names.type`). Missing names are broken references.
272fn validate_name_kinds(tables: &StartposTables, issues: &mut Vec<StartposIssue>) {
273    let (Ok(names), Ok(characters)) = (tables.table("names"), tables.table("start_pos_characters")) else { return };
274    let mut kinds = HashMap::<String, HashSet<i64>>::new();
275    for row in names.rows() {
276        if let (Ok(id), Ok(kind)) = (row.str("id"), row.int("type")) {
277            kinds.entry(id.into_owned()).or_default().insert(kind);
278        }
279    }
280
281    for (position, row) in characters.rows().enumerate() {
282        for (column, kind) in NAME_COLUMNS.iter().zip(0..) {
283            let Ok(name) = row.str(column) else { continue };
284            let Some(name_kinds) = kinds.get(name.as_ref()) else { continue };
285            if !name_kinds.contains(&kind) {
286                issues.push(StartposIssue {
287                    severity: Severity::Warning,
288                    table: "start_pos_characters".to_owned(),
289                    row: row_name(&row, &["ID".to_owned()], position),
290                    column: Some(column.to_string()),
291                    message: format!("the name {name} isn't of type {kind} in names, so the game drops it."),
292                });
293            }
294        }
295    }
296}
297
298/// Checks the technologies factions start with are in their technology tree.
299fn validate_technologies(tables: &StartposTables, campaign: &str, issues: &mut Vec<StartposIssue>) {
300    let (Ok(technologies), Ok(index)) = (tables.table("start_pos_technologies"), CampaignIndex::new(tables, campaign)) else { return };
301    let mut trees = HashMap::new();
302    for (position, row) in technologies.rows().enumerate() {
303        let (Ok(faction_id), Ok(technology)) = (row.str("faction"), row.str("technology")) else { continue };
304        let Some(faction) = index.faction_by_id(&faction_id) else { continue };
305        let tree = trees.entry(faction.key().to_owned()).or_insert_with(|| faction_technologies(tables, campaign, faction).unwrap_or_default());
306        if !tree.iter().any(|known| *known == technology) {
307            issues.push(StartposIssue {
308                severity: Severity::Warning,
309                table: "start_pos_technologies".to_owned(),
310                row: row_name(&row, &["faction".to_owned(), "technology".to_owned()], position),
311                column: Some("technology".to_owned()),
312                message: format!("{technology} isn't in the technology tree of {}, so the game drops it.", faction.key()),
313            });
314        }
315    }
316}
317
318/// Checks vassals don't have non-aggression pacts or military access with other factions.
319fn validate_vassal_pacts(tables: &StartposTables, issues: &mut Vec<StartposIssue>) {
320    let Ok(diplomacy) = tables.table("start_pos_diplomacy") else { return };
321    let vassals = diplomacy.rows()
322        .filter(|row| row.str("stance").is_ok_and(|stance| stance == VASSAL_STANCE))
323        .filter_map(|row| row.str("faction1").ok().map(|faction| faction.into_owned()))
324        .collect::<HashSet<_>>();
325
326    for (position, row) in diplomacy.rows().enumerate() {
327        let (Ok(first), Ok(second), Ok(stance)) = (row.str("faction1"), row.str("faction2"), row.str("stance")) else { continue };
328        if stance == VASSAL_STANCE || !(vassals.contains(first.as_ref()) || vassals.contains(second.as_ref())) {
329            continue;
330        }
331        for column in ["non_aggression_pact", "grants_military_access"] {
332            if row.bool(column).unwrap_or_default() {
333                issues.push(StartposIssue {
334                    severity: Severity::Warning,
335                    table: "start_pos_diplomacy".to_owned(),
336                    row: row_name(&row, &["key".to_owned()], position),
337                    column: Some(column.to_owned()),
338                    message: "one of the factions is a vassal, and vassals can't have non-aggression pacts or military access with other factions, so the game drops it.".to_owned(),
339                });
340            }
341        }
342    }
343}
344
345/// Returns the values of a column, or `None` if the table or column isn't there.
346fn column_values(tables: &StartposTables, table: &str, column: &str) -> Option<HashSet<String>> {
347    let table = tables.table(table).ok()?;
348    table.column(column).ok()?;
349    Some(table.rows().filter_map(|row| row.str(column).ok().map(|value| value.into_owned())).collect())
350}
351
352/// Returns the name of a row: its key values joined by `|`, or its position if it has no keys.
353fn row_name(row: &Row, keys: &[String], position: usize) -> String {
354    let values = keys.iter().filter_map(|key| row.str(key).ok().map(|value| value.into_owned())).collect::<Vec<_>>();
355    if values.is_empty() { format!("#{}", position + 1) } else { values.join("|") }
356}
357
358/// Returns the latest definition of a table.
359fn latest(definitions: &[Definition]) -> Option<&Definition> {
360    definitions.iter().max_by_key(|definition| *definition.version())
361}