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;
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_level_armed_citizenry_junctions",
61    "building_levels", "campaign_character_art_sets", "campaign_character_art_sets_group_junctions", "campaigns",
62    "character_skill_node_sets", "character_skill_nodes", "character_trait_levels", "commander_unit_permissions", "commodities",
63    "cultures_subcultures", "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_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", "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/// Suffix of the campaign map keys, which the region lookup image's name lacks.
92const MAP_KEY_SUFFIX: &str = "_map";
93
94/// Suffix of the region lookup image's name.
95const LOOKUP_IMAGE_SUFFIX: &str = "_lookup.tga";
96
97/// TGA image type of colour-mapped images.
98const TGA_COLOUR_MAPPED: u8 = 1;
99
100/// Size of a TGA header.
101const TGA_HEADER_SIZE: usize = 18;
102
103//---------------------------------------------------------------------------//
104//                              Types
105//---------------------------------------------------------------------------//
106
107/// What a campaign's startpos is built from.
108pub struct StartposSources {
109
110    /// Campaign key.
111    campaign: String,
112
113    /// Campaign map key.
114    map: String,
115
116    /// Localised name of the season the campaign starts in, empty if it has none.
117    season_name: String,
118
119    /// RGB colours of the campaign map's region lookup image, by palette index. Empty without the image.
120    region_palette: Vec<[u8; 3]>,
121
122    /// Tables the build reads.
123    tables: StartposTables,
124
125    /// The campaign map's `map_data.esf`.
126    map_data: ESF,
127
128    /// The campaign map's `hlp_data.esf`.
129    hlp_data: ESF,
130
131    /// The campaign map's `dynamic_resources.esf`.
132    dynamic_resources: ESF,
133
134    /// The campaign map's `trade_routes.ptd`.
135    trade_routes: TradeRoutes,
136
137    /// Victory objectives of the campaigns (`db/victory_objectives.txt`).
138    victory_objectives: Vec<VictoryObjectives>,
139
140    /// `start_pos_characters` ids of the characters with a legacy override text.
141    legacy_overrides: HashSet<String>,
142
143    /// Values the campaign's scripts save when the game builds the startpos, or `None` if they couldn't run.
144    script_variables: Option<Vec<(String, LuaSavedValue)>>,
145}
146
147/// Where a file is looked for: the pack first, then the game's files.
148struct FileSource<'a> {
149    pack: &'a Pack,
150    dependencies: &'a Dependencies,
151}
152
153//---------------------------------------------------------------------------//
154//                              Implementations
155//---------------------------------------------------------------------------//
156
157impl StartposSources {
158
159    /// Collects the sources of a campaign's startpos from its tables (see [`load_tables`]), a pack and the game's
160    /// files. Files come from the pack if it has them, otherwise from the game.
161    ///
162    /// # Arguments
163    ///
164    /// * `tables` - The tables of the build.
165    /// * `dependencies` - The game's files.
166    /// * `pack` - Pack with the campaign's startpos tables.
167    /// * `campaign` - Campaign key.
168    ///
169    /// # Errors
170    ///
171    /// Returns an error if a file is missing or can't be decoded.
172    pub fn from_tables(tables: StartposTables, dependencies: &Dependencies, pack: &Pack, campaign: &str) -> Result<Self> {
173        let map = tables.table("campaigns")?.rows()
174            .find_map(|row| (row.str("campaign_name").ok()? == campaign).then(|| row.str("map_name").ok()).flatten())
175            .ok_or_else(|| RLibError::StartposInvalidValue("campaigns".to_owned(), "campaign_name".to_owned(), campaign.to_owned()))?
176            .into_owned();
177
178        let files = FileSource { pack, dependencies };
179        let map_file = |name: &str| files.bytes(&format!("campaign_maps/{map}/{name}"));
180        let esf = |name: &str| -> Result<ESF> { ESF::decode(&mut Cursor::new(map_file(name)?), &None) };
181        let victory_text = files.bytes(VICTORY_OBJECTIVES_FILE_NAME)?;
182        let lookup_image = format!("{}{LOOKUP_IMAGE_SUFFIX}", map.strip_suffix(MAP_KEY_SUFFIX).unwrap_or(&map));
183        let region_palette = match map_file(&lookup_image) {
184            Ok(bytes) => tga_palette(&bytes)?,
185            Err(_) => vec![],
186        };
187        let season = Calendar::new(&tables, campaign)?.start_season().to_owned();
188
189        Ok(Self {
190            campaign: campaign.to_owned(),
191            season_name: loc_text(dependencies, pack, &format!("{SEASON_NAME_PREFIX}{season}")).unwrap_or_default(),
192            region_palette,
193            map_data: esf("map_data.esf")?,
194            hlp_data: esf("hlp_data.esf")?,
195            dynamic_resources: esf("dynamic_resources.esf")?,
196            trade_routes: TradeRoutes::decode(&mut Cursor::new(map_file("trade_routes.ptd")?), &None)?,
197            victory_objectives: parse_victory_objectives(&String::from_utf8_lossy(&victory_text))?,
198            legacy_overrides: legacy_overrides(dependencies, pack),
199            script_variables: script_variables(dependencies, pack, campaign),
200            map,
201            tables,
202        })
203    }
204}
205
206/// Loads the tables of a startpos build: the ones the build reads, every `start_pos_*` table of the schema with the
207/// tables it references, for validation, and the ones the extraction of tables from a startpos reads.
208///
209/// Each table merges the game's fragments and then the pack's, in load order: a row replaces an earlier row with the
210/// same key. Tables neither has come from the game's Assembly Kit.
211///
212/// # Arguments
213///
214/// * `dependencies` - The game's files.
215/// * `pack` - Pack with the campaign's startpos tables.
216/// * `schema` - Schema of the game, to decode the tables.
217///
218/// # Errors
219///
220/// Returns an error if a table can't be decoded, or its fragments don't share their columns.
221pub fn load_tables(dependencies: &Dependencies, pack: &Pack, schema: &Schema) -> Result<StartposTables> {
222    let mut extra = DecodeableExtraData::default();
223    extra.set_schema(Some(schema));
224    let extra = Some(extra);
225
226    let mut names = STARTPOS_TABLES.iter().chain(&EXTRACTION_TABLES).map(|name| name.to_string()).collect::<BTreeSet<_>>();
227    names.extend(referenced_tables(schema));
228    names.extend(schema.definitions().keys().filter(|name| name.starts_with("start_pos_")).map(|name| name.trim_end_matches("_tables").to_owned()));
229
230    let mut tables = StartposTables::new();
231    for table in &names {
232        let folder = format!("db/{table}_tables/");
233        let mut fragments = vec![];
234        for file in dependencies.db_data(&format!("{table}_tables"), true, true)? {
235            fragments.extend(decoded_db(file, &extra)?);
236        }
237
238        let mut pack_files = pack.files().iter().filter(|(path, _)| path.starts_with(&folder)).collect::<Vec<_>>();
239        pack_files.sort_by_key(|(path, _)| *path);
240        let mut pack_fragments = vec![];
241        for (_, file) in pack_files {
242            pack_fragments.extend(decoded_db(file, &extra)?);
243        }
244
245        if fragments.is_empty() && pack_fragments.is_empty() {
246            fragments.extend(dependencies.asskit_only_db_tables().get(&format!("{table}_tables")).cloned());
247        }
248        fragments.extend(pack_fragments);
249        add_merged(&mut tables, table, &fragments)?;
250    }
251    Ok(tables)
252}
253
254/// Loads the startpos of a campaign (`campaigns/<campaign>/startpos.esf`), from the pack if it has it, otherwise from
255/// the game's files.
256///
257/// # Arguments
258///
259/// * `dependencies` - The game's files.
260/// * `pack` - Pack that may have the startpos.
261/// * `campaign` - Campaign key.
262///
263/// # Errors
264///
265/// Returns an error if neither has the startpos, or it can't be decoded.
266pub fn load_startpos(dependencies: &Dependencies, pack: &Pack, campaign: &str) -> Result<ESF> {
267    let files = FileSource { pack, dependencies };
268    let bytes = files.bytes(&format!("{CAMPAIGNS_FOLDER}/{campaign}/{STARTPOS_FILE}"))?;
269    ESF::decode(&mut Cursor::new(bytes), &None)
270}
271
272impl FileSource<'_> {
273
274    /// Returns the bytes of a file, from the pack if it has it, otherwise from the game's files.
275    fn bytes(&self, path: &str) -> Result<Vec<u8>> {
276        let file = match self.pack.file(path, true) {
277            Some(file) => file,
278            None => self.dependencies.file(path, true, true, true)?,
279        };
280
281        // Files are cloned so the pack's and the cache's copies keep their state.
282        file.clone().encode(&None, false, false, true)?.ok_or_else(|| RLibError::FileNotFound(path.to_owned()))
283    }
284}
285
286/// Generates the startpos of a campaign.
287///
288/// # Arguments
289///
290/// * `sources` - What the startpos is built from.
291/// * `layout` - Record layouts of the game.
292///
293/// # Errors
294///
295/// Returns an error if the sources lack data the build needs.
296pub fn generate_startpos(sources: &StartposSources, layout: &StartposLayout) -> Result<ESF> {
297    generate_startpos_with_plan(sources, layout, |index| WorldPlan::new(index))
298}
299
300/// Generates the startpos of a campaign with the orders and ids of its world given by a plan.
301pub(crate) fn generate_startpos_with_plan(sources: &StartposSources, layout: &StartposLayout, plan: impl for<'i> FnOnce(&'i CampaignIndex) -> WorldPlan<'i>) -> Result<ESF> {
302    let index = CampaignIndex::new(&sources.tables, &sources.campaign)?;
303    let settlements = SettlementLayout::all_from_map_data(&sources.map_data, |region| index.settlement_of(region).is_some_and(|settlement| settlement.is_major()))?;
304    let region_map = RegionMap::from_map_data(&sources.map_data)?;
305    let world = WorldInputs {
306        tables: &sources.tables,
307        index: &index,
308        layout,
309        settlements: &settlements,
310        region_map: &region_map,
311        legacy_overrides: &sources.legacy_overrides,
312    };
313
314    let trade_network = TradeNetwork::from_trade_routes(&sources.trade_routes, &region_map.scale());
315    let inputs = StartposInputs {
316        world: &world,
317        trade_network: &trade_network,
318        victory_objectives: &sources.victory_objectives,
319        map_data: &sources.map_data,
320        hlp_data: &sources.hlp_data,
321        dynamic_resources: &sources.dynamic_resources,
322        campaign: &sources.campaign,
323        map: &sources.map,
324        season_name: &sources.season_name,
325        region_palette: &sources.region_palette,
326        script_variables: sources.script_variables.as_deref(),
327    };
328    write_startpos(&inputs, &plan(&index))
329}
330
331/// Record layouts of a game: one per campaign the game has a startpos for, and one merged from all of them.
332#[derive(Debug, Clone)]
333pub struct GameLayouts {
334
335    /// Layout merged from the startposes of all the game's campaigns.
336    merged: StartposLayout,
337
338    /// Layout of each of the game's campaigns, by campaign key.
339    campaigns: BTreeMap<String, StartposLayout>,
340}
341
342impl GameLayouts {
343
344    /// Loads the layouts of a game, extracting them from the game's startposes if they aren't cached or one of the
345    /// startposes is newer than the cache (like after a patch, or a new campaign).
346    ///
347    /// # Arguments
348    ///
349    /// * `layouts_folder` - Folder with the cached layouts.
350    /// * `game` - The game.
351    /// * `game_data_path` - The game's data folder, with `campaigns/<campaign>/startpos.esf`.
352    ///
353    /// # Errors
354    ///
355    /// Returns an error if the game has no startpos to take the layouts from, or one can't be decoded or cached.
356    pub fn load_or_extract(layouts_folder: &Path, game: &GameInfo, game_data_path: &Path) -> Result<Self> {
357        let startposes = game_startposes(game_data_path);
358        if startposes.is_empty() {
359            return Err(RLibError::StartposLayoutError(format!("the game has no startpos in {} to take the record layouts from", game_data_path.join(CAMPAIGNS_FOLDER).display())));
360        }
361
362        let merged_path = layouts_folder.join(layout_file_name(game.key(), None));
363        let campaign_path = |campaign: &str| layouts_folder.join(layout_file_name(game.key(), Some(campaign)));
364        let modified = |path: &Path| path.metadata().and_then(|metadata| metadata.modified()).ok();
365        let newest_startpos = startposes.values().filter_map(|path| modified(path)).max();
366        let is_current = |path: &Path| modified(path).is_some_and(|cached| newest_startpos.is_some_and(|newest| cached >= newest));
367
368        if is_current(&merged_path) && startposes.keys().all(|campaign| is_current(&campaign_path(campaign))) {
369            let cached = StartposLayout::load(&merged_path).and_then(|merged| {
370                let campaigns = startposes.keys().map(|campaign| Ok((campaign.to_owned(), StartposLayout::load(&campaign_path(campaign))?))).collect::<Result<_>>()?;
371                Ok(Self { merged, campaigns })
372            });
373            if let Ok(layouts) = cached {
374                return Ok(layouts);
375            }
376        }
377
378        // Each startpos is decoded once, for its own layout and for the merged one.
379        let mut campaigns = BTreeMap::new();
380        let merged = StartposLayout::from_esfs(startposes.iter().map(|(campaign, path)| {
381            let startpos = ESF::decode(&mut BufReader::new(File::open(path)?), &None)?;
382            campaigns.insert(campaign.to_owned(), StartposLayout::from_esf(&startpos)?);
383            Ok(startpos)
384        }))?;
385
386        std::fs::create_dir_all(layouts_folder)?;
387        for (campaign, layout) in &campaigns {
388            layout.save(&campaign_path(campaign))?;
389        }
390        merged.save(&merged_path)?;
391        Ok(Self { merged, campaigns })
392    }
393
394    /// Returns the layout to write a campaign's startpos with: the campaign's own if the game has its startpos,
395    /// otherwise the one of a game campaign on the same map, otherwise the merged one. The campaign's or map's layout
396    /// takes the records it lacks from the merged one.
397    ///
398    /// # Arguments
399    ///
400    /// * `tables` - Tables with `campaigns`, to find the campaigns on the same map.
401    /// * `campaign` - Campaign key.
402    pub fn for_campaign(&self, tables: &StartposTables, campaign: &str) -> StartposLayout {
403        let maps = tables.table("campaigns").map(|table| table.rows()
404            .filter_map(|row| Some((row.str("campaign_name").ok()?.into_owned(), row.str("map_name").ok()?.into_owned())))
405            .collect::<HashMap<_, _>>())
406            .unwrap_or_default();
407        let map = maps.get(campaign);
408
409        let primary = self.campaigns.get(campaign)
410            .or_else(|| self.campaigns.iter().find(|(other, _)| map.is_some() && maps.get(other.as_str()) == map).map(|(_, layout)| layout));
411        match primary {
412            Some(layout) => layout.clone().with_fallback(&self.merged),
413            None => self.merged.clone(),
414        }
415    }
416}
417
418/// Returns the paths of the game's startposes, by campaign key.
419fn game_startposes(game_data_path: &Path) -> BTreeMap<String, PathBuf> {
420    std::fs::read_dir(game_data_path.join(CAMPAIGNS_FOLDER))
421        .map(|entries| entries.flatten()
422            .filter_map(|entry| Some((entry.file_name().into_string().ok()?, entry.path().join(STARTPOS_FILE))))
423            .filter(|(_, path)| path.is_file())
424            .collect())
425        .unwrap_or_default()
426}
427
428/// Returns a decoded DB from a file, decoding a copy of it if needed. Empty files of tables without definitions
429/// hold no rows, so they're skipped.
430fn decoded_db(file: &RFile, extra: &Option<DecodeableExtraData>) -> Result<Option<DB>> {
431    if let Ok(RFileDecoded::DB(db)) = file.decoded() {
432        return Ok(Some(db.clone()));
433    }
434    match file.clone().decode(extra, false, true) {
435        Ok(Some(RFileDecoded::DB(db))) => Ok(Some(db)),
436        Err(RLibError::DecodingDBNoDefinitionsFoundAndEmptyFile) => Ok(None),
437        Err(error) => Err(error),
438        Ok(_) => Err(RLibError::DecodingDBNotADBTable),
439    }
440}
441
442/// Adds a table merged from its fragments, in load order: a row replaces an earlier row with the same key, in place.
443fn add_merged(tables: &mut StartposTables, table: &str, fragments: &[DB]) -> Result<()> {
444    let Some(first) = fragments.first() else { return Ok(()) };
445    let columns = first.definition().fields_processed().iter().map(|field| field.name().to_owned()).collect::<Vec<_>>();
446
447    let mut rows: Vec<Vec<DecodedData>> = vec![];
448    let mut positions = HashMap::new();
449    for fragment in fragments {
450        let fragment_columns = fragment.definition().fields_processed().iter().map(|field| field.name().to_owned()).collect::<Vec<_>>();
451        let order = columns.iter()
452            .map(|column| fragment_columns.iter().position(|other| other == column).ok_or_else(|| RLibError::StartposMissingColumn(table.to_owned(), column.to_owned())))
453            .collect::<Result<Vec<_>>>()?;
454        let keys = fragment.definition().key_column_positions();
455
456        for row in fragment.data().iter() {
457            let key = keys.iter().map(|&column| row[column].data_to_string().into_owned()).collect::<Vec<_>>();
458            let row = order.iter().map(|&source| row[source].clone()).collect::<Vec<_>>();
459            match (keys.is_empty(), positions.get(&key)) {
460                (false, Some(&position)) => rows[position] = row,
461                (false, None) => {
462                    positions.insert(key, rows.len());
463                    rows.push(row);
464                },
465                (true, _) => rows.push(row),
466            }
467        }
468    }
469    tables.add_rows(table, &columns, &rows)
470}
471
472/// Returns the `start_pos_characters` ids with a legacy override text, in the pack's or the game's localisation.
473fn legacy_overrides(dependencies: &Dependencies, pack: &Pack) -> HashSet<String> {
474    let game_texts = dependencies.localisation_data().iter().map(|(key, text)| (key.to_owned(), text.to_owned()));
475    let pack_texts = pack.files_by_type(&[FileType::Loc]).into_iter().flat_map(loc_texts);
476    game_texts.chain(pack_texts)
477        .filter(|(_, text)| !text.is_empty())
478        .filter_map(|(key, _)| key.strip_prefix(LEGACY_OVERRIDE_PREFIX).map(str::to_owned))
479        .collect()
480}
481
482/// Returns the values a campaign's scripts save when the game builds its startpos, running the scripts of the game and
483/// the pack, or `None` if they can't run or the campaign's scripts aren't there.
484fn script_variables(dependencies: &Dependencies, pack: &Pack, campaign: &str) -> Option<Vec<(String, LuaSavedValue)>> {
485    let scripts = LuaScripts::from_game_and_packs(dependencies, [pack]);
486    let campaign_script = format!("{CAMPAIGNS_FOLDER}/{campaign}/{CAMPAIGN_SCRIPT}");
487    if !scripts.contains(&campaign_script) {
488        log::warn!("{campaign_script} isn't in the pack nor the game's files, so the startpos keeps the script variables of its layout");
489        return None;
490    }
491
492    let mut options = LuaTestOptions::default();
493    options.set_campaign(Some(campaign.to_owned()));
494
495    // The games with startposes have no scripting docs, so the engine has no typed methods.
496    match save_campaign(&LuaApi::default(), &scripts, &options, &STARTPOS_BUILD_EVENTS) {
497        Ok(save) => {
498            for error in save.errors() {
499                log::warn!("The scripts of {campaign} raised an error while saving its startpos: {error}");
500            }
501            Some(save.values().to_owned())
502        },
503        Err(error) => {
504            log::warn!("The scripts of {campaign} couldn't run, so its startpos keeps the script variables of its layout: {error}");
505            None
506        },
507    }
508}
509
510/// Returns the text of a localisation key, from the pack if it has it, else from the game.
511fn loc_text(dependencies: &Dependencies, pack: &Pack, key: &str) -> Option<String> {
512    pack.files_by_type(&[FileType::Loc]).into_iter()
513        .flat_map(loc_texts)
514        .find_map(|(loc_key, text)| (loc_key == key).then_some(text))
515        .or_else(|| dependencies.localisation_data().get(key).cloned())
516}
517
518/// Returns the RGB colours of a colour-mapped TGA image's palette, by palette index.
519///
520/// # Errors
521///
522/// Returns an error if the image isn't colour-mapped, or is cut short.
523pub(crate) fn tga_palette(bytes: &[u8]) -> Result<Vec<[u8; 3]>> {
524    let invalid = || RLibError::CampaignMapDataError("the region lookup image isn't a colour-mapped TGA".to_owned());
525    let header = bytes.get(..TGA_HEADER_SIZE).ok_or_else(invalid)?;
526    if header[2] != TGA_COLOUR_MAPPED {
527        return Err(invalid());
528    }
529
530    // Header bytes: 0 id length, 3-4 first palette index, 5-6 palette length, 7 bits per palette entry (BGR order).
531    let start = TGA_HEADER_SIZE + usize::from(header[0]);
532    let length = usize::from(u16::from_le_bytes([header[5], header[6]]));
533    let entry_size = usize::from(header[7]) / 8;
534    let palette = bytes.get(start..start + length * entry_size).ok_or_else(invalid)?;
535    if entry_size < 3 {
536        return Err(invalid());
537    }
538    Ok(palette.chunks_exact(entry_size).map(|entry| [entry[2], entry[1], entry[0]]).collect())
539}
540
541/// Returns the (key, text) pairs of a localisation file, or none if it can't be decoded.
542fn loc_texts(file: &RFile) -> Vec<(String, String)> {
543    let decoded = match file.decoded() {
544        Ok(decoded) => Some(decoded.clone()),
545        Err(_) => file.clone().decode(&None, false, true).ok().flatten(),
546    };
547    match decoded {
548        Some(RFileDecoded::Loc(loc)) => loc.data().iter()
549            .filter_map(|row| Some((row.first()?.data_to_string().into_owned(), row.get(1)?.data_to_string().into_owned())))
550            .collect(),
551        _ => vec![],
552    }
553}