Skip to main content

rpfm_extensions/startpos/extract/
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//! Extraction of a campaign's `start_pos_*` tables from its startpos: the inverse of the build.
12//!
13//! The extractor reads a startpos through the record layouts and rebuilds the rows it was built from, so a startpos
14//! without its tables can be edited again, and a generated startpos can be checked against the tables it came from
15//! (see [`compare`]).
16//!
17//! The build doesn't keep everything, so the extracted rows are the ones that build the same startpos:
18//!
19//! - Columns the build ignores (descriptions, multiplayer counts...) keep the table's default value.
20//! - A province's fertility, public order and development are sums over its regions: the sum goes to one region.
21//! - Horde buildings fill the first building columns, and garrison and bodyguard units aren't rows.
22//! - Faction and region ids are kept (the startpos has them in localisation keys). Other row ids are numbered from 1.
23//! - Lists the game stores in hash order (units, traits, technologies...) come out in startpos order.
24//!
25//! Besides the tables, the extraction gets back the victory objectives (`victory_objectives.txt`), which the build
26//! also reads. The settlements listed by loot and raze objectives aren't kept.
27//!
28//! # Submodules
29//!
30//! - [`compare`]: comparison of extracted tables with the tables a startpos was built from.
31
32use getset::Getters;
33
34use std::collections::{BTreeMap, HashMap};
35
36use rpfm_lib::error::{RLibError, Result};
37use rpfm_lib::files::db::DB;
38use rpfm_lib::files::esf::{NodeType, RecordNode, ESF};
39use rpfm_lib::files::table::DecodedData;
40use rpfm_lib::schema::Schema;
41
42use super::cai::CAI_WORLD_PATH;
43use super::layout::{child_path, GroupView, StartposLayout};
44use super::missions::{write_victory_objectives, VictoryObjectives};
45use super::tables::StartposTables;
46use super::world::writer::factions::FACTION_KEY_FIELDS;
47use super::world::writer::WORLD_PATH;
48
49pub mod compare;
50mod characters;
51mod factions;
52mod missions;
53mod regions;
54
55#[cfg(test)] pub(crate) mod extract_test;
56
57/// Path of the root record.
58const ROOT_PATH: &str = "CAMPAIGN_STARTPOS";
59
60/// Path of the pre-campaign map info.
61const PREOPEN_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_PREOPEN_MAP_INFO";
62
63/// Path of the campaign environment.
64const ENV_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV";
65
66/// Path of the campaign model.
67const MODEL_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL";
68
69/// Path of a faction.
70const FACTION_PATH: &str = "CAMPAIGN_STARTPOS/CAMPAIGN_ENV/CAMPAIGN_MODEL/WORLD/FACTION_ARRAY/FACTION";
71
72/// Game tables the extraction reads, besides the startpos, without the `_tables` suffix.
73pub const EXTRACTION_TABLES: [&str; 2] = ["character_trait_levels", "seasons"];
74
75/// Localisation key prefix of the faction descriptions, followed by the faction's `start_pos_factions` id.
76const DESCRIPTION_KEY_PREFIX: &str = "start_pos_factions_description_";
77
78//---------------------------------------------------------------------------//
79//                              Types
80//---------------------------------------------------------------------------//
81
82/// The `start_pos_*` rows of a campaign, extracted from its startpos.
83#[derive(Debug, Clone, Default, Getters)]
84pub struct ExtractedTables {
85
86    /// Campaign key.
87    #[getset(get = "pub")]
88    campaign: String,
89
90    /// Rows of each table, by table name (without the `_tables` suffix).
91    tables: BTreeMap<String, TableRows>,
92
93    /// Victory objectives of each faction and kind of campaign.
94    #[getset(get = "pub")]
95    victory_objectives: Vec<VictoryObjectives>,
96}
97
98/// Rows of a table, with their values as text.
99#[derive(Debug, Clone, Default, PartialEq, Getters)]
100#[getset(get = "pub")]
101pub struct TableRows {
102
103    /// Names of the columns the rows have. Other columns keep their default value.
104    columns: Vec<&'static str>,
105
106    /// Rows, with their values in `columns` order.
107    rows: Vec<Vec<String>>,
108}
109
110/// What the extraction reads from a startpos, read once.
111struct Source<'a> {
112
113    /// Record layouts of the startpos.
114    layout: &'a StartposLayout,
115
116    /// Campaign key.
117    campaign: String,
118
119    /// The pre-campaign map info (`CAMPAIGN_PREOPEN_MAP_INFO`).
120    preopen: GroupView<'a>,
121
122    /// The calendar (`CAMPAIGN_CALENDAR`).
123    calendar: GroupView<'a>,
124
125    /// The calendar's start date.
126    start_date: GroupView<'a>,
127
128    /// Factions, in `FACTION_ARRAY` order.
129    factions: Vec<FactionSource<'a>>,
130
131    /// Regions (`REGION` groups), in `REGIONS_ARRAY` order.
132    regions: Vec<GroupView<'a>>,
133
134    /// Provinces (`PROVINCE` groups), in `PROVINCE_ARRAY` order.
135    provinces: Vec<GroupView<'a>>,
136
137    /// The world, for its singletons (family tree, slaves).
138    world: GroupView<'a>,
139
140    /// The AI's factions (`CAI_FACTION` groups).
141    cai_factions: Vec<GroupView<'a>>,
142}
143
144/// A faction of the startpos.
145struct FactionSource<'a> {
146
147    /// The `FACTION` group.
148    group: GroupView<'a>,
149
150    /// The faction's pre-campaign info (`FACTION_INFOS` group), if it has one.
151    info: Option<GroupView<'a>>,
152
153    /// Runtime id.
154    id: u32,
155
156    /// Faction key.
157    key: String,
158
159    /// Row id in `start_pos_factions`.
160    table_id: String,
161}
162
163/// Numbers the rows whose ids the startpos doesn't keep.
164#[derive(Debug, Default)]
165struct Ids {
166    last: u64,
167}
168
169//---------------------------------------------------------------------------//
170//                              Implementations
171//---------------------------------------------------------------------------//
172
173/// Extracts the `start_pos_*` tables of a campaign from its startpos.
174///
175/// # Arguments
176///
177/// * `startpos` - The startpos.
178/// * `layout` - Record layouts of the game (the ones the startpos is written with).
179/// * `tables` - Game tables with the [`EXTRACTION_TABLES`].
180///
181/// # Returns
182///
183/// The rows of every table the startpos keeps something of, and the victory objectives.
184///
185/// # Errors
186///
187/// Returns an error if the startpos doesn't fit the layouts, or a table is missing.
188pub fn extract_tables(startpos: &ESF, layout: &StartposLayout, tables: &StartposTables) -> Result<ExtractedTables> {
189    let source = Source::read(startpos, layout)?;
190    let mut ids = Ids::default();
191    let mut extracted = ExtractedTables { campaign: source.campaign.to_owned(), tables: BTreeMap::new(), victory_objectives: missions::extract(&source)? };
192
193    extracted.insert("start_pos_calendars", factions::calendar(&source, tables)?);
194    extracted.insert("start_pos_factions", factions::factions(&source)?);
195    extracted.insert("start_pos_technologies", factions::technologies(&source)?);
196    extracted.insert("start_pos_victory_conditions", factions::victory_conditions(&source));
197    extracted.insert("start_pos_diplomacy", factions::diplomacy(&source, &mut ids)?);
198
199    let regions = regions::extract(&source, &mut ids)?;
200    let characters = characters::extract(&source, tables, &regions, &mut ids)?;
201    for (name, rows) in regions.tables.into_iter().chain(characters) {
202        extracted.insert(name, rows);
203    }
204    Ok(extracted)
205}
206
207impl ExtractedTables {
208
209    /// Returns the rows of a table, by name (without the `_tables` suffix).
210    pub fn table(&self, name: &str) -> Option<&TableRows> {
211        self.tables.get(name)
212    }
213
214    /// Returns the tables, by name (without the `_tables` suffix).
215    pub fn tables(&self) -> impl Iterator<Item = (&str, &TableRows)> {
216        self.tables.iter().map(|(name, rows)| (name.as_str(), rows))
217    }
218
219    /// Returns the victory objectives as a `victory_objectives.txt` script.
220    pub fn victory_objectives_script(&self) -> String {
221        write_victory_objectives(&self.victory_objectives)
222    }
223
224    /// Builds a DB file per table, with the newest definition of the schema. Columns the extraction doesn't fill keep
225    /// their default value, and columns the definition lacks are left out.
226    ///
227    /// # Arguments
228    ///
229    /// * `schema` - Schema of the game.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if the schema has no definition for a table, or a value doesn't fit its column's type.
234    pub fn to_dbs(&self, schema: &Schema) -> Result<Vec<DB>> {
235        self.tables.iter().map(|(name, rows)| {
236            let table_name = format!("{name}_tables");
237            let definition = schema.definitions_by_table_name(&table_name)
238                .and_then(|definitions| definitions.iter().max_by_key(|definition| *definition.version()))
239                .ok_or_else(|| extraction_error(&format!("the schema has no definition for {table_name}")))?;
240
241            let mut db = DB::new(definition, None, &table_name);
242            let fields = definition.fields_processed();
243            let positions = rows.columns.iter()
244                .map(|column| fields.iter().position(|field| field.name() == *column))
245                .collect::<Vec<_>>();
246
247            let mut data = Vec::with_capacity(rows.rows.len());
248            for row in &rows.rows {
249                let mut new_row = db.new_row();
250                for (position, value) in positions.iter().zip(row) {
251                    if let Some(position) = *position {
252                        new_row[position] = DecodedData::new_from_type_and_string(fields[position].field_type(), value)?;
253                    }
254                }
255                data.push(new_row);
256            }
257            db.set_data(&data)?;
258            Ok(db)
259        }).collect()
260    }
261
262    /// Builds a table set from the extracted rows, typed as the schema's newest definitions (see [`Self::to_dbs`]).
263    ///
264    /// # Errors
265    ///
266    /// Returns an error if the schema has no definition for a table, or a value doesn't fit its column's type.
267    pub fn to_tables(&self, schema: &Schema) -> Result<StartposTables> {
268        let mut tables = StartposTables::new();
269        for db in self.to_dbs(schema)? {
270            tables.add_db(&db)?;
271        }
272        Ok(tables)
273    }
274
275    /// Adds the rows of a table.
276    fn insert(&mut self, name: &str, rows: TableRows) {
277        self.tables.insert(name.to_owned(), rows);
278    }
279}
280
281impl TableRows {
282
283    /// Creates an empty table with the given columns.
284    fn new(columns: &[&'static str]) -> Self {
285        Self { columns: columns.to_vec(), rows: vec![] }
286    }
287
288    /// Adds a row, with its values in column order.
289    fn push(&mut self, row: Vec<String>) {
290        debug_assert_eq!(row.len(), self.columns.len(), "a row of the extracted tables doesn't fit its columns");
291        self.rows.push(row);
292    }
293}
294
295impl<'a> Source<'a> {
296
297    /// Reads the parts of a startpos the extraction needs.
298    fn read(startpos: &'a ESF, layout: &'a StartposLayout) -> Result<Self> {
299        let NodeType::Record(root) = startpos.root_node() else { return Err(extraction_error("the startpos has no root record")) };
300        let root = layout.read_single(ROOT_PATH, root)?;
301        let preopen = layout.read_single(PREOPEN_PATH, required(&root, "CAMPAIGN_PREOPEN_MAP_INFO")?)?;
302        let env = layout.read_single(ENV_PATH, required(&root, "CAMPAIGN_ENV")?)?;
303        let model = layout.read_single(MODEL_PATH, required(&env, "CAMPAIGN_MODEL")?)?;
304        let world = layout.read_single(WORLD_PATH, required(&model, "WORLD")?)?;
305
306        let calendar_path = child_path(MODEL_PATH, "CAMPAIGN_CALENDAR");
307        let calendar = layout.read_single(&calendar_path, required(&model, "CAMPAIGN_CALENDAR")?)?;
308        let start_date = layout.read_single(&child_path(&calendar_path, "DATE"), required(&calendar, "DATE")?)?;
309
310        let infos_path = child_path(PREOPEN_PATH, "FACTION_INFOS");
311        let infos = match preopen.record("FACTION_INFOS") {
312            Some(record) => layout.read(&infos_path, record)?,
313            None => vec![],
314        };
315        let mut infos = infos.into_iter()
316            .filter_map(|info| Some((info.str("^+1")?.to_owned(), info)))
317            .collect::<HashMap<_, _>>();
318
319        let factions = layout.read_items(&child_path(WORLD_PATH, "FACTION_ARRAY"), &world, "FACTION_ARRAY", "FACTION")?.into_iter()
320            .map(|(_, group)| {
321                let key = FACTION_KEY_FIELDS.iter().find_map(|field| group.str(field)).unwrap_or_default().to_owned();
322                let info = infos.remove(&key);
323                let table_id = info.as_ref()
324                    .and_then(|info| info.str("^+5"))
325                    .and_then(|description| description.strip_prefix(DESCRIPTION_KEY_PREFIX))
326                    .unwrap_or_default()
327                    .to_owned();
328                FactionSource { id: group.u32("^+1").unwrap_or_default(), group, info, key, table_id }
329            })
330            .collect();
331
332        let region_manager_path = child_path(WORLD_PATH, "REGION_MANAGER");
333        let region_manager = layout.read_single(&region_manager_path, required(&world, "REGION_MANAGER")?)?;
334        let regions = layout.read_items(&child_path(&region_manager_path, "REGIONS_ARRAY"), &region_manager, "REGIONS_ARRAY", "REGION")?.into_iter()
335            .map(|(_, region)| region)
336            .collect();
337
338        let province_manager_path = child_path(WORLD_PATH, "PROVINCE_MANAGER");
339        let province_manager = layout.read_single(&province_manager_path, required(&world, "PROVINCE_MANAGER")?)?;
340        let provinces = layout.read_items(&child_path(&province_manager_path, "PROVINCE_ARRAY"), &province_manager, "PROVINCE_ARRAY", "PROVINCE")?.into_iter()
341            .map(|(_, province)| province)
342            .collect();
343
344        let cai_interface_path = child_path(MODEL_PATH, "CAI_INTERFACE");
345        let mut cai_factions = vec![];
346        if let Some(interface) = model.record("CAI_INTERFACE") {
347            let interface = layout.read_single(&cai_interface_path, interface)?;
348            if let Some(cai_world) = interface.record("CAI_WORLD") {
349                let cai_world = layout.read_single(CAI_WORLD_PATH, cai_world)?;
350                let factions_path = child_path(CAI_WORLD_PATH, "CAI_WORLD_FACTIONS");
351                cai_factions = layout.read_items(&factions_path, &cai_world, "CAI_WORLD_FACTIONS", "CAI_FACTION")?.into_iter()
352                    .map(|(_, faction)| faction)
353                    .collect();
354            }
355        }
356
357        Ok(Self {
358            layout,
359            campaign: preopen.str("DATE+1").unwrap_or_default().to_owned(),
360            preopen,
361            calendar,
362            start_date,
363            factions,
364            regions,
365            provinces,
366            world,
367            cai_factions,
368        })
369    }
370
371    /// Returns the start year.
372    fn start_year(&self) -> u32 {
373        self.start_date.u32("^+1").unwrap_or_default()
374    }
375
376    /// Returns a faction by runtime id.
377    fn faction(&self, id: u32) -> Option<&FactionSource<'a>> {
378        self.factions.iter().find(|faction| faction.id == id)
379    }
380
381    /// Returns a faction by key.
382    fn faction_by_key(&self, key: &str) -> Option<&FactionSource<'a>> {
383        self.factions.iter().find(|faction| faction.key == key)
384    }
385
386    /// Reads the only group of a child record of a faction.
387    fn faction_record(&self, faction: &FactionSource<'a>, key: &str) -> Result<Option<GroupView<'a>>> {
388        faction.group.record(key)
389            .map(|record| self.layout.read_single(&child_path(FACTION_PATH, key), record))
390            .transpose()
391    }
392}
393
394impl Ids {
395
396    /// Returns a new id.
397    fn next(&mut self) -> String {
398        self.last += 1;
399        self.last.to_string()
400    }
401}
402
403/// Returns a child record of a group, or an error naming it if the group lacks it.
404fn required<'a>(group: &GroupView<'a>, key: &str) -> Result<&'a RecordNode> {
405    group.record(key).ok_or_else(|| extraction_error(&format!("missing {key}")))
406}
407
408/// Builds the error for a startpos that lacks something.
409fn extraction_error(message: &str) -> RLibError {
410    RLibError::StartposExtractionError(message.to_owned())
411}