1use 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
53pub const SUPPORTED_GAMES: [&str; 1] = [KEY_ATTILA];
55
56pub 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
74const LEGACY_OVERRIDE_PREFIX: &str = "start_pos_characters_legacy_override_";
76
77const CAMPAIGNS_FOLDER: &str = "campaigns";
80const CAMPAIGN_SCRIPT: &str = "scripting.lua";
81
82const STARTPOS_FILE: &str = "startpos.esf";
84
85const STARTPOS_BUILD_EVENTS: [&str; 2] = ["NewSession", "WorldCreated"];
87
88const SEASON_NAME_PREFIX: &str = "seasons_onscreen_";
90
91const MAP_KEY_SUFFIX: &str = "_map";
93
94const LOOKUP_IMAGE_SUFFIX: &str = "_lookup.tga";
96
97const TGA_COLOUR_MAPPED: u8 = 1;
99
100const TGA_HEADER_SIZE: usize = 18;
102
103pub struct StartposSources {
109
110 campaign: String,
112
113 map: String,
115
116 season_name: String,
118
119 region_palette: Vec<[u8; 3]>,
121
122 tables: StartposTables,
124
125 map_data: ESF,
127
128 hlp_data: ESF,
130
131 dynamic_resources: ESF,
133
134 trade_routes: TradeRoutes,
136
137 victory_objectives: Vec<VictoryObjectives>,
139
140 legacy_overrides: HashSet<String>,
142
143 script_variables: Option<Vec<(String, LuaSavedValue)>>,
145}
146
147struct FileSource<'a> {
149 pack: &'a Pack,
150 dependencies: &'a Dependencies,
151}
152
153impl StartposSources {
158
159 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
206pub 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
254pub 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 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 file.clone().encode(&None, false, false, true)?.ok_or_else(|| RLibError::FileNotFound(path.to_owned()))
283 }
284}
285
286pub fn generate_startpos(sources: &StartposSources, layout: &StartposLayout) -> Result<ESF> {
297 generate_startpos_with_plan(sources, layout, |index| WorldPlan::new(index))
298}
299
300pub(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: ®ion_map,
311 legacy_overrides: &sources.legacy_overrides,
312 };
313
314 let trade_network = TradeNetwork::from_trade_routes(&sources.trade_routes, ®ion_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#[derive(Debug, Clone)]
333pub struct GameLayouts {
334
335 merged: StartposLayout,
337
338 campaigns: BTreeMap<String, StartposLayout>,
340}
341
342impl GameLayouts {
343
344 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 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 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
418fn 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
428fn 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
442fn 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
472fn 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
482fn 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 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
510fn 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
518pub(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 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
541fn 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}