Skip to main content

rpfm_extensions/startpos/
generator.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//! Generation of a campaign's startpos from a pack and the game's files, without launching the game.
12//!
13//! [`load_tables`] loads the tables (the game's, its Assembly Kit's and the pack's, merged as the game loads them),
14//! which [`validation`](super::validation) checks. [`StartposSources`] adds what else the build reads: the campaign
15//! map's files, the victory objectives, the characters with legacy override texts, the start season's name and the
16//! values the campaign's scripts save. [`GameLayouts`] gets the record layouts from the game's startposes, cached in a
17//! layouts folder. [`generate_startpos`] writes the startpos.
18
19use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
20use std::fs::File;
21use std::io::{BufReader, Cursor};
22use std::path::{Path, PathBuf};
23
24use rpfm_lib::error::{RLibError, Result};
25use rpfm_lib::files::{Container, Decodeable, DecodeableExtraData, FileType, RFile, RFileDecoded};
26use rpfm_lib::files::db::DB;
27use rpfm_lib::files::esf::ESF;
28use rpfm_lib::files::pack::Pack;
29use rpfm_lib::files::table::DecodedData;
30use rpfm_lib::files::trade_routes::TradeRoutes;
31use rpfm_lib::games::GameInfo;
32use rpfm_lib::games::supported_games::KEY_ATTILA;
33use rpfm_lib::schema::Schema;
34
35use crate::dependencies::{Dependencies, VICTORY_OBJECTIVES_FILE_NAME};
36use crate::lua::LuaApi;
37use crate::lua::harness::{save_campaign, LuaSavedValue, LuaScripts, LuaTestOptions};
38use super::extract::EXTRACTION_TABLES;
39use super::layout::{layout_file_name, StartposLayout};
40use super::missions::{parse_victory_objectives, VictoryObjectives};
41use super::region_map::RegionMap;
42use super::settlements::SettlementLayout;
43use super::tables::StartposTables;
44use super::trade::TradeNetwork;
45use super::validation::referenced_tables;
46use super::world::{CampaignIndex, NAME_COLUMNS};
47use super::world::calendar::Calendar;
48use super::world::writer::{WorldInputs, WorldPlan};
49use super::writer::{write_startpos, StartposInputs};
50
51#[cfg(test)] #[path = "generator_test.rs"] mod generator_test;
52
53/// Games whose startposes can be generated.
54pub const SUPPORTED_GAMES: [&str; 1] = [KEY_ATTILA];
55
56/// Tables the startpos build reads, without the `_tables` suffix.
57pub const STARTPOS_TABLES: &[&str] = &[
58    "agent_culture_details", "agent_to_agent_abilities", "agent_to_agent_attributes", "agent_uniforms", "agents",
59    "armed_citizenry_units_to_unit_groups_junctions", "battlefield_engines", "building_chain_availabilities",
60    "building_chain_availability_sets", "building_chains", "building_effects_junction", "building_level_armed_citizenry_junctions",
61    "building_levels", "campaign_character_art_sets", "campaign_character_art_sets_group_junctions", "campaign_variables", "campaigns",
62    "character_experience_skill_tiers", "character_skill_node_sets", "character_skill_nodes", "character_trait_levels", "commander_unit_permissions", "commodities",
63    "cultures_subcultures", "effect_bonus_value_basic_junction", "effect_bonus_value_estate_junctions", "effect_bonus_value_unit_category_junction", "effect_bonus_value_unit_class_junction", "faction_political_parties_junctions", "faction_to_mercenary_set_junctions", "factions", "land_units",
64    "main_units", "mercenary_pool_to_groups_junctions", "mercenary_unit_groups", "military_force_legacy_emblems",
65    "military_force_legacy_names", "ministerial_positions", "ministerial_positions_culture_details", "names", "naval_units",
66    "political_parties", "province_to_mercenary_set_junctions", "region_to_province_junctions", "regions", "religions", "resources",
67    "start_pos_calendars", "start_pos_character_estates", "start_pos_character_skills", "start_pos_character_to_settlements", "start_pos_character_traits", "start_pos_characters",
68    "start_pos_diplomacy", "start_pos_family_relationships", "start_pos_factions", "start_pos_horde_details", "start_pos_land_units",
69    "start_pos_naval_units", "start_pos_past_events", "start_pos_region_religions", "start_pos_region_slot_templates",
70    "start_pos_regions", "start_pos_settlements", "start_pos_technologies", "start_pos_victory_conditions",
71    "technology_node_sets", "technology_nodes", "technology_unit_upgrades", "ui_unit_groupings", "units_to_groupings_military_permissions",
72];
73
74/// Localisation key prefix of the `start_pos_characters.legacy_override` texts.
75const LEGACY_OVERRIDE_PREFIX: &str = "start_pos_characters_legacy_override_";
76
77/// Folder with the files of each campaign (scripts, startpos), and the script the game runs first from a campaign's
78/// folder.
79const CAMPAIGNS_FOLDER: &str = "campaigns";
80const CAMPAIGN_SCRIPT: &str = "scripting.lua";
81
82/// Name of the startpos file in a campaign's folder.
83const STARTPOS_FILE: &str = "startpos.esf";
84
85/// Events the game triggers while building a startpos, before saving it.
86const STARTPOS_BUILD_EVENTS: [&str; 2] = ["NewSession", "WorldCreated"];
87
88/// Localisation key prefix of the season names.
89const SEASON_NAME_PREFIX: &str = "seasons_onscreen_";
90
91/// Localisation key prefix of the factions' screen names.
92pub(crate) const FACTION_SCREEN_NAME_PREFIX: &str = "factions_screen_name_";
93
94/// Localisation key prefix of the characters' names.
95pub(crate) const CHARACTER_NAME_PREFIX: &str = "names_name_";
96
97/// Suffix of the campaign map keys, which the region lookup image's name lacks.
98const MAP_KEY_SUFFIX: &str = "_map";
99
100/// Suffix of the region lookup image's name.
101const LOOKUP_IMAGE_SUFFIX: &str = "_lookup.tga";
102
103/// TGA image type of colour-mapped images.
104const TGA_COLOUR_MAPPED: u8 = 1;
105
106/// Columns the build reads that some games' tables don't have (Rome 2's, mostly; Attila has no `xp`), with the value they read as: the
107/// empty or zero one, meaning the game doesn't have the feature (hordes, fertility, culture filters...).
108const OPTIONAL_COLUMNS: [(&str, &str, OptionalValue); 32] = [
109    ("agent_culture_details", "season", OptionalValue::Text),
110    ("agents", "religion", OptionalValue::Text),
111    ("building_chain_availabilities", "campaign", OptionalValue::Text),
112    ("building_chain_availabilities", "faction", OptionalValue::Text),
113    ("building_chain_availabilities", "sub_culture", OptionalValue::Text),
114    ("campaign_character_art_sets", "culture", OptionalValue::Text),
115    ("campaign_character_art_sets", "subculture", OptionalValue::Text),
116    ("character_skill_node_sets", "agent_key", OptionalValue::Text),
117    ("character_skill_node_sets", "subculture", OptionalValue::Text),
118    ("character_skill_nodes", "points_on_creation", OptionalValue::Integer),
119    ("character_skill_nodes", "subculture", OptionalValue::Text),
120    ("commander_unit_permissions", "culture_key", OptionalValue::Text),
121    ("commander_unit_permissions", "subculture_key", OptionalValue::Text),
122    ("factions", "can_be_horde", OptionalValue::Boolean),
123    ("main_units", "tier", OptionalValue::Integer),
124    ("military_force_legacy_emblems", "campaign", OptionalValue::Text),
125    ("military_force_legacy_emblems", "faction_key", OptionalValue::Text),
126    ("military_force_legacy_emblems", "subculture_key", OptionalValue::Text),
127    ("ministerial_positions", "maximum_concurrent_ministers", OptionalValue::Integer),
128    ("ministerial_positions", "order", OptionalValue::Integer),
129    ("start_pos_characters", "death_type", OptionalValue::Text),
130    ("start_pos_characters", "portrait_id", OptionalValue::Text),
131    ("start_pos_characters", "turns_died_before_start", OptionalValue::Text),
132    ("start_pos_characters", "xp", OptionalValue::Integer),
133    ("start_pos_factions", "cai_personality_group", OptionalValue::Text),
134    ("start_pos_regions", "base_fertility", OptionalValue::Integer),
135    ("start_pos_regions", "initial_public_order", OptionalValue::Integer),
136    ("start_pos_settlements", "building2", OptionalValue::Text),
137    ("start_pos_settlements", "building3", OptionalValue::Text),
138    ("start_pos_settlements", "building4", OptionalValue::Text),
139    ("start_pos_settlements", "building5", OptionalValue::Text),
140    ("technology_node_sets", "culture", OptionalValue::Text),
141];
142
143/// Size of a TGA header.
144const TGA_HEADER_SIZE: usize = 18;
145
146//---------------------------------------------------------------------------//
147//                              Types
148//---------------------------------------------------------------------------//
149
150/// The value an optional column reads as in games without it.
151#[derive(Clone, Copy)]
152enum OptionalValue {
153    Text,
154    Integer,
155    Boolean,
156}
157
158/// What a campaign's startpos is built from.
159pub struct StartposSources {
160
161    /// Key of the game.
162    game: String,
163
164    /// Campaign key.
165    campaign: String,
166
167    /// Campaign map key.
168    map: String,
169
170    /// Localised name of the season the campaign starts in, empty if it has none.
171    season_name: String,
172
173    /// Localised screen names of the campaign's factions and names of its characters, by localisation key.
174    texts: HashMap<String, String>,
175
176    /// RGB colours of the campaign map's region lookup image, by palette index. Empty without the image.
177    region_palette: Vec<[u8; 3]>,
178
179    /// Tables the build reads.
180    tables: StartposTables,
181
182    /// The campaign map's `map_data.esf`.
183    map_data: ESF,
184
185    /// The campaign map's `hlp_data.esf`.
186    hlp_data: ESF,
187
188    /// The campaign map's `dynamic_resources.esf`.
189    dynamic_resources: ESF,
190
191    /// The campaign map's `trade_routes.ptd`.
192    trade_routes: TradeRoutes,
193
194    /// Victory objectives of the campaigns (`db/victory_objectives.txt`).
195    victory_objectives: Vec<VictoryObjectives>,
196
197    /// `start_pos_characters` ids of the characters with a legacy override text.
198    legacy_overrides: HashSet<String>,
199
200    /// Values the campaign's scripts save when the game builds the startpos, or `None` if they couldn't run.
201    script_variables: Option<Vec<(String, LuaSavedValue)>>,
202}
203
204/// Where a file is looked for: the pack first, then the game's files.
205struct FileSource<'a> {
206    pack: &'a Pack,
207    dependencies: &'a Dependencies,
208}
209
210//---------------------------------------------------------------------------//
211//                              Implementations
212//---------------------------------------------------------------------------//
213
214impl StartposSources {
215
216    /// Collects the sources of a campaign's startpos from its tables (see [`load_tables`]), a pack and the game's
217    /// files. Files come from the pack if it has them, otherwise from the game.
218    ///
219    /// # Arguments
220    ///
221    /// * `game` - The game.
222    /// * `tables` - The tables of the build.
223    /// * `dependencies` - The game's files.
224    /// * `pack` - Pack with the campaign's startpos tables.
225    /// * `campaign` - Campaign key.
226    ///
227    /// # Errors
228    ///
229    /// Returns an error if a file is missing or can't be decoded.
230    pub fn from_tables(game: &GameInfo, tables: StartposTables, dependencies: &Dependencies, pack: &Pack, campaign: &str) -> Result<Self> {
231        let map = tables.table("campaigns")?.rows()
232            .find_map(|row| (row.str("campaign_name").ok()? == campaign).then(|| row.str("map_name").ok()).flatten())
233            .ok_or_else(|| RLibError::StartposInvalidValue("campaigns".to_owned(), "campaign_name".to_owned(), campaign.to_owned()))?
234            .into_owned();
235
236        let files = FileSource { pack, dependencies };
237        let map_file = |name: &str| files.bytes(&format!("campaign_maps/{map}/{name}"));
238        let esf = |name: &str| -> Result<ESF> { ESF::decode(&mut Cursor::new(map_file(name)?), &None) };
239        let victory_text = files.bytes(VICTORY_OBJECTIVES_FILE_NAME)?;
240        let lookup_image = format!("{}{LOOKUP_IMAGE_SUFFIX}", map.strip_suffix(MAP_KEY_SUFFIX).unwrap_or(&map));
241        let region_palette = match map_file(&lookup_image) {
242            Ok(bytes) => tga_palette(&bytes)?,
243            Err(_) => vec![],
244        };
245        let season = Calendar::new(&tables, campaign)?.start_season().to_owned();
246
247        Ok(Self {
248            game: game.key().to_owned(),
249            campaign: campaign.to_owned(),
250            season_name: loc_text(dependencies, pack, &format!("{SEASON_NAME_PREFIX}{season}")).unwrap_or_default(),
251            texts: header_texts(&tables, dependencies, pack)?,
252            region_palette,
253            map_data: esf("map_data.esf")?,
254            hlp_data: esf("hlp_data.esf")?,
255            dynamic_resources: esf("dynamic_resources.esf")?,
256            trade_routes: TradeRoutes::decode(&mut Cursor::new(map_file("trade_routes.ptd")?), &None)?,
257            victory_objectives: parse_victory_objectives(&String::from_utf8_lossy(&victory_text))?,
258            legacy_overrides: legacy_overrides(dependencies, pack),
259            script_variables: script_variables(dependencies, pack, campaign),
260            map,
261            tables,
262        })
263    }
264}
265
266/// Loads the tables of a startpos build: the ones the build reads, every `start_pos_*` table of the schema with the
267/// tables it references, for validation, and the ones the extraction of tables from a startpos reads.
268///
269/// Each table merges the game's fragments and then the pack's, in load order: a row replaces an earlier row with the
270/// same key. Tables neither has come from the game's Assembly Kit; the ones its schema doesn't define are empty, and
271/// the [`OPTIONAL_COLUMNS`] a game lacks get their default.
272///
273/// # Arguments
274///
275/// * `dependencies` - The game's files.
276/// * `pack` - Pack with the campaign's startpos tables.
277/// * `schema` - Schema of the game, to decode the tables.
278///
279/// # Errors
280///
281/// Returns an error if a table can't be decoded, or its fragments don't share their columns.
282pub fn load_tables(dependencies: &Dependencies, pack: &Pack, schema: &Schema) -> Result<StartposTables> {
283    let mut extra = DecodeableExtraData::default();
284    extra.set_schema(Some(schema));
285    let extra = Some(extra);
286
287    let mut names = STARTPOS_TABLES.iter().chain(&EXTRACTION_TABLES).map(|name| name.to_string()).collect::<BTreeSet<_>>();
288    names.extend(referenced_tables(schema));
289    names.extend(schema.definitions().keys().filter(|name| name.starts_with("start_pos_")).map(|name| name.trim_end_matches("_tables").to_owned()));
290
291    let mut tables = StartposTables::new();
292    for table in &names {
293        let folder = format!("db/{table}_tables/");
294        let mut fragments = vec![];
295        for file in dependencies.db_data(&format!("{table}_tables"), true, true)? {
296            fragments.extend(decoded_db(file, &extra)?);
297        }
298
299        let mut pack_files = pack.files().iter().filter(|(path, _)| path.starts_with(&folder)).collect::<Vec<_>>();
300        pack_files.sort_by_key(|(path, _)| *path);
301        let mut pack_fragments = vec![];
302        for (_, file) in pack_files {
303            pack_fragments.extend(decoded_db(file, &extra)?);
304        }
305
306        if fragments.is_empty() && pack_fragments.is_empty() {
307            fragments.extend(dependencies.asskit_only_db_tables().get(&format!("{table}_tables")).cloned());
308        }
309        fragments.extend(pack_fragments);
310
311        // Tables the game doesn't have (Rome 2 has no hordes) are empty, so builds skip what they'd add.
312        if fragments.is_empty() && schema.definitions().get(&format!("{table}_tables")).is_none() {
313            tables.add_rows::<&str>(table, &[], &[])?;
314        }
315        add_merged(&mut tables, table, &fragments)?;
316    }
317
318    for (table, column, value) in OPTIONAL_COLUMNS {
319        let value = match value {
320            OptionalValue::Text => DecodedData::StringU8(String::new()),
321            OptionalValue::Integer => DecodedData::I32(0),
322            OptionalValue::Boolean => DecodedData::Boolean(false),
323        };
324        tables.add_missing_column(table, column, value);
325    }
326    Ok(tables)
327}
328
329/// Loads the startpos of a campaign (`campaigns/<campaign>/startpos.esf`), from the pack if it has it, otherwise from
330/// the game's files.
331///
332/// # Arguments
333///
334/// * `dependencies` - The game's files.
335/// * `pack` - Pack that may have the startpos.
336/// * `campaign` - Campaign key.
337///
338/// # Errors
339///
340/// Returns an error if neither has the startpos, or it can't be decoded.
341pub fn load_startpos(dependencies: &Dependencies, pack: &Pack, campaign: &str) -> Result<ESF> {
342    let files = FileSource { pack, dependencies };
343    let bytes = files.bytes(&format!("{CAMPAIGNS_FOLDER}/{campaign}/{STARTPOS_FILE}"))?;
344    ESF::decode(&mut Cursor::new(bytes), &None)
345}
346
347impl FileSource<'_> {
348
349    /// Returns the bytes of a file, from the pack if it has it, otherwise from the game's files.
350    fn bytes(&self, path: &str) -> Result<Vec<u8>> {
351        let file = match self.pack.file(path, true) {
352            Some(file) => file,
353            None => self.dependencies.file(path, true, true, true)?,
354        };
355
356        // Files are cloned so the pack's and the cache's copies keep their state.
357        file.clone().encode(&None, false, false, true)?.ok_or_else(|| RLibError::FileNotFound(path.to_owned()))
358    }
359}
360
361/// Generates the startpos of a campaign.
362///
363/// # Arguments
364///
365/// * `sources` - What the startpos is built from.
366/// * `layout` - Record layouts of the game.
367///
368/// # Errors
369///
370/// Returns an error if the sources lack data the build needs.
371pub fn generate_startpos(sources: &StartposSources, layout: &StartposLayout) -> Result<ESF> {
372    generate_startpos_with_plan(sources, layout, |index| WorldPlan::new(index))
373}
374
375/// Generates the startpos of a campaign with the orders and ids of its world given by a plan.
376pub(crate) fn generate_startpos_with_plan(sources: &StartposSources, layout: &StartposLayout, plan: impl for<'i> FnOnce(&'i CampaignIndex) -> WorldPlan<'i>) -> Result<ESF> {
377    let index = CampaignIndex::new(&sources.tables, &sources.campaign)?;
378    let settlements = SettlementLayout::all_from_map_data(&sources.map_data, |region| index.settlement_of(region).is_some_and(|settlement| settlement.is_major()))?;
379    let region_map = RegionMap::from_map_data(&sources.map_data)?;
380    let world = WorldInputs {
381        tables: &sources.tables,
382        index: &index,
383        layout,
384        settlements: &settlements,
385        region_map: &region_map,
386        legacy_overrides: &sources.legacy_overrides,
387        trading: None,
388        game: &sources.game,
389    };
390
391    let trade_network = TradeNetwork::from_trade_routes(&sources.trade_routes, &region_map.scale());
392    let inputs = StartposInputs {
393        world: &world,
394        trade_network: &trade_network,
395        victory_objectives: &sources.victory_objectives,
396        game: &sources.game,
397        map_data: &sources.map_data,
398        hlp_data: &sources.hlp_data,
399        dynamic_resources: &sources.dynamic_resources,
400        campaign: &sources.campaign,
401        map: &sources.map,
402        season_name: &sources.season_name,
403        texts: &sources.texts,
404        region_palette: &sources.region_palette,
405        script_variables: sources.script_variables.as_deref(),
406    };
407    write_startpos(&inputs, &plan(&index))
408}
409
410/// Record layouts of a game: one per campaign the game has a startpos for, and one merged from all of them.
411#[derive(Debug, Clone)]
412pub struct GameLayouts {
413
414    /// Layout merged from the startposes of all the game's campaigns.
415    merged: StartposLayout,
416
417    /// Layout of each of the game's campaigns, by campaign key.
418    campaigns: BTreeMap<String, StartposLayout>,
419}
420
421impl GameLayouts {
422
423    /// Loads the layouts of a game, extracting them from the game's startposes if they aren't cached or one of the
424    /// startposes is newer than the cache (like after a patch, or a new campaign).
425    ///
426    /// # Arguments
427    ///
428    /// * `layouts_folder` - Folder with the cached layouts.
429    /// * `game` - The game.
430    /// * `game_data_path` - The game's data folder, with `campaigns/<campaign>/startpos.esf`.
431    ///
432    /// # Errors
433    ///
434    /// Returns an error if the game has no startpos to take the layouts from, or one can't be decoded or cached.
435    pub fn load_or_extract(layouts_folder: &Path, game: &GameInfo, game_data_path: &Path) -> Result<Self> {
436        let startposes = game_startposes(game_data_path);
437        if startposes.is_empty() {
438            return Err(RLibError::StartposLayoutError(format!("the game has no startpos in {} to take the record layouts from", game_data_path.join(CAMPAIGNS_FOLDER).display())));
439        }
440
441        let merged_path = layouts_folder.join(layout_file_name(game.key(), None));
442        let campaign_path = |campaign: &str| layouts_folder.join(layout_file_name(game.key(), Some(campaign)));
443        let modified = |path: &Path| path.metadata().and_then(|metadata| metadata.modified()).ok();
444        let newest_startpos = startposes.values().filter_map(|path| modified(path)).max();
445        let is_current = |path: &Path| modified(path).is_some_and(|cached| newest_startpos.is_some_and(|newest| cached >= newest));
446
447        if is_current(&merged_path) && startposes.keys().all(|campaign| is_current(&campaign_path(campaign))) {
448            let cached = StartposLayout::load(&merged_path).and_then(|merged| {
449                let campaigns = startposes.keys().map(|campaign| Ok((campaign.to_owned(), StartposLayout::load(&campaign_path(campaign))?))).collect::<Result<_>>()?;
450                Ok(Self { merged, campaigns })
451            });
452            if let Ok(layouts) = cached {
453                return Ok(layouts);
454            }
455        }
456
457        // Each startpos is decoded once, for its own layout and for the merged one.
458        let mut campaigns = BTreeMap::new();
459        let merged = StartposLayout::from_esfs(startposes.iter().map(|(campaign, path)| {
460            let startpos = ESF::decode(&mut BufReader::new(File::open(path)?), &None)?;
461            campaigns.insert(campaign.to_owned(), StartposLayout::from_esf(&startpos)?);
462            Ok(startpos)
463        }))?;
464
465        std::fs::create_dir_all(layouts_folder)?;
466        for (campaign, layout) in &campaigns {
467            layout.save(&campaign_path(campaign))?;
468        }
469        merged.save(&merged_path)?;
470        Ok(Self { merged, campaigns })
471    }
472
473    /// Returns the layout to write a campaign's startpos with: the campaign's own if the game has its startpos,
474    /// otherwise the one of a game campaign on the same map, otherwise the merged one. The campaign's or map's layout
475    /// takes the records it lacks from the merged one.
476    ///
477    /// # Arguments
478    ///
479    /// * `tables` - Tables with `campaigns`, to find the campaigns on the same map.
480    /// * `campaign` - Campaign key.
481    pub fn for_campaign(&self, tables: &StartposTables, campaign: &str) -> StartposLayout {
482        let maps = tables.table("campaigns").map(|table| table.rows()
483            .filter_map(|row| Some((row.str("campaign_name").ok()?.into_owned(), row.str("map_name").ok()?.into_owned())))
484            .collect::<HashMap<_, _>>())
485            .unwrap_or_default();
486        let map = maps.get(campaign);
487
488        let primary = self.campaigns.get(campaign)
489            .or_else(|| self.campaigns.iter().find(|(other, _)| map.is_some() && maps.get(other.as_str()) == map).map(|(_, layout)| layout));
490        match primary {
491            Some(layout) => layout.clone().with_fallback(&self.merged),
492            None => self.merged.clone(),
493        }
494    }
495}
496
497/// Returns the paths of the game's startposes, by campaign key.
498fn game_startposes(game_data_path: &Path) -> BTreeMap<String, PathBuf> {
499    std::fs::read_dir(game_data_path.join(CAMPAIGNS_FOLDER))
500        .map(|entries| entries.flatten()
501            .filter_map(|entry| Some((entry.file_name().into_string().ok()?, entry.path().join(STARTPOS_FILE))))
502            .filter(|(_, path)| path.is_file())
503            .collect())
504        .unwrap_or_default()
505}
506
507/// Returns a decoded DB from a file, decoding a copy of it if needed. Empty files of tables without definitions
508/// hold no rows, so they're skipped.
509fn decoded_db(file: &RFile, extra: &Option<DecodeableExtraData>) -> Result<Option<DB>> {
510    if let Ok(RFileDecoded::DB(db)) = file.decoded() {
511        return Ok(Some(db.clone()));
512    }
513    match file.clone().decode(extra, false, true) {
514        Ok(Some(RFileDecoded::DB(db))) => Ok(Some(db)),
515        Err(RLibError::DecodingDBNoDefinitionsFoundAndEmptyFile) => Ok(None),
516        Err(error) => Err(error),
517        Ok(_) => Err(RLibError::DecodingDBNotADBTable),
518    }
519}
520
521/// Adds a table merged from its fragments, in load order: a row replaces an earlier row with the same key, in place.
522fn add_merged(tables: &mut StartposTables, table: &str, fragments: &[DB]) -> Result<()> {
523    let Some(first) = fragments.first() else { return Ok(()) };
524    let columns = first.definition().fields_processed().iter().map(|field| field.name().to_owned()).collect::<Vec<_>>();
525
526    let mut rows: Vec<Vec<DecodedData>> = vec![];
527    let mut positions = HashMap::new();
528    for fragment in fragments {
529        let fragment_columns = fragment.definition().fields_processed().iter().map(|field| field.name().to_owned()).collect::<Vec<_>>();
530        let order = columns.iter()
531            .map(|column| fragment_columns.iter().position(|other| other == column).ok_or_else(|| RLibError::StartposMissingColumn(table.to_owned(), column.to_owned())))
532            .collect::<Result<Vec<_>>>()?;
533        let keys = fragment.definition().key_column_positions();
534
535        for row in fragment.data().iter() {
536            let key = keys.iter().map(|&column| row[column].data_to_string().into_owned()).collect::<Vec<_>>();
537            let row = order.iter().map(|&source| row[source].clone()).collect::<Vec<_>>();
538            match (keys.is_empty(), positions.get(&key)) {
539                (false, Some(&position)) => rows[position] = row,
540                (false, None) => {
541                    positions.insert(key, rows.len());
542                    rows.push(row);
543                },
544                (true, _) => rows.push(row),
545            }
546        }
547    }
548    tables.add_rows(table, &columns, &rows)
549}
550
551/// Returns the `start_pos_characters` ids with a legacy override text, in the pack's or the game's localisation.
552fn legacy_overrides(dependencies: &Dependencies, pack: &Pack) -> HashSet<String> {
553    let game_texts = dependencies.localisation_data().iter().map(|(key, text)| (key.to_owned(), text.to_owned()));
554    let pack_texts = pack.files_by_type(&[FileType::Loc]).into_iter().flat_map(loc_texts);
555    game_texts.chain(pack_texts)
556        .filter(|(_, text)| !text.is_empty())
557        .filter_map(|(key, _)| key.strip_prefix(LEGACY_OVERRIDE_PREFIX).map(str::to_owned))
558        .collect()
559}
560
561/// Returns the values a campaign's scripts save when the game builds its startpos, running the scripts of the game and
562/// the pack, or `None` if they can't run or the campaign's scripts aren't there.
563fn script_variables(dependencies: &Dependencies, pack: &Pack, campaign: &str) -> Option<Vec<(String, LuaSavedValue)>> {
564    let scripts = LuaScripts::from_game_and_packs(dependencies, [pack]);
565    let campaign_script = format!("{CAMPAIGNS_FOLDER}/{campaign}/{CAMPAIGN_SCRIPT}");
566    if !scripts.contains(&campaign_script) {
567        log::warn!("{campaign_script} isn't in the pack nor the game's files, so the startpos keeps the script variables of its layout");
568        return None;
569    }
570
571    let mut options = LuaTestOptions::default();
572    options.set_campaign(Some(campaign.to_owned()));
573
574    // The games with startposes have no scripting docs, so the engine has no typed methods.
575    match save_campaign(&LuaApi::default(), &scripts, &options, &STARTPOS_BUILD_EVENTS) {
576        Ok(save) => {
577            for error in save.errors() {
578                log::warn!("The scripts of {campaign} raised an error while saving its startpos: {error}");
579            }
580            Some(save.values().to_owned())
581        },
582        Err(error) => {
583            log::warn!("The scripts of {campaign} couldn't run, so its startpos keeps the script variables of its layout: {error}");
584            None
585        },
586    }
587}
588
589/// Returns the localised texts the save game header can show: every faction's screen name and every character's
590/// names, by localisation key, from the pack if it has them, else from the game.
591fn header_texts(tables: &StartposTables, dependencies: &Dependencies, pack: &Pack) -> Result<HashMap<String, String>> {
592    let mut keys = vec![];
593    for row in tables.table("start_pos_factions")?.rows() {
594        keys.push(format!("{FACTION_SCREEN_NAME_PREFIX}{}", row.str("faction")?));
595    }
596    for row in tables.table("start_pos_characters")?.rows() {
597        for column in NAME_COLUMNS {
598            keys.push(format!("{CHARACTER_NAME_PREFIX}{}", row.str(column)?));
599        }
600    }
601
602    // Texts are looked up once each: the localisation scan is slow.
603    let pack_texts = pack.files_by_type(&[FileType::Loc]).into_iter().flat_map(loc_texts).collect::<HashMap<_, _>>();
604    Ok(keys.into_iter()
605        .filter_map(|key| pack_texts.get(&key).or_else(|| dependencies.localisation_data().get(&key)).cloned().map(|text| (key, text)))
606        .collect())
607}
608
609/// Returns the text of a localisation key, from the pack if it has it, else from the game.
610fn loc_text(dependencies: &Dependencies, pack: &Pack, key: &str) -> Option<String> {
611    pack.files_by_type(&[FileType::Loc]).into_iter()
612        .flat_map(loc_texts)
613        .find_map(|(loc_key, text)| (loc_key == key).then_some(text))
614        .or_else(|| dependencies.localisation_data().get(key).cloned())
615}
616
617/// Returns the RGB colours of a colour-mapped TGA image's palette, by palette index.
618///
619/// # Errors
620///
621/// Returns an error if the image isn't colour-mapped, or is cut short.
622pub(crate) fn tga_palette(bytes: &[u8]) -> Result<Vec<[u8; 3]>> {
623    let invalid = || RLibError::CampaignMapDataError("the region lookup image isn't a colour-mapped TGA".to_owned());
624    let header = bytes.get(..TGA_HEADER_SIZE).ok_or_else(invalid)?;
625    if header[2] != TGA_COLOUR_MAPPED {
626        return Err(invalid());
627    }
628
629    // Header bytes: 0 id length, 3-4 first palette index, 5-6 palette length, 7 bits per palette entry (BGR order).
630    let start = TGA_HEADER_SIZE + usize::from(header[0]);
631    let length = usize::from(u16::from_le_bytes([header[5], header[6]]));
632    let entry_size = usize::from(header[7]) / 8;
633    let palette = bytes.get(start..start + length * entry_size).ok_or_else(invalid)?;
634    if entry_size < 3 {
635        return Err(invalid());
636    }
637    Ok(palette.chunks_exact(entry_size).map(|entry| [entry[2], entry[1], entry[0]]).collect())
638}
639
640/// Returns the (key, text) pairs of a localisation file, or none if it can't be decoded.
641fn loc_texts(file: &RFile) -> Vec<(String, String)> {
642    let decoded = match file.decoded() {
643        Ok(decoded) => Some(decoded.clone()),
644        Err(_) => file.clone().decode(&None, false, true).ok().flatten(),
645    };
646    match decoded {
647        Some(RFileDecoded::Loc(loc)) => loc.data().iter()
648            .filter_map(|row| Some((row.first()?.data_to_string().into_owned(), row.get(1)?.data_to_string().into_owned())))
649            .collect(),
650        _ => vec![],
651    }
652}