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, 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
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_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
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
91pub(crate) const FACTION_SCREEN_NAME_PREFIX: &str = "factions_screen_name_";
93
94pub(crate) const CHARACTER_NAME_PREFIX: &str = "names_name_";
96
97const MAP_KEY_SUFFIX: &str = "_map";
99
100const LOOKUP_IMAGE_SUFFIX: &str = "_lookup.tga";
102
103const TGA_COLOUR_MAPPED: u8 = 1;
105
106const 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
143const TGA_HEADER_SIZE: usize = 18;
145
146#[derive(Clone, Copy)]
152enum OptionalValue {
153 Text,
154 Integer,
155 Boolean,
156}
157
158pub struct StartposSources {
160
161 game: String,
163
164 campaign: String,
166
167 map: String,
169
170 season_name: String,
172
173 texts: HashMap<String, String>,
175
176 region_palette: Vec<[u8; 3]>,
178
179 tables: StartposTables,
181
182 map_data: ESF,
184
185 hlp_data: ESF,
187
188 dynamic_resources: ESF,
190
191 trade_routes: TradeRoutes,
193
194 victory_objectives: Vec<VictoryObjectives>,
196
197 legacy_overrides: HashSet<String>,
199
200 script_variables: Option<Vec<(String, LuaSavedValue)>>,
202}
203
204struct FileSource<'a> {
206 pack: &'a Pack,
207 dependencies: &'a Dependencies,
208}
209
210impl StartposSources {
215
216 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
266pub 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 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
329pub 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 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 file.clone().encode(&None, false, false, true)?.ok_or_else(|| RLibError::FileNotFound(path.to_owned()))
358 }
359}
360
361pub fn generate_startpos(sources: &StartposSources, layout: &StartposLayout) -> Result<ESF> {
372 generate_startpos_with_plan(sources, layout, |index| WorldPlan::new(index))
373}
374
375pub(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: ®ion_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, ®ion_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#[derive(Debug, Clone)]
412pub struct GameLayouts {
413
414 merged: StartposLayout,
416
417 campaigns: BTreeMap<String, StartposLayout>,
419}
420
421impl GameLayouts {
422
423 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 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 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
497fn 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
507fn 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
521fn 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
551fn 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
561fn 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 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
589fn 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 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
609fn 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
617pub(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 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
640fn 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}