Skip to main content

rpfm_server/
ceo_builder.rs

1//! CEO Builder backend logic.
2//!
3//! Contains the functions for building CEO entries, importing ceo_data.ccd,
4//! and querying trait CEOs from the Assembly Kit data.
5
6use anyhow::{anyhow, Result};
7
8use std::collections::{BTreeMap, HashMap, HashSet};
9use std::path::{Path, PathBuf};
10use std::process::{Command as SysCommand, Stdio};
11use std::slice::from_ref;
12use std::time::Instant;
13
14use rpfm_lib::files::{Container, ContainerPath, db::DB, DecodeableExtraData, FileType, loc::Loc, pack::Pack, RFile, RFileDecoded, table::DecodedData};
15use rpfm_lib::schema::*;
16
17use rpfm_ipc::api::tools::CeoEntryData;
18
19use rpfm_telemetry::*;
20
21/// Build CEO entries (armour, career, traits, loc) from structured input data.
22///
23/// This is the core logic for the `BuildCeoEntries` command, extracted to keep
24/// the background thread function manageable and to help the compiler optimize.
25pub fn build_ceo_entries(
26    pack: &mut Pack,
27    schema: &Schema,
28    entries: &[CeoEntryData],
29) -> Result<Vec<ContainerPath>> {
30        let mut added_paths: Vec<ContainerPath> = Vec::new();
31
32        // ── helpers ────────────────────────────────────────────
33        // Stable CRC32-based synthetic ID for auto_id fields.
34        // These columns exist only in AKit XML, not in binary pack format.
35        fn crc32_id(data: &[u8]) -> i64 {
36            let mut crc: u32 = 0xFFFF_FFFF;
37            for &b in data {
38                crc ^= b as u32;
39                for _ in 0..8 {
40                    if crc & 1 != 0 { crc = (crc >> 1) ^ 0xEDB8_8320; }
41                    else { crc >>= 1; }
42                }
43            }
44            (1_500_000_000u32 + (!crc % 647_000_000u32)) as i64
45        }
46
47        fn auto_id(table: &str, row_key: &str) -> i64 {
48            crc32_id(format!("{}|{}", table, row_key).as_bytes())
49        }
50
51        // Get-or-create a DB table file in the pack.
52        // Returns the path so the caller can track it.
53        fn get_or_create_db(
54            pack: &mut Pack,
55            schema: &Schema,
56            table_name: &str,
57            file_stem: &str,
58        ) -> Result<String> {
59            let path = format!("ceo_db/{}/{}", table_name, file_stem);
60            let container_path = ContainerPath::File(path.clone());
61
62            // If the file doesn't exist yet, create it.
63            if pack.files_by_paths(from_ref(&container_path), false).is_empty() {
64                let _full_table_name = format!("{}_tables", table_name.trim_end_matches("_tables"));
65                let table_name_with_suffix = if table_name.ends_with("_tables") {
66                    table_name.to_string()
67                } else {
68                    format!("{}_tables", table_name)
69                };
70
71                let def = schema.definitions_by_table_name(&table_name_with_suffix)
72                    .and_then(|defs| defs.first())
73                    .ok_or_else(|| anyhow!("No schema definition for {}", table_name_with_suffix))?;
74
75                let patches = schema.patches_for_table(&table_name_with_suffix);
76                let db = DB::new(def, patches, &table_name_with_suffix);
77                let rfile = RFile::new_from_decoded(
78                    &RFileDecoded::DB(db), 0, &path
79                );
80                pack.insert(rfile)?;
81            }
82            Ok(path)
83        }
84
85        // Insert a row into a DB table. Builds the row by matching field names
86        // from the definition against the provided HashMap of name→value strings.
87        fn insert_row(
88            pack: &mut Pack,
89            schema: &Schema,
90            table_name: &str,
91            file_stem: &str,
92            values: &std::collections::HashMap<&str, String>,
93        ) -> Result<ContainerPath> {
94            let path = get_or_create_db(pack, schema, table_name, file_stem)?;
95            let container_path = ContainerPath::File(path.clone());
96
97            let _table_name_with_suffix = if table_name.ends_with("_tables") {
98                table_name.to_string()
99            } else {
100                format!("{}_tables", table_name)
101            };
102
103            let mut files = pack.files_by_type_and_paths_mut(
104                &[FileType::DB],
105                from_ref(&container_path),
106                true,
107            );
108
109            if let Some(file) = files.first_mut() {
110                if let Ok(RFileDecoded::DB(db)) = file.decoded_mut() {
111                    let fields = db.definition().fields_processed().to_vec();
112                    let mut row: Vec<DecodedData> = Vec::new();
113
114                    for field in &fields {
115                        let fname = field.name();
116                        let raw = values.get(fname).map(|s| s.as_str()).unwrap_or("");
117
118                        let cell = match field.field_type() {
119                            FieldType::Boolean =>
120                                DecodedData::Boolean(raw == "true" || raw == "1"),
121                            FieldType::I16 =>
122                                DecodedData::I16(raw.parse().unwrap_or(0)),
123                            FieldType::I32 =>
124                                DecodedData::I32(raw.parse().unwrap_or(0)),
125                            FieldType::I64 =>
126                                DecodedData::I64(raw.parse().unwrap_or(0)),
127                            FieldType::F32 =>
128                                DecodedData::F32(raw.parse().unwrap_or(0.0)),
129                            FieldType::F64 =>
130                                DecodedData::F64(raw.parse().unwrap_or(0.0)),
131                            FieldType::OptionalI16 =>
132                                DecodedData::OptionalI16(raw.parse().unwrap_or(0)),
133                            FieldType::OptionalI32 =>
134                                DecodedData::OptionalI32(raw.parse().unwrap_or(0)),
135                            FieldType::OptionalI64 =>
136                                DecodedData::OptionalI64(raw.parse().unwrap_or(0)),
137                            FieldType::OptionalStringU8 =>
138                                DecodedData::OptionalStringU8(raw.to_string()),
139                            FieldType::OptionalStringU16 =>
140                                DecodedData::OptionalStringU16(raw.to_string()),
141                            FieldType::StringU16 =>
142                                DecodedData::StringU16(raw.to_string()),
143                            _ =>
144                                DecodedData::StringU8(raw.to_string()),
145                        };
146                        row.push(cell);
147                    }
148                    db.data_mut().push(row);
149                }
150            }
151            Ok(container_path)
152        }
153
154        // ── loc helper ─────────────────────────────────────────
155        fn insert_loc_entries(
156            pack: &mut Pack,
157            loc_path: &str,
158            entries: &[(&str, &str)], // (key, text)
159        ) -> Result<ContainerPath> {
160            let container_path = ContainerPath::File(loc_path.to_string());
161
162            if pack.files_by_paths(from_ref(&container_path), false).is_empty() {
163                let loc = Loc::new();
164                let rfile = RFile::new_from_decoded(
165                    &RFileDecoded::Loc(loc), 0, loc_path
166                );
167                pack.insert(rfile)?;
168            }
169
170            let mut files = pack.files_by_type_and_paths_mut(
171                &[FileType::Loc],
172                from_ref(&container_path),
173                true,
174            );
175
176            if let Some(file) = files.first_mut() {
177                if let Ok(RFileDecoded::Loc(loc)) = file.decoded_mut() {
178                    for (key, text) in entries {
179                        loc.data_mut().push(vec![
180                            DecodedData::StringU16(key.to_string()),
181                            DecodedData::StringU16(text.to_string()),
182                            DecodedData::Boolean(true),
183                        ]);
184                    }
185                }
186            }
187
188            Ok(container_path)
189        }
190
191        // ── macro shorthand ────────────────────────────────────
192        macro_rules! row {
193            ($($k:expr => $v:expr),* $(,)?) => {{
194                let mut m = std::collections::HashMap::new();
195                $(m.insert($k, $v.to_string());)*
196                m
197            }};
198        }
199
200        // ── file stem shared across all entries ────────────────
201        let stem = format!("ceo_{}", entries.first().map(|e| e.name.as_str()).unwrap_or("builder"));
202        let stem = stem.as_str();
203        let loc_path = format!("text/ceo/general_items/{}.loc", stem);
204
205        // ── process each entry ─────────────────────────────────
206        let mut loc_entries: Vec<(String, String)> = Vec::new();
207
208        for entry in entries {
209            let n = &entry.name;
210            let element = &entry.element;
211            let gender = &entry.gender;
212            let is_unique = entry.option == "unique";
213
214            if is_unique {
215                // ── UNIQUE PATH ────────────────────────────────
216
217                // ceos — armor
218                let p = insert_row(pack, schema, "ceos_tables", stem, &row![
219                    "key" => format!("3k_main_ancilliary_armour_{n}_armour_unique"),
220                    "exists_in_location" => "character_ceo_manager",
221                    "category" => "3k_main_ceo_category_ancillary_armour",
222                    "equipped_in_location" => "character_equipment",
223                    "priority" => "1",
224                    "turns_to_expire" => "0",
225                    "point_change_per_turn_if_inactive" => "0",
226                    "point_change_per_turn_while_active" => "0",
227                    "point_change_per_turn_while_equipped" => "0",
228                    "inheritance_chance" => "0",
229                    "can_be_looted_post_battle" => "false",
230                    "can_be_traded_in_diplomacy" => "false",
231                    "can_be_stolen" => "false",
232                    "rarity" => "unique",
233                    "can_be_unequipped" => "false",
234                    "can_be_transferred_if_equipped" => "true",
235                    "cannot_reequip_until_next_round_if_unequipped" => "true",
236                    "provides_scripted_permissions_on_spawn" => "",
237                ])?;
238                if !added_paths.contains(&p) { added_paths.push(p); }
239
240                // ceos — career/title
241                let p = insert_row(pack, schema, "ceos_tables", stem, &row![
242                    "key" => format!("3k_main_ceo_career_historical_{n}"),
243                    "exists_in_location" => "character_ceo_manager",
244                    "category" => "3k_main_ceo_category_career",
245                    "equipped_in_location" => "character_equipment",
246                    "priority" => "1",
247                    "turns_to_expire" => "0",
248                    "point_change_per_turn_if_inactive" => "0",
249                    "point_change_per_turn_while_active" => "0",
250                    "point_change_per_turn_while_equipped" => "0",
251                    "inheritance_chance" => "0",
252                    "can_be_looted_post_battle" => "false",
253                    "can_be_traded_in_diplomacy" => "false",
254                    "can_be_stolen" => "false",
255                    "rarity" => "common",
256                    "can_be_unequipped" => "false",
257                    "can_be_transferred_if_equipped" => "true",
258                    "cannot_reequip_until_next_round_if_unequipped" => "true",
259                    "provides_scripted_permissions_on_spawn" => "",
260                ])?;
261                if !added_paths.contains(&p) { added_paths.push(p); }
262
263                // ceo_groups
264                let p = insert_row(pack, schema, "ceo_groups_tables", stem, &row![
265                    "key" => format!("3k_main_ceo_group_ancillary_armour_character_specific_{n}"),
266                ])?;
267                if !added_paths.contains(&p) { added_paths.push(p); }
268
269                // ceo_group_ceos
270                let grp_key = format!("3k_main_ceo_group_ancillary_armour_character_specific_{n}");
271                let armor_key = format!("3k_main_ancilliary_armour_{n}_armour_unique");
272                let career_key_grp = format!("3k_main_ceo_career_historical_{n}");
273                // armour into character_specific group
274                let p = insert_row(pack, schema, "ceo_group_ceos_tables", stem, &row![
275                    "ceo_group" => &grp_key,
276                    "ceo" => &armor_key,
277                    "trigger_weighting" => "0.1",
278                    "auto_id" => auto_id("ceo_group_ceos", &format!("{grp_key}|{armor_key}")),
279                ])?;
280                if !added_paths.contains(&p) { added_paths.push(p); }
281                // armour into type_character_specific group
282                let p = insert_row(pack, schema, "ceo_group_ceos_tables", stem, &row![
283                    "ceo_group" => "3k_main_ceo_group_ancillary_armour_type_character_specific",
284                    "ceo" => &armor_key,
285                    "trigger_weighting" => "0.1",
286                    "auto_id" => auto_id("ceo_group_ceos", &format!("3k_main_ceo_group_ancillary_armour_type_character_specific|{armor_key}")),
287                ])?;
288                if !added_paths.contains(&p) { added_paths.push(p); }
289                // armour into character_all group
290                let p = insert_row(pack, schema, "ceo_group_ceos_tables", stem, &row![
291                    "ceo_group" => "3k_main_ceo_group_ancillary_armour_character_all",
292                    "ceo" => &armor_key,
293                    "trigger_weighting" => "0.1",
294                    "auto_id" => auto_id("ceo_group_ceos", &format!("3k_main_ceo_group_ancillary_armour_character_all|{armor_key}")),
295                ])?;
296                if !added_paths.contains(&p) { added_paths.push(p); }
297                // career into career_all group
298                let p = insert_row(pack, schema, "ceo_group_ceos_tables", stem, &row![
299                    "ceo_group" => "3k_main_ceo_group_career_all",
300                    "ceo" => &career_key_grp,
301                    "trigger_weighting" => "1",
302                    "auto_id" => auto_id("ceo_group_ceos", &format!("3k_main_ceo_group_career_all|{career_key_grp}")),
303                ])?;
304                if !added_paths.contains(&p) { added_paths.push(p); }
305
306                // ceo_permissions
307                let perm_key = format!("3k_main_ceo_permissions_ancillary_armour_character_specific_{n}");
308                let p = insert_row(pack, schema, "ceo_permissions_tables", stem, &row![
309                    "key" => &perm_key,
310                ])?;
311                if !added_paths.contains(&p) { added_paths.push(p); }
312
313                // ceo_permissions_groups
314                let p = insert_row(pack, schema, "ceo_permissions_groups_tables", stem, &row![
315                    "permissions" => &perm_key,
316                    "group" => &grp_key,
317                    "point_gain_enabled_override" => "true",
318                    "point_gain_disabled_override" => "false",
319                    "state_active_override" => "true",
320                    "state_inactive_override" => "false",
321                    "auto_id" => auto_id("ceo_permissions_groups", &format!("{perm_key}|{grp_key}")),
322                ])?;
323                if !added_paths.contains(&p) { added_paths.push(p); }
324
325                // ceo_scripted_permissions
326                let scripted_perm_key = format!("3k_main_ceo_permissions_ancillary_armour_character_specific_{n}");
327                let p = insert_row(pack, schema, "ceo_scripted_permissions_tables", stem, &row![
328                    "key" => &scripted_perm_key,
329                    "exists_in_and_provides_permission_to_location" => "character_ceo_manager",
330                ])?;
331                if !added_paths.contains(&p) { added_paths.push(p); }
332
333                // ceo_scripted_permissions_to_permissions
334                let p = insert_row(pack, schema, "ceo_scripted_permissions_to_permissions_tables", stem, &row![
335                    "scripted_permissions" => &scripted_perm_key,
336                    "permissions" => &perm_key,
337                    "auto_id" => auto_id("ceo_scripted_permissions_to_permissions", &format!("{scripted_perm_key}|{perm_key}")),
338                ])?;
339                if !added_paths.contains(&p) { added_paths.push(p); }
340
341                // ceo_initial_data_stages — two stages
342                let stage1_key = format!("3k_main_ceo_initial_data_stage_character_traits_historical_{n}");
343                let stage2_key = format!("3k_main_ceo_initial_data_character_historical_{n}_ancillaries");
344                for key in &[&stage1_key, &stage2_key] {
345                    let p = insert_row(pack, schema, "ceo_initial_data_stages_tables", stem, &row![
346                        "key" => *key,
347                    ])?;
348                    if !added_paths.contains(&p) { added_paths.push(p); }
349                }
350
351                // ceo_effect_lists — armor + career
352                let effect_list_armor = format!("3k_main_ancilliary_armour_{n}_armour_unique");
353                let effect_list_career = format!("3k_main_ceo_career_historical_{n}");
354                for key in &[&effect_list_armor, &effect_list_career] {
355                    let p = insert_row(pack, schema, "ceo_effect_lists_tables", stem, &row![
356                        "key" => *key,
357                    ])?;
358                    if !added_paths.contains(&p) { added_paths.push(p); }
359                }
360
361                // ceo_initial_datas
362                let initial_data_key = format!("3k_main_ceo_initial_data_character_historical_{n}");
363                let p = insert_row(pack, schema, "ceo_initial_datas_tables", stem, &row![
364                    "key" => &initial_data_key,
365                    "template_manager" => "character_ceo_manager",
366                ])?;
367                if !added_paths.contains(&p) { added_paths.push(p); }
368
369                // Element-specific: scripted permissions + equipments + active_ceos + to_stages
370                let armor_ceo_key = format!("3k_main_ancilliary_armour_{n}_armour_unique");
371                match element.as_str() {
372                    "metal" => {
373                        let armour_perm_key = format!("3k_main_ceo_permissions_ancillary_armour_character_specific_{n}");
374                        for scripted in &[
375                            "3k_main_ceo_permissions_ancillary_weapon_character_sword_dual_enable",
376                            "3k_main_ceo_permissions_ancillary_weapon_character_axe_dual_enable",
377                            "3k_main_ceo_permissions_ancillary_weapon_character_sword_one_handed_enable",
378                            "3k_main_ceo_permissions_ancillary_weapon_character_axe_one_handed_enable",
379                            "3k_ytr_ceo_permissions_ancillary_weapon_character_mace_dual_enable",
380                            armour_perm_key.as_str(),
381                        ] {
382                            let p = insert_row(pack, schema, "ceo_initial_data_scripted_permissions_tables", stem, &row![
383                                "initial_data_stage" => &stage2_key,
384                                "scripted_permissions" => *scripted,
385                                "auto_id" => auto_id("ceo_initial_data_scripted_permissions", &format!("{stage2_key}|{scripted}")),
386                            ])?;
387                            if !added_paths.contains(&p) { added_paths.push(p); }
388                        }
389                        for (category, equipped_ceo) in &[
390                            ("3k_main_ceo_category_ancillary_weapon", "3k_main_ancillary_weapon_double_edged_sword_common"),
391                            ("3k_main_ceo_category_ancillary_mount",  "3k_main_ancillary_mount_grey_horse"),
392                            ("3k_main_ceo_category_ancillary_armour", armor_ceo_key.as_str()),
393                        ] {
394                            let p = insert_row(pack, schema, "ceo_initial_data_equipments_tables", stem, &row![
395                                "initial_data_stage" => &stage2_key,
396                                "category" => *category,
397                                "equipped_ceo" => *equipped_ceo,
398                                "slot_index" => "0",
399                                "target" => "character_equipment",
400                                "auto_id" => auto_id("ceo_initial_data_equipments", &format!("{stage2_key}|{equipped_ceo}")),
401                            ])?;
402                            if !added_paths.contains(&p) { added_paths.push(p); }
403                        }
404                        for active_ceo in &[
405                            "3k_main_ancillary_weapon_single_edged_sword_common",
406                            "3k_ytr_ancillary_weapon_dual_maces_common",
407                            "3k_main_ancillary_weapon_one_handed_axe_common",
408                            "3k_main_ancillary_weapon_dual_swords_common",
409                            "3k_main_ancillary_weapon_double_edged_sword_common",
410                            "3k_main_ancillary_mount_grey_horse",
411                        ] {
412                            let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
413                                "initial_data_stage" => &stage2_key,
414                                "active_ceo" => *active_ceo,
415                                "starting_points_delta" => "0",
416                                "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{active_ceo}")),
417                            ])?;
418                            if !added_paths.contains(&p) { added_paths.push(p); }
419                        }
420                        // class into stage1
421                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
422                            "initial_data_stage" => &stage1_key,
423                            "active_ceo" => "3k_main_ceo_class_metal",
424                            "starting_points_delta" => "0",
425                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|3k_main_ceo_class_metal")),
426                        ])?;
427                        if !added_paths.contains(&p) { added_paths.push(p); }
428                        // armour active_ceo into stage2
429                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
430                            "initial_data_stage" => &stage2_key,
431                            "active_ceo" => &armor_ceo_key,
432                            "starting_points_delta" => "0",
433                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{armor_ceo_key}")),
434                        ])?;
435                        if !added_paths.contains(&p) { added_paths.push(p); }
436                        // expanded: sword_and_shield only if expanded
437                        if entry.expanded {
438                            let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
439                                "initial_data_stage" => &stage2_key,
440                                "active_ceo" => "3k_main_ancillary_weapon_sword_and_shield_common",
441                                "starting_points_delta" => "0",
442                                "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|3k_main_ancillary_weapon_sword_and_shield_common")),
443                            ])?;
444                            if !added_paths.contains(&p) { added_paths.push(p); }
445                        }
446                        // armour effect: expertise_mod
447                        let p = insert_row(pack, schema, "ceo_effect_list_to_effects_tables", stem, &row![
448                            "effect_list" => &effect_list_armor,
449                            "effect" => "3k_main_effect_character_attribute_expertise_mod",
450                            "value" => "18",
451                            "effect_scope" => "character_to_character_own",
452                            "optional_only_in_game_mode" => "",
453                            "auto_id" => auto_id("ceo_effect_list_to_effects", &format!("{effect_list_armor}|expertise")),
454                        ])?;
455                        if !added_paths.contains(&p) { added_paths.push(p); }
456                        for (id_stage, stage_num) in &[
457                            ("3k_main_ceo_initial_data_stage_character_childhood_metal", 17i32),
458                            ("3k_main_ceo_initial_data_equipment_permissions_unique_metal", 4i32),
459                        ] {
460                            let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
461                                "ceo_initial_data" => &initial_data_key,
462                                "initial_data_stage" => *id_stage,
463                                "stage" => stage_num,
464                            ])?;
465                            if !added_paths.contains(&p) { added_paths.push(p); }
466                        }
467                    },
468                    "water" => {
469                        for scripted in &[
470                            "3k_main_ceo_permissions_ancillary_weapon_character_sword_one_handed_enable",
471                            &format!("3k_main_ceo_permissions_ancillary_armour_character_specific_{n}"),
472                        ] {
473                            let p = insert_row(pack, schema, "ceo_initial_data_scripted_permissions_tables", stem, &row![
474                                "initial_data_stage" => &stage2_key,
475                                "scripted_permissions" => *scripted,
476                                "auto_id" => auto_id("ceo_initial_data_scripted_permissions", &format!("{stage2_key}|{scripted}")),
477                            ])?;
478                            if !added_paths.contains(&p) { added_paths.push(p); }
479                        }
480                        for (category, equipped_ceo) in &[
481                            ("3k_main_ceo_category_ancillary_mount",  "3k_main_ancillary_mount_black_horse"),
482                            ("3k_main_ceo_category_ancillary_weapon", "3k_main_ancillary_weapon_single_edged_sword_common"),
483                            ("3k_main_ceo_category_ancillary_armour", armor_ceo_key.as_str()),
484                        ] {
485                            let p = insert_row(pack, schema, "ceo_initial_data_equipments_tables", stem, &row![
486                                "initial_data_stage" => &stage2_key,
487                                "category" => *category,
488                                "equipped_ceo" => *equipped_ceo,
489                                "slot_index" => "0",
490                                "target" => "character_equipment",
491                                "auto_id" => auto_id("ceo_initial_data_equipments", &format!("{stage2_key}|{equipped_ceo}")),
492                            ])?;
493                            if !added_paths.contains(&p) { added_paths.push(p); }
494                        }
495                        for active_ceo in &[
496                            "3k_main_ancillary_weapon_single_edged_sword_common",
497                            "3k_main_ancillary_weapon_dual_swords_common",
498                            "3k_main_ancillary_weapon_double_edged_sword_common",
499                            "3k_main_ancillary_mount_black_horse",
500                        ] {
501                            let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
502                                "initial_data_stage" => &stage2_key,
503                                "active_ceo" => *active_ceo,
504                                "starting_points_delta" => "0",
505                                "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{active_ceo}")),
506                            ])?;
507                            if !added_paths.contains(&p) { added_paths.push(p); }
508                        }
509                        // armour active_ceo into stage2
510                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
511                            "initial_data_stage" => &stage2_key,
512                            "active_ceo" => &armor_ceo_key,
513                            "starting_points_delta" => "0",
514                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{armor_ceo_key}")),
515                        ])?;
516                        if !added_paths.contains(&p) { added_paths.push(p); }
517                        // class into stage1
518                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
519                            "initial_data_stage" => &stage1_key,
520                            "active_ceo" => "3k_main_ceo_class_water",
521                            "starting_points_delta" => "0",
522                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|3k_main_ceo_class_water")),
523                        ])?;
524                        if !added_paths.contains(&p) { added_paths.push(p); }
525                        // armour effect: cunning_mod
526                        let p = insert_row(pack, schema, "ceo_effect_list_to_effects_tables", stem, &row![
527                            "effect_list" => &effect_list_armor,
528                            "effect" => "3k_main_effect_character_attribute_cunning_mod",
529                            "value" => "18",
530                            "effect_scope" => "character_to_character_own",
531                            "optional_only_in_game_mode" => "",
532                            "auto_id" => auto_id("ceo_effect_list_to_effects", &format!("{effect_list_armor}|cunning")),
533                        ])?;
534                        if !added_paths.contains(&p) { added_paths.push(p); }
535                        for (id_stage, stage_num) in &[
536                            ("3k_main_ceo_initial_data_stage_character_childhood_water", 17i32),
537                            ("3k_main_ceo_initial_data_equipment_permissions_unique_water", 4i32),
538                        ] {
539                            let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
540                                "ceo_initial_data" => &initial_data_key,
541                                "initial_data_stage" => *id_stage,
542                                "stage" => stage_num,
543                            ])?;
544                            if !added_paths.contains(&p) { added_paths.push(p); }
545                        }
546                    },
547                    "earth" => {
548                        for scripted in &[
549                            "3k_main_ceo_permissions_ancillary_weapon_character_axe_dual_enable",
550                            "3k_main_ceo_permissions_ancillary_weapon_character_axe_one_handed_enable",
551                            &format!("3k_main_ceo_permissions_ancillary_armour_character_specific_{n}"),
552                            "3k_main_ceo_permissions_ancillary_weapon_character_sword_one_handed_enable",
553                            "3k_main_ceo_permissions_ancillary_weapon_character_sword_dual_enable",
554                        ] {
555                            let p = insert_row(pack, schema, "ceo_initial_data_scripted_permissions_tables", stem, &row![
556                                "initial_data_stage" => &stage2_key,
557                                "scripted_permissions" => *scripted,
558                                "auto_id" => auto_id("ceo_initial_data_scripted_permissions", &format!("{stage2_key}|{scripted}")),
559                            ])?;
560                            if !added_paths.contains(&p) { added_paths.push(p); }
561                        }
562                        for (category, equipped_ceo) in &[
563                            ("3k_main_ceo_category_ancillary_weapon", "3k_main_ancillary_weapon_single_edged_sword_common"),
564                            ("3k_main_ceo_category_ancillary_mount",  "3k_main_ancillary_mount_white_horse"),
565                            ("3k_main_ceo_category_ancillary_armour", armor_ceo_key.as_str()),
566                        ] {
567                            let p = insert_row(pack, schema, "ceo_initial_data_equipments_tables", stem, &row![
568                                "initial_data_stage" => &stage2_key,
569                                "category" => *category,
570                                "equipped_ceo" => *equipped_ceo,
571                                "slot_index" => "0",
572                                "target" => "character_equipment",
573                                "auto_id" => auto_id("ceo_initial_data_equipments", &format!("{stage2_key}|{equipped_ceo}")),
574                            ])?;
575                            if !added_paths.contains(&p) { added_paths.push(p); }
576                        }
577                        for active_ceo in &[
578                            "3k_main_ancillary_weapon_one_handed_axe_common",
579                            "3k_main_ancillary_weapon_dual_swords_common",
580                            "3k_main_ancillary_mount_white_horse",
581                            "3k_main_ancillary_weapon_single_edged_sword_common",
582                            "3k_main_ancillary_weapon_double_edged_sword_common",
583                        ] {
584                            let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
585                                "initial_data_stage" => &stage2_key,
586                                "active_ceo" => *active_ceo,
587                                "starting_points_delta" => "0",
588                                "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{active_ceo}")),
589                            ])?;
590                            if !added_paths.contains(&p) { added_paths.push(p); }
591                        }
592                        // armour active_ceo into stage2
593                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
594                            "initial_data_stage" => &stage2_key,
595                            "active_ceo" => &armor_ceo_key,
596                            "starting_points_delta" => "0",
597                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{armor_ceo_key}")),
598                        ])?;
599                        if !added_paths.contains(&p) { added_paths.push(p); }
600                        // class into stage1
601                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
602                            "initial_data_stage" => &stage1_key,
603                            "active_ceo" => "3k_main_ceo_class_earth",
604                            "starting_points_delta" => "0",
605                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|3k_main_ceo_class_earth")),
606                        ])?;
607                        if !added_paths.contains(&p) { added_paths.push(p); }
608                        // expanded: sword_and_shield
609                        if entry.expanded {
610                            let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
611                                "initial_data_stage" => &stage2_key,
612                                "active_ceo" => "3k_main_ancillary_weapon_sword_and_shield_common",
613                                "starting_points_delta" => "0",
614                                "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|3k_main_ancillary_weapon_sword_and_shield_common")),
615                            ])?;
616                            if !added_paths.contains(&p) { added_paths.push(p); }
617                        }
618                        // armour effect: authority_mod
619                        let p = insert_row(pack, schema, "ceo_effect_list_to_effects_tables", stem, &row![
620                            "effect_list" => &effect_list_armor,
621                            "effect" => "3k_main_effect_character_attribute_authority_mod",
622                            "value" => "18",
623                            "effect_scope" => "character_to_character_own",
624                            "optional_only_in_game_mode" => "",
625                            "auto_id" => auto_id("ceo_effect_list_to_effects", &format!("{effect_list_armor}|authority")),
626                        ])?;
627                        if !added_paths.contains(&p) { added_paths.push(p); }
628                        for (id_stage, stage_num) in &[
629                            ("3k_main_ceo_initial_data_stage_character_childhood_earth", 17i32),
630                            ("3k_main_ceo_initial_data_equipment_permissions_unique_earth", 4i32),
631                        ] {
632                            let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
633                                "ceo_initial_data" => &initial_data_key,
634                                "initial_data_stage" => *id_stage,
635                                "stage" => stage_num,
636                            ])?;
637                            if !added_paths.contains(&p) { added_paths.push(p); }
638                        }
639                    },
640                    "fire" => {
641                        for scripted in &[
642                            "3k_main_ceo_permissions_ancillary_weapon_character_axe_two_handed_enable",
643                            "3k_main_ceo_permissions_ancillary_weapon_character_spear_two_handed_long_enable",
644                            &format!("3k_main_ceo_permissions_ancillary_armour_character_specific_{n}"),
645                            "3k_main_ceo_permissions_ancillary_weapon_character_spear_two_handed_enable",
646                            "3k_main_ceo_permissions_ancillary_weapon_character_axe_two_handed_enable",
647                        ] {
648                            let p = insert_row(pack, schema, "ceo_initial_data_scripted_permissions_tables", stem, &row![
649                                "initial_data_stage" => &stage2_key,
650                                "scripted_permissions" => *scripted,
651                                "auto_id" => auto_id("ceo_initial_data_scripted_permissions", &format!("{stage2_key}|{scripted}")),
652                            ])?;
653                            if !added_paths.contains(&p) { added_paths.push(p); }
654                        }
655                        for (category, equipped_ceo) in &[
656                            ("3k_main_ceo_category_ancillary_mount",  "3k_main_ancillary_mount_red_horse"),
657                            ("3k_main_ceo_category_ancillary_weapon", "3k_main_ancillary_weapon_two_handed_spear_common"),
658                            ("3k_main_ceo_category_ancillary_armour", armor_ceo_key.as_str()),
659                        ] {
660                            let p = insert_row(pack, schema, "ceo_initial_data_equipments_tables", stem, &row![
661                                "initial_data_stage" => &stage2_key,
662                                "category" => *category,
663                                "equipped_ceo" => *equipped_ceo,
664                                "slot_index" => "0",
665                                "target" => "character_equipment",
666                                "auto_id" => auto_id("ceo_initial_data_equipments", &format!("{stage2_key}|{equipped_ceo}")),
667                            ])?;
668                            if !added_paths.contains(&p) { added_paths.push(p); }
669                        }
670                        for active_ceo in &[
671                            "3k_main_ancillary_weapon_hook_sickle_sabre_common",
672                            "3k_main_ancillary_weapon_two_handed_axe_common",
673                            "3k_main_ancillary_weapon_two_handed_spear_common",
674                            "3k_main_ancillary_weapon_halberd_common",
675                            "3k_main_ancillary_mount_red_horse",
676                        ] {
677                            let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
678                                "initial_data_stage" => &stage2_key,
679                                "active_ceo" => *active_ceo,
680                                "starting_points_delta" => "0",
681                                "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{active_ceo}")),
682                            ])?;
683                            if !added_paths.contains(&p) { added_paths.push(p); }
684                        }
685                        // armour active_ceo into stage2
686                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
687                            "initial_data_stage" => &stage2_key,
688                            "active_ceo" => &armor_ceo_key,
689                            "starting_points_delta" => "0",
690                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{armor_ceo_key}")),
691                        ])?;
692                        if !added_paths.contains(&p) { added_paths.push(p); }
693                        // class into stage1
694                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
695                            "initial_data_stage" => &stage1_key,
696                            "active_ceo" => "3k_main_ceo_class_fire",
697                            "starting_points_delta" => "0",
698                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|3k_main_ceo_class_fire")),
699                        ])?;
700                        if !added_paths.contains(&p) { added_paths.push(p); }
701                        // armour effect: instinct_mod
702                        let p = insert_row(pack, schema, "ceo_effect_list_to_effects_tables", stem, &row![
703                            "effect_list" => &effect_list_armor,
704                            "effect" => "3k_main_effect_character_attribute_instinct_mod",
705                            "value" => "18",
706                            "effect_scope" => "character_to_character_own",
707                            "optional_only_in_game_mode" => "",
708                            "auto_id" => auto_id("ceo_effect_list_to_effects", &format!("{effect_list_armor}|instinct")),
709                        ])?;
710                        if !added_paths.contains(&p) { added_paths.push(p); }
711                        for (id_stage, stage_num) in &[
712                            ("3k_main_ceo_initial_data_stage_character_childhood_fire", 17i32),
713                            ("3k_main_ceo_initial_data_equipment_permissions_unique_fire", 4i32),
714                        ] {
715                            let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
716                                "ceo_initial_data" => &initial_data_key,
717                                "initial_data_stage" => *id_stage,
718                                "stage" => stage_num,
719                            ])?;
720                            if !added_paths.contains(&p) { added_paths.push(p); }
721                        }
722                    },
723                    _ => { // wood
724                        for scripted in &[
725                            &format!("3k_main_ceo_permissions_ancillary_armour_character_specific_{n}"),
726                            "3k_main_ceo_permissions_ancillary_weapon_character_spear_two_handed_enable",
727                            "3k_main_ceo_permissions_ancillary_weapon_character_spear_two_handed_long_enable",
728                            "3k_ytr_ceo_permissions_ancillary_weapon_character_staff_two_handed_enable",
729                            "3k_ytr_ceo_permissions_ancillary_weapon_character_mace_two_handed_enable",
730                        ] {
731                            let p = insert_row(pack, schema, "ceo_initial_data_scripted_permissions_tables", stem, &row![
732                                "initial_data_stage" => &stage2_key,
733                                "scripted_permissions" => *scripted,
734                                "auto_id" => auto_id("ceo_initial_data_scripted_permissions", &format!("{stage2_key}|{scripted}")),
735                            ])?;
736                            if !added_paths.contains(&p) { added_paths.push(p); }
737                        }
738                        for (category, equipped_ceo) in &[
739                            ("3k_main_ceo_category_ancillary_mount",  "3k_main_ancillary_mount_brown_horse"),
740                            ("3k_main_ceo_category_ancillary_weapon", "3k_main_ancillary_weapon_two_handed_spear_common"),
741                            ("3k_main_ceo_category_ancillary_armour", armor_ceo_key.as_str()),
742                        ] {
743                            let p = insert_row(pack, schema, "ceo_initial_data_equipments_tables", stem, &row![
744                                "initial_data_stage" => &stage2_key,
745                                "category" => *category,
746                                "equipped_ceo" => *equipped_ceo,
747                                "slot_index" => "0",
748                                "target" => "character_equipment",
749                                "auto_id" => auto_id("ceo_initial_data_equipments", &format!("{stage2_key}|{equipped_ceo}")),
750                            ])?;
751                            if !added_paths.contains(&p) { added_paths.push(p); }
752                        }
753                        for active_ceo in &[
754                            "3k_ytr_ancillary_weapon_2h_ball_mace_common",
755                            "3k_main_ancillary_weapon_hook_sickle_sabre_common",
756                            "3k_main_ancillary_mount_brown_horse",
757                            "3k_main_ancillary_weapon_two_handed_spear_common",
758                            "3k_main_ancillary_weapon_halberd_common",
759                        ] {
760                            let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
761                                "initial_data_stage" => &stage2_key,
762                                "active_ceo" => *active_ceo,
763                                "starting_points_delta" => "0",
764                                "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{active_ceo}")),
765                            ])?;
766                            if !added_paths.contains(&p) { added_paths.push(p); }
767                        }
768                        // armour active_ceo into stage2
769                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
770                            "initial_data_stage" => &stage2_key,
771                            "active_ceo" => &armor_ceo_key,
772                            "starting_points_delta" => "0",
773                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage2_key}|{armor_ceo_key}")),
774                        ])?;
775                        if !added_paths.contains(&p) { added_paths.push(p); }
776                        // class into stage1
777                        let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
778                            "initial_data_stage" => &stage1_key,
779                            "active_ceo" => "3k_main_ceo_class_wood",
780                            "starting_points_delta" => "0",
781                            "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|3k_main_ceo_class_wood")),
782                        ])?;
783                        if !added_paths.contains(&p) { added_paths.push(p); }
784                        // armour effect: resolve_mod
785                        let p = insert_row(pack, schema, "ceo_effect_list_to_effects_tables", stem, &row![
786                            "effect_list" => &effect_list_armor,
787                            "effect" => "3k_main_effect_character_attribute_resolve_mod",
788                            "value" => "18",
789                            "effect_scope" => "character_to_character_own",
790                            "optional_only_in_game_mode" => "",
791                            "auto_id" => auto_id("ceo_effect_list_to_effects", &format!("{effect_list_armor}|resolve")),
792                        ])?;
793                        if !added_paths.contains(&p) { added_paths.push(p); }
794                        for (id_stage, stage_num) in &[
795                            ("3k_main_ceo_initial_data_stage_character_childhood_wood", 17i32),
796                            ("3k_main_ceo_initial_data_equipment_permissions_unique_wood", 4i32),
797                        ] {
798                            let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
799                                "ceo_initial_data" => &initial_data_key,
800                                "initial_data_stage" => *id_stage,
801                                "stage" => stage_num,
802                            ])?;
803                            if !added_paths.contains(&p) { added_paths.push(p); }
804                        }
805                    },
806                }
807                // Gender
808                let gender_stage = if gender == "male" {
809                    "3k_main_ceo_initial_data_stage_character_gender_male"
810                } else {
811                    "3k_main_ceo_initial_data_stage_character_gender_female"
812                };
813                let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
814                    "ceo_initial_data" => &initial_data_key,
815                    "initial_data_stage" => gender_stage,
816                    "stage" => "13",
817                ])?;
818                if !added_paths.contains(&p) { added_paths.push(p); }
819
820                // Shared unique records after element/gender
821
822                // Link the _ancillaries stage (stage2_key) at stage order 3
823                let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
824                    "ceo_initial_data" => &initial_data_key,
825                    "initial_data_stage" => &stage2_key,
826                    "stage" => "3",
827                ])?;
828                if !added_paths.contains(&p) { added_paths.push(p); }
829
830                let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
831                    "initial_data_stage" => &stage1_key,
832                    "active_ceo" => &format!("3k_main_ceo_career_historical_{n}"),
833                    "starting_points_delta" => "0",
834                    "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|3k_main_ceo_career_historical_{n}")),
835                ])?;
836                if !added_paths.contains(&p) { added_paths.push(p); }
837
838                for (id_stage, stage_num) in &[
839                    ("3k_main_initial_data_character_ancillaries_global", 2i32),
840                    ("3k_dlc04_ceo_initial_data_character_give_political_support_random", 21i32),
841                    ("3k_main_ceo_initial_data_stage_character_wealth_random", 15i32),
842                    ("3k_main_ceo_initial_data_stage_character_traits_shared_global_permissions", 10i32),
843                    ("3k_main_ceo_initial_data_stage_character_protagonist", 14i32),
844                ] {
845                    let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
846                        "ceo_initial_data" => &initial_data_key,
847                        "initial_data_stage" => *id_stage,
848                        "stage" => stage_num,
849                    ])?;
850                    if !added_paths.contains(&p) { added_paths.push(p); }
851                }
852                let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
853                    "ceo_initial_data" => &initial_data_key,
854                    "initial_data_stage" => &stage1_key,
855                    "stage" => "11",
856                ])?;
857                if !added_paths.contains(&p) { added_paths.push(p); }
858
859                // ceo_thresholds
860                for (ceo_ref, key_val) in &[
861                    (format!("3k_main_ceo_career_historical_{n}"), format!("3k_main_ceo_career_historical_{n}")),
862                    (format!("3k_main_ancilliary_armour_{n}_armour_unique"), format!("3k_main_ancilliary_armour_{n}_armour_unique")),
863                ] {
864                    let p = insert_row(pack, schema, "ceo_thresholds_tables", stem, &row![
865                        "key" => key_val,
866                        "ceo" => ceo_ref,
867                        "point_threshold_to_activate" => "1",
868                        "point_theshold_to_destroy" => "0",
869                        "starting_points" => "1",
870                        "max_points" => "1",
871                        "resets_to_starting_points_when_deactivated" => "false",
872                    ])?;
873                    if !added_paths.contains(&p) { added_paths.push(p); }
874                }
875
876                // ceo_nodes — career
877                let p = insert_row(pack, schema, "ceo_nodes_tables", stem, &row![
878                    "key" => format!("3k_main_ceo_career_historical_{n}"),
879                    "ceo_effect_list" => &effect_list_career,
880                    "title" => "placeholder",
881                    "description" => "placeholder",
882                    "icon_path" => "",
883                    "opinion_topic_modifier" => "",
884                    "point_change_per_turn_if_active" => "0",
885                ])?;
886                if !added_paths.contains(&p) { added_paths.push(p); }
887                // ceo_nodes — armour (with element-specific icon)
888                let armour_icon = format!("armours/3k_main_ancillary_{}_armour_unique.png", element);
889                let p = insert_row(pack, schema, "ceo_nodes_tables", stem, &row![
890                    "key" => format!("3k_main_ancilliary_armour_{n}_armour_unique"),
891                    "ceo_effect_list" => &effect_list_armor,
892                    "title" => "placeholder",
893                    "description" => "placeholder",
894                    "icon_path" => &armour_icon,
895                    "opinion_topic_modifier" => "",
896                    "point_change_per_turn_if_active" => "0",
897                ])?;
898                if !added_paths.contains(&p) { added_paths.push(p); }
899
900                // ceo_threshold_nodes
901                for (node_key, threshold_key) in &[
902                    (format!("3k_main_ceo_career_historical_{n}"), format!("3k_main_ceo_career_historical_{n}")),
903                    (format!("3k_main_ancilliary_armour_{n}_armour_unique"), format!("3k_main_ancilliary_armour_{n}_armour_unique")),
904                ] {
905                    let p = insert_row(pack, schema, "ceo_threshold_nodes_tables", stem, &row![
906                        "ceo_threshold" => threshold_key,
907                        "ceo_node" => node_key,
908                        "points_threshold_to_activate_node" => "1",
909                        "can_downgrade_to_previous_node" => "false",
910                        "auto_id" => auto_id("ceo_threshold_nodes", &format!("{threshold_key}|{node_key}")),
911                    ])?;
912                    if !added_paths.contains(&p) { added_paths.push(p); }
913                }
914
915                // ceo_effect_list_to_effects — dummy subcategory for armour
916                let p = insert_row(pack, schema, "ceo_effect_list_to_effects_tables", stem, &row![
917                    "effect_list" => &effect_list_armor,
918                    "effect" => "3k_dummy_effect_ceo_subcategory_armour_unique",
919                    "value" => "0",
920                    "effect_scope" => "character_to_character_own",
921                    "optional_only_in_game_mode" => "",
922                    "auto_id" => auto_id("ceo_effect_list_to_effects", &format!("{effect_list_armor}|dummy_subcategory")),
923                ])?;
924                if !added_paths.contains(&p) { added_paths.push(p); }
925
926                // ceo_effect_list_to_effects (career: wealth + lives)
927                for (effect, val) in &[
928                    ("3k_main_character_wealth", "2"),
929                    ("3k_main_effect_character_num_lives", "1"),
930                ] {
931                    let p = insert_row(pack, schema, "ceo_effect_list_to_effects_tables", stem, &row![
932                        "effect_list" => &effect_list_career,
933                        "effect" => *effect,
934                        "value" => *val,
935                        "effect_scope" => "character_to_character_own",
936                        "optional_only_in_game_mode" => "",
937                        "auto_id" => auto_id("ceo_effect_list_to_effects", &format!("{effect_list_career}|{effect}")),
938                    ])?;
939                    if !added_paths.contains(&p) { added_paths.push(p); }
940                }
941
942                // Traits
943                for (trait_uuid, trait_key) in &entry.traits {
944                    let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
945                        "initial_data_stage" => &stage1_key,
946                        "active_ceo" => trait_key.as_str(),
947                        "starting_points_delta" => "0",
948                        "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|{trait_key}|{trait_uuid}")),
949                    ])?;
950                    if !added_paths.contains(&p) { added_paths.push(p); }
951                }
952
953                // ceos_to_equipment_variants
954                let p = insert_row(pack, schema, "ceos_to_equipment_variants_tables", stem, &row![
955                    "ceos_key" => &format!("3k_main_ancilliary_armour_{n}_armour_unique"),
956                    "game_mode" => "",
957                    "armour" => "3k_ytr_hero_scholar_unique",
958                    "male_vmd" => "",
959                    "female_vmd" => "",
960                    "mount" => "",
961                    "primary_melee_weapon" => "",
962                    "primary_missile_weapon" => "",
963                    "shield" => "",
964                    "man_animation" => "",
965                    "mount_animation" => "",
966                    "secondary_weapon_animation" => "",
967                    "remap_general_unit_to_hero_unit" => "false",
968                    "priority" => "1",
969                    "autonomous_rider_group" => "",
970                    "ground_type_stat_effect_group" => "",
971                ])?;
972                if !added_paths.contains(&p) { added_paths.push(p); }
973
974                // ceo_template_manager_ceo_limits — limit unique armour to 1 globally
975                let p = insert_row(pack, schema, "ceo_template_manager_ceo_limits_tables", stem, &row![
976                    "ceo_to_limit" => &format!("3k_main_ancilliary_armour_{n}_armour_unique"),
977                    "template_manager" => "3k_main_ceo_template_manager_world_generic",
978                    "max_limit_that_can_exist_at_once" => "1",
979                    "scoped_limit_or_local_only_limit" => "true",
980                    "ceo_category_to_limit" => "",
981                    "ceo_node_to_limit" => "",
982                    "auto_id" => auto_id("ceo_template_manager_ceo_limits", &format!("3k_main_ancilliary_armour_{n}_armour_unique")),
983                ])?;
984                if !added_paths.contains(&p) { added_paths.push(p); }
985
986                // Loc entries for unique
987                let human = n.replace("_ironic", "")
988                    .split('_')
989                    .map(|w: &str| {
990                        let mut c = w.chars();
991                        c.next().map(|f: char| f.to_uppercase().collect::<String>() + c.as_str()).unwrap_or_default()
992                    })
993                    .collect::<Vec<_>>()
994                    .join(" ");
995                loc_entries.push((format!("ceo_nodes_title_3k_main_ceo_career_historical_{n}"), "PLACEHOLDER".into()));
996                loc_entries.push((format!("ceo_nodes_description_3k_main_ceo_career_historical_{n}"), "PLACEHOLDER".into()));
997                loc_entries.push((format!("ceo_nodes_title_3k_main_ancilliary_armour_{n}_armour_unique"), format!("{human}'s Armour")));
998                loc_entries.push((format!("ceo_nodes_description_3k_main_ancilliary_armour_{n}_armour_unique"), "The perfect weight and fit, tailored for this warrior of class and distinction.".into()));
999
1000            } else {
1001                // ── TITLE PATH ────────────────────────────────
1002
1003                // ceos
1004                let p = insert_row(pack, schema, "ceos_tables", stem, &row![
1005                    "key" => format!("3k_main_ceo_career_historical_{n}"),
1006                    "exists_in_location" => "character_ceo_manager",
1007                    "category" => "3k_main_ceo_category_career",
1008                    "equipped_in_location" => "character_equipment",
1009                    "priority" => "1",
1010                    "turns_to_expire" => "0",
1011                    "point_change_per_turn_if_inactive" => "0",
1012                    "point_change_per_turn_while_active" => "0",
1013                    "point_change_per_turn_while_equipped" => "0",
1014                    "inheritance_chance" => "0",
1015                    "can_be_looted_post_battle" => "false",
1016                    "can_be_traded_in_diplomacy" => "false",
1017                    "can_be_stolen" => "false",
1018                    "rarity" => "common",
1019                    "can_be_unequipped" => "false",
1020                    "can_be_transferred_if_equipped" => "true",
1021                    "cannot_reequip_until_next_round_if_unequipped" => "true",
1022                    "provides_scripted_permissions_on_spawn" => "",
1023                ])?;
1024                if !added_paths.contains(&p) { added_paths.push(p); }
1025
1026                // ceo_group_ceos — career into career_all
1027                let career_key_title = format!("3k_main_ceo_career_historical_{n}");
1028                let p = insert_row(pack, schema, "ceo_group_ceos_tables", stem, &row![
1029                    "ceo_group" => "3k_main_ceo_group_career_all",
1030                    "ceo" => &career_key_title,
1031                    "trigger_weighting" => "1",
1032                    "auto_id" => auto_id("ceo_group_ceos", &format!("3k_main_ceo_group_career_all|{career_key_title}")),
1033                ])?;
1034                if !added_paths.contains(&p) { added_paths.push(p); }
1035
1036                let stage1_key = format!("3k_main_ceo_initial_data_stage_character_traits_historical_{n}");
1037                let p = insert_row(pack, schema, "ceo_initial_data_stages_tables", stem, &row![
1038                    "key" => &stage1_key,
1039                ])?;
1040                if !added_paths.contains(&p) { added_paths.push(p); }
1041
1042                let effect_list_key = format!("3k_main_ceo_career_historical_{n}");
1043                let p = insert_row(pack, schema, "ceo_effect_lists_tables", stem, &row![
1044                    "key" => &effect_list_key,
1045                ])?;
1046                if !added_paths.contains(&p) { added_paths.push(p); }
1047
1048                let initial_data_key = format!("3k_main_ceo_initial_data_character_historical_{n}");
1049                let p = insert_row(pack, schema, "ceo_initial_datas_tables", stem, &row![
1050                    "key" => &initial_data_key,
1051                    "template_manager" => "character_ceo_manager",
1052                ])?;
1053                if !added_paths.contains(&p) { added_paths.push(p); }
1054
1055                // Element branch (title)
1056                let (childhood_stage, equipment_stage, class_ceo, generic_stage, generic_stage_num) = match element.as_str() {
1057                    "metal" => ("3k_main_ceo_initial_data_stage_character_childhood_metal", "3k_main_ceo_initial_data_equipment_permissions_title_metal", "3k_main_ceo_class_metal", "3k_main_ceo_initial_data_character_generic_metal_ancillaries_01", 3i32),
1058                    "earth" => ("3k_main_ceo_initial_data_stage_character_childhood_earth", "3k_main_ceo_initial_data_equipment_permissions_title_earth", "3k_main_ceo_class_earth", "3k_main_ceo_initial_data_character_generic_earth_ancillaries_01", 3i32),
1059                    "water" => ("3k_main_ceo_initial_data_stage_character_childhood_water", "3k_main_ceo_initial_data_equipment_permissions_title_water", "3k_main_ceo_class_water", "3k_main_ceo_initial_data_character_generic_water_ancillaries_01", 3i32),
1060                    "fire"  => ("3k_main_ceo_initial_data_stage_character_childhood_fire",  "3k_main_ceo_initial_data_equipment_permissions_title_fire",  "3k_main_ceo_class_fire",  "3k_main_ceo_initial_data_character_generic_fire_ancillaries_01",  3i32),
1061                    _       => ("3k_main_ceo_initial_data_stage_character_childhood_wood",  "3k_main_ceo_initial_data_equipment_permissions_title_wood",  "3k_main_ceo_class_wood",  "3k_main_ceo_initial_data_character_generic_wood_ancillaries_01",  3i32),
1062                };
1063
1064                for (id_stage, stage_num) in &[
1065                    (childhood_stage, 17i32),
1066                    (equipment_stage, 4i32),
1067                ] {
1068                    let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
1069                        "ceo_initial_data" => &initial_data_key,
1070                        "initial_data_stage" => *id_stage,
1071                        "stage" => stage_num,
1072                    ])?;
1073                    if !added_paths.contains(&p) { added_paths.push(p); }
1074                }
1075                let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
1076                    "initial_data_stage" => &stage1_key,
1077                    "active_ceo" => class_ceo,
1078                    "starting_points_delta" => "0",
1079                    "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|{class_ceo}")),
1080                ])?;
1081                if !added_paths.contains(&p) { added_paths.push(p); }
1082                let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
1083                    "ceo_initial_data" => &initial_data_key,
1084                    "initial_data_stage" => generic_stage,
1085                    "stage" => &generic_stage_num,
1086                ])?;
1087                if !added_paths.contains(&p) { added_paths.push(p); }
1088
1089                // Gender
1090                let gender_stage = if gender == "male" {
1091                    "3k_main_ceo_initial_data_stage_character_gender_male"
1092                } else {
1093                    "3k_main_ceo_initial_data_stage_character_gender_female"
1094                };
1095                let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
1096                    "ceo_initial_data" => &initial_data_key,
1097                    "initial_data_stage" => gender_stage,
1098                    "stage" => "13",
1099                ])?;
1100                if !added_paths.contains(&p) { added_paths.push(p); }
1101
1102                // Generic shared (title)
1103                let career_key = format!("3k_main_ceo_career_historical_{n}");
1104                let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
1105                    "initial_data_stage" => &stage1_key,
1106                    "active_ceo" => &career_key,
1107                    "starting_points_delta" => "0",
1108                    "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|{career_key}")),
1109                ])?;
1110                if !added_paths.contains(&p) { added_paths.push(p); }
1111
1112                for (id_stage, stage_num) in &[
1113                    ("3k_main_initial_data_character_ancillaries_global", 2i32),
1114                    ("3k_dlc04_ceo_initial_data_character_give_political_support_random", 21i32),
1115                    ("3k_main_ceo_initial_data_stage_character_wealth_random", 15i32),
1116                    ("3k_main_ceo_initial_data_stage_character_traits_shared_global_permissions", 10i32),
1117                    ("3k_main_ceo_initial_data_stage_character_protagonist", 14i32),
1118                ] {
1119                    let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
1120                        "ceo_initial_data" => &initial_data_key,
1121                        "initial_data_stage" => *id_stage,
1122                        "stage" => stage_num,
1123                    ])?;
1124                    if !added_paths.contains(&p) { added_paths.push(p); }
1125                }
1126                let p = insert_row(pack, schema, "ceo_initial_data_to_stages_tables", stem, &row![
1127                    "ceo_initial_data" => &initial_data_key,
1128                    "initial_data_stage" => &stage1_key,
1129                    "stage" => "11",
1130                ])?;
1131                if !added_paths.contains(&p) { added_paths.push(p); }
1132
1133                // ceo_thresholds
1134                let p = insert_row(pack, schema, "ceo_thresholds_tables", stem, &row![
1135                    "key" => &career_key,
1136                    "ceo" => &career_key,
1137                    "point_threshold_to_activate" => "1",
1138                    "point_theshold_to_destroy" => "0",
1139                    "starting_points" => "1",
1140                    "max_points" => "1",
1141                    "resets_to_starting_points_when_deactivated" => "false",
1142                ])?;
1143                if !added_paths.contains(&p) { added_paths.push(p); }
1144
1145                // ceo_nodes
1146                let p = insert_row(pack, schema, "ceo_nodes_tables", stem, &row![
1147                    "key" => &career_key,
1148                    "ceo_effect_list" => &effect_list_key,
1149                    "title" => "placeholder",
1150                    "description" => "placeholder",
1151                    "icon_path" => "",
1152                    "opinion_topic_modifier" => "",
1153                    "point_change_per_turn_if_active" => "0",
1154                ])?;
1155                if !added_paths.contains(&p) { added_paths.push(p); }
1156
1157                // ceo_threshold_nodes
1158                let p = insert_row(pack, schema, "ceo_threshold_nodes_tables", stem, &row![
1159                    "ceo_threshold" => &career_key,
1160                    "ceo_node" => &career_key,
1161                    "points_threshold_to_activate_node" => "1",
1162                    "can_downgrade_to_previous_node" => "false",
1163                    "auto_id" => auto_id("ceo_threshold_nodes", &format!("{career_key}|{career_key}")),
1164                ])?;
1165                if !added_paths.contains(&p) { added_paths.push(p); }
1166
1167                // ceo_effect_list_to_effects
1168                for (effect, val) in &[
1169                    ("3k_main_character_wealth", "2"),
1170                    ("3k_main_effect_character_num_lives", "1"),
1171                ] {
1172                    let p = insert_row(pack, schema, "ceo_effect_list_to_effects_tables", stem, &row![
1173                        "effect_list" => &effect_list_key,
1174                        "effect" => *effect,
1175                        "value" => *val,
1176                        "effect_scope" => "character_to_character_own",
1177                        "optional_only_in_game_mode" => "",
1178                        "auto_id" => auto_id("ceo_effect_list_to_effects", &format!("{effect_list_key}|{effect}")),
1179                    ])?;
1180                    if !added_paths.contains(&p) { added_paths.push(p); }
1181                }
1182
1183                // Traits
1184                for (trait_uuid, trait_key) in &entry.traits {
1185                    let p = insert_row(pack, schema, "ceo_initial_data_active_ceos_tables", stem, &row![
1186                        "initial_data_stage" => &stage1_key,
1187                        "active_ceo" => trait_key.as_str(),
1188                        "starting_points_delta" => "0",
1189                        "auto_id" => auto_id("ceo_initial_data_active_ceos", &format!("{stage1_key}|{trait_key}|{trait_uuid}")),
1190                    ])?;
1191                    if !added_paths.contains(&p) { added_paths.push(p); }
1192                }
1193
1194                // Loc entries for title
1195                loc_entries.push((format!("ceo_nodes_title_3k_main_ceo_career_historical_{n}"), "PLACEHOLDER".into()));
1196                loc_entries.push((format!("ceo_nodes_description_3k_main_ceo_career_historical_{n}"), "PLACEHOLDER".into()));
1197            }
1198        }
1199
1200        // ── Write loc file ──────────────────────────────────────
1201        if !loc_entries.is_empty() {
1202            let loc_pairs: Vec<(&str, &str)> = loc_entries.iter()
1203                .map(|(k, v)| (k.as_str(), v.as_str()))
1204                .collect();
1205            let p = insert_loc_entries(pack, &loc_path, &loc_pairs)?;
1206            if !added_paths.contains(&p) { added_paths.push(p); }
1207        }
1208
1209        Ok(added_paths)
1210}
1211
1212/// Fetch all trait CEOs from the Assembly Kit data, resolving display names via loc.
1213pub fn get_trait_ceos(deps: &rpfm_extensions::dependencies::Dependencies) -> Vec<(String, String)> {
1214    let ak_tables = deps.asskit_only_db_tables();
1215    let mut trait_ceos: Vec<(String, String)> = Vec::new();
1216
1217    let trait_categories: HashSet<&str> = [
1218        "3k_main_ceo_category_traits_personality",
1219        "3k_main_ceo_category_traits_physical",
1220    ].iter().copied().collect();
1221
1222    info!("GetTraitCeos: AK tables available: {}", ak_tables.len());
1223
1224    // Helper to read rows from an AK table by name, extracting two columns.
1225    fn ak_lookup_pairs(ak_tables: &HashMap<String, DB>, table_name: &str, col_a: &str, col_b: &str) -> Vec<(String, String)> {
1226        let mut result = Vec::new();
1227        if let Some(db) = ak_tables.get(table_name) {
1228            let fields = db.definition().fields_processed();
1229            let a_idx = fields.iter().position(|f| f.name() == col_a);
1230            let b_idx = fields.iter().position(|f| f.name() == col_b);
1231            if let (Some(ai), Some(bi)) = (a_idx, b_idx) {
1232                for row in db.data().iter() {
1233                    result.push((row[ai].data_to_string().to_string(), row[bi].data_to_string().to_string()));
1234                }
1235            }
1236        }
1237        result
1238    }
1239
1240    // Step 1: Get all CEO keys that belong to trait categories from AK.
1241    if let Some(ceos_db) = ak_tables.get("ceos_tables") {
1242        let fields = ceos_db.definition().fields_processed();
1243        let key_idx = fields.iter().position(|f| f.name() == "key");
1244        let cat_idx = fields.iter().position(|f| f.name() == "category");
1245
1246        if let (Some(ki), Some(ci)) = (key_idx, cat_idx) {
1247            for row in ceos_db.data().iter() {
1248                let category = row[ci].data_to_string();
1249                if trait_categories.contains(&*category) {
1250                    let ceo_key = row[ki].data_to_string().to_string();
1251                    trait_ceos.push((ceo_key, String::new()));
1252                }
1253            }
1254        }
1255    }
1256
1257    info!("GetTraitCeos: found {} trait CEOs from AK ceos_tables", trait_ceos.len());
1258
1259    // Step 2: Walk ceos -> ceo_thresholds -> ceo_threshold_nodes -> ceo_nodes for display names.
1260    let ceo_to_threshold: HashMap<String, String> =
1261        ak_lookup_pairs(ak_tables, "ceo_thresholds_tables", "ceo", "key")
1262            .into_iter().collect();
1263
1264    let threshold_to_node: HashMap<String, String> =
1265        ak_lookup_pairs(ak_tables, "ceo_threshold_nodes_tables", "ceo_threshold", "ceo_node")
1266            .into_iter().collect();
1267
1268    let node_to_title: HashMap<String, String> =
1269        ak_lookup_pairs(ak_tables, "ceo_nodes_tables", "key", "title")
1270            .into_iter().collect();
1271
1272    // Build loc lookup from dependencies (game files have the loc data).
1273    let mut loc_lookup: HashMap<String, String> = HashMap::new();
1274    if let Ok(loc_files) = deps.loc_data(true, true) {
1275        for rfile in &loc_files {
1276            if let Ok(RFileDecoded::Loc(loc)) = rfile.decoded() {
1277                for row in loc.data().iter() {
1278                    if row.len() >= 2 {
1279                        let loc_key = row[0].data_to_string().to_string();
1280                        let loc_val = row[1].data_to_string().to_string();
1281                        loc_lookup.insert(loc_key, loc_val);
1282                    }
1283                }
1284            }
1285        }
1286    }
1287
1288    info!("GetTraitCeos: loc_lookup has {} entries, node_to_title has {} entries", loc_lookup.len(), node_to_title.len());
1289
1290    // Step 3: Resolve display names.
1291    for (ceo_key, display_name) in &mut trait_ceos {
1292        // Chain: ceo_key -> threshold -> node -> title (loc key) -> loc text
1293        let resolved = ceo_to_threshold.get(ceo_key.as_str())
1294            .and_then(|thresh| threshold_to_node.get(thresh))
1295            .and_then(|node| {
1296                // Try the title field value as a direct loc key
1297                node_to_title.get(node).and_then(|title_key| {
1298                    loc_lookup.get(title_key)
1299                        .or_else(|| {
1300                            // Try constructing "ceo_nodes_title_{node_key}"
1301                            let constructed = format!("ceo_nodes_title_{}", node);
1302                            loc_lookup.get(&constructed)
1303                        })
1304                })
1305            });
1306
1307        if let Some(name) = resolved {
1308            *display_name = name.clone();
1309        } else {
1310            // Fallback: humanize the key
1311            *display_name = ceo_key
1312                .replace("3k_main_ceo_trait_", "")
1313                .replace("3k_dlc", "")
1314                .replace("3k_ytr_ceo_trait_", "")
1315                .replace('_', " ");
1316        }
1317    }
1318
1319    // Sort by display name for the UI.
1320    trait_ceos.sort_by(|a, b| a.1.cmp(&b.1));
1321
1322    info!("GetTraitCeos: returning {} traits", trait_ceos.len());
1323
1324    trait_ceos
1325}
1326
1327/// CEO tables BOB reads when building `ceo_data.ccd`. Only these are exported.
1328const CEO_BOB_TABLES: [&str; 61] = [
1329    "ceo_active_permissions_tables",
1330    "ceo_anti_ceo_pairs_tables",
1331    "ceo_can_equip_requirements_tables",
1332    "ceo_categories_tables",
1333    "ceo_effect_list_to_effects_tables",
1334    "ceo_effect_lists_tables",
1335    "ceo_equipment_category_managers_tables",
1336    "ceo_equipment_manager_all_possible_ceos_tables",
1337    "ceo_equipment_manager_campaign_lookups_tables",
1338    "ceo_equipment_manager_to_category_managers_tables",
1339    "ceo_equipment_manager_types_tables",
1340    "ceo_equipment_managers_tables",
1341    "ceo_equipped_set_bonus_ceos_tables",
1342    "ceo_equipped_set_bonus_effect_bundles_tables",
1343    "ceo_equipped_set_bonuses_tables",
1344    "ceo_equipped_set_bonuses_to_incident_junctions_tables",
1345    "ceo_event_feed_categories_tables",
1346    "ceo_group_ceos_tables",
1347    "ceo_group_spawners_tables",
1348    "ceo_groups_tables",
1349    "ceo_initial_data_active_ceos_tables",
1350    "ceo_initial_data_active_spawners_tables",
1351    "ceo_initial_data_equipments_tables",
1352    "ceo_initial_data_scripted_permissions_tables",
1353    "ceo_initial_data_stages_tables",
1354    "ceo_initial_data_to_stages_tables",
1355    "ceo_initial_data_triggers_tables",
1356    "ceo_initial_datas_tables",
1357    "ceo_location_enums_tables",
1358    "ceo_nodes_tables",
1359    "ceo_permissions_groups_tables",
1360    "ceo_permissions_tables",
1361    "ceo_post_battle_loot_chances_tables",
1362    "ceo_rarities_tables",
1363    "ceo_scripted_permissions_tables",
1364    "ceo_scripted_permissions_to_permissions_tables",
1365    "ceo_set_items_tables",
1366    "ceo_sets_tables",
1367    "ceo_spawner_can_spawn_requirements_tables",
1368    "ceo_spawners_tables",
1369    "ceo_template_manager_all_possible_ceos_tables",
1370    "ceo_template_manager_campaign_lookups_tables",
1371    "ceo_template_manager_ceo_limits_tables",
1372    "ceo_template_manager_ceo_spawn_limits_tables",
1373    "ceo_template_manager_supported_categories_tables",
1374    "ceo_template_manager_types_tables",
1375    "ceo_template_managers_tables",
1376    "ceo_threshold_nodes_tables",
1377    "ceo_thresholds_tables",
1378    "ceo_to_target_ceo_junctions_tables",
1379    "ceo_to_target_factions_tables",
1380    "ceo_to_target_junction_reasons_tables",
1381    "ceo_to_target_province_junctions_tables",
1382    "ceo_to_ui_display_junctions_tables",
1383    "ceo_trigger_behaviour_enums_tables",
1384    "ceo_trigger_target_requirements_tables",
1385    "ceo_trigger_targets_tables",
1386    "ceo_trigger_to_trigger_targets_tables",
1387    "ceo_triggers_tables",
1388    "ceos_tables",
1389    "ceos_to_equipment_variants_tables",
1390];
1391
1392/// CEO tables BOB can't build `ceo_data.ccd` without.
1393const CEO_REQUIRED_TABLES: [&str; 5] = [
1394    "ceos_tables",
1395    "ceo_nodes_tables",
1396    "ceo_thresholds_tables",
1397    "ceo_threshold_nodes_tables",
1398    "ceo_initial_datas_tables",
1399];
1400
1401/// BOB configuration that builds only `ceo_data.ccd`, silently.
1402const BOB_CONFIG_XML: &str = r#"<?xml version="1.0" encoding="UTF-8"?>
1403                <bob_configuration><processors><processor>Campaign</processor></processors>
1404                <directories/><global_rules/><retail>0</retail><silent>1</silent>
1405                <get_latest>0</get_latest><connect_db>0</connect_db>
1406                <merge_for_checkin_mode>2</merge_for_checkin_mode>
1407                <selected_files><entry>&lt;working&gt;/campaigns/ceo_data.ccd</entry></selected_files>
1408                </bob_configuration>"#;
1409
1410/// Builds `ceo_data.ccd` in the Assembly Kit from the CEO tables of a pack, running BOB.
1411///
1412/// The CEO tables under the pack's `ceo_db/` folder are exported to the Assembly Kit's
1413/// `raw_data/db` as XML, and BOB is run to build the file. The previous `ceo_data.ccd` is
1414/// backed up, and the original XML files and BOB config are restored afterwards.
1415///
1416/// # Arguments
1417///
1418/// * `pack` - Pack with the CEO tables.
1419/// * `schema` - Schema to decode the tables.
1420/// * `akit_root` - Root folder of the Assembly Kit.
1421/// * `bob_exe` - Path of BOB's executable.
1422///
1423/// # Errors
1424///
1425/// Fails if required tables are missing, if exporting them fails, or if BOB doesn't produce `ceo_data.ccd`.
1426pub fn build_ceo(pack: &mut Pack, schema: &Option<Schema>, akit_root: &Path, bob_exe: &Path) -> Result<()> {
1427    let bob_dir = bob_exe.parent().ok_or_else(|| anyhow!("Invalid BOB path"))?;
1428    let raw_db = akit_root.join(r"raw_data\db");
1429    let ceo_ccd = akit_root.join(r"working_data\campaigns\ceo_data.ccd");
1430
1431    if ceo_ccd.exists() {
1432        std::fs::copy(&ceo_ccd, ceo_ccd.with_extension("ccd.bak1"))
1433            .map_err(|e| anyhow!("Failed to backup ceo_data.ccd: {e}"))?;
1434    }
1435
1436    let xml_backups = backup_ceo_xmls(&raw_db)?;
1437    let result = export_ceo_tables(pack, schema, &raw_db)
1438        .and_then(|_| run_bob(bob_exe, bob_dir, &ceo_ccd));
1439
1440    for (orig, bak) in &xml_backups {
1441        let _ = std::fs::rename(bak, orig);
1442    }
1443
1444    result
1445}
1446
1447/// Copies the `ceo*.xml` files of the Assembly Kit's `raw_data/db` to `.xml.bak` files.
1448///
1449/// # Returns
1450///
1451/// The original and backup path of each backed up file.
1452fn backup_ceo_xmls(raw_db: &Path) -> Result<Vec<(PathBuf, PathBuf)>> {
1453    let mut xml_backups = vec![];
1454    if !raw_db.exists() {
1455        return Ok(xml_backups);
1456    }
1457
1458    let entries = std::fs::read_dir(raw_db).map_err(|e| anyhow!("Failed to read raw_data/db: {e}"))?;
1459    for entry in entries.filter_map(|e| e.ok()) {
1460        let file_name = entry.file_name().to_string_lossy().to_lowercase();
1461        if file_name.starts_with("ceo") && file_name.ends_with(".xml") {
1462            let orig = entry.path();
1463            let bak = orig.with_extension("xml.bak");
1464            if std::fs::copy(&orig, &bak).is_ok() {
1465                xml_backups.push((orig, bak));
1466            }
1467        }
1468    }
1469
1470    Ok(xml_backups)
1471}
1472
1473/// Exports the CEO tables under the `ceo_db/` folder of a pack to XML files in the Assembly Kit's `raw_data/db`.
1474///
1475/// Tables of the same folder (like `data__` and `data__01`) are combined into one XML file.
1476fn export_ceo_tables(pack: &mut Pack, schema: &Option<Schema>, raw_db: &Path) -> Result<()> {
1477    let ceo_table_paths = pack.files()
1478        .keys()
1479        .filter(|path| {
1480            let mut parts = path.splitn(3, '/');
1481            parts.next() == Some("ceo_db") && parts.next().is_some_and(|folder| CEO_BOB_TABLES.contains(&folder))
1482        })
1483        .cloned()
1484        .collect::<Vec<String>>();
1485
1486    if ceo_table_paths.is_empty() {
1487        return Err(anyhow!("No CEO tables found in the pack (only the ceo_db/ folder is scanned). \
1488            Import CEO tables from the Assembly Kit, or move any CEO tables under db/ to ceo_db/."));
1489    }
1490
1491    let present_folders = ceo_table_paths.iter()
1492        .filter_map(|path| path.split('/').nth(1))
1493        .collect::<HashSet<&str>>();
1494    let missing = CEO_REQUIRED_TABLES.iter()
1495        .filter(|table| !present_folders.contains(**table))
1496        .map(|table| format!("  - {table}"))
1497        .collect::<Vec<_>>();
1498
1499    if !missing.is_empty() {
1500        return Err(anyhow!("The following required CEO tables are missing from the pack:\n{}\n\n\
1501            Import them from the Assembly Kit generate them.", missing.join("\n")));
1502    }
1503
1504    let mut decode_extra_data = DecodeableExtraData::default();
1505    decode_extra_data.set_schema(schema.as_ref());
1506    let decode_extra_data = Some(decode_extra_data);
1507
1508    // "ceo_db/ceos_tables/data__" -> "ceos.xml".
1509    let mut xml_groups: BTreeMap<String, Vec<String>> = BTreeMap::new();
1510    for table_path in &ceo_table_paths {
1511        if let Some(folder) = table_path.split('/').nth(1) {
1512            let xml_name = format!("{}.xml", folder.strip_suffix("_tables").unwrap_or(folder));
1513            xml_groups.entry(xml_name).or_default().push(table_path.clone());
1514        }
1515    }
1516
1517    let mut export_errors = vec![];
1518    for (xml_name, table_paths) in &xml_groups {
1519        let xml_path = raw_db.join(xml_name);
1520        let table_tag = xml_name.trim_end_matches(".xml");
1521        let xsd_name = xml_name.replace(".xml", ".xsd");
1522
1523        let mut xml = format!(
1524            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\r\n\
1525             <dataroot xmlns:od=\"urn:schemas-microsoft-com:officedata\" \
1526             xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" \
1527             xsi:noNamespaceSchemaLocation=\"{xsd_name}\" \
1528             export_time=\"\" revision=\"0\" export_branch=\"\" export_user=\"rpfm\">\r\n\
1529             <edit_uuid>00000000-0000-0000-0000-000000000000</edit_uuid>\r\n"
1530        );
1531
1532        for table_path in table_paths {
1533            let Some(rfile) = pack.files_mut().get_mut(table_path.as_str()) else { continue };
1534            let _ = rfile.load();
1535            let _ = rfile.decode(&decode_extra_data, true, false);
1536
1537            let Ok(RFileDecoded::DB(table)) = rfile.decoded() else {
1538                export_errors.push(format!("Could not decode {table_path}"));
1539                continue;
1540            };
1541
1542            let fields = table.definition().fields_processed();
1543            for row in table.data().iter() {
1544                let mut field_pairs = fields.iter()
1545                    .zip(row.iter())
1546                    .map(|(field, value)| (field.name().to_owned(), xml_escaped_value(value)))
1547                    .collect::<Vec<_>>();
1548                field_pairs.sort_by(|a, b| a.0.cmp(&b.0));
1549
1550                xml.push_str(&format!("<{table_tag}>\r\n"));
1551                for (field_name, value) in &field_pairs {
1552                    xml.push_str(&format!("<{field_name}>{value}</{field_name}>\r\n"));
1553                }
1554                xml.push_str(&format!("</{table_tag}>\r\n"));
1555            }
1556        }
1557
1558        xml.push_str("</dataroot>\r\n");
1559
1560        if let Err(e) = std::fs::write(&xml_path, xml.as_bytes()) {
1561            export_errors.push(format!("Failed to write {}: {e}", xml_path.display()));
1562        }
1563    }
1564
1565    if export_errors.is_empty() {
1566        Ok(())
1567    } else {
1568        Err(anyhow!("Export errors:\n{}", export_errors.join("\n")))
1569    }
1570}
1571
1572/// Returns a table value as BOB expects it in an XML file.
1573fn xml_escaped_value(value: &DecodedData) -> String {
1574    let value = match value {
1575        DecodedData::Boolean(b) => if *b { "1".to_owned() } else { "0".to_owned() },
1576        DecodedData::I16(v) => v.to_string(),
1577        DecodedData::I32(v) => v.to_string(),
1578        DecodedData::I64(v) => v.to_string(),
1579        DecodedData::OptionalI16(v) => v.to_string(),
1580        DecodedData::OptionalI32(v) => v.to_string(),
1581        DecodedData::OptionalI64(v) => v.to_string(),
1582        DecodedData::F32(v) => v.to_string(),
1583        DecodedData::F64(v) => v.to_string(),
1584        DecodedData::StringU8(s) | DecodedData::StringU16(s) |
1585        DecodedData::OptionalStringU8(s) | DecodedData::OptionalStringU16(s) |
1586        DecodedData::ColourRGB(s) => s.clone(),
1587        _ => String::new(),
1588    };
1589
1590    value.replace('&', "&amp;")
1591        .replace('<', "&lt;")
1592        .replace('>', "&gt;")
1593        .replace('"', "&quot;")
1594}
1595
1596/// Runs BOB to build `ceo_data.ccd`, with a config that only builds that file.
1597///
1598/// Any previous BOB config is restored afterwards.
1599///
1600/// # Errors
1601///
1602/// Fails if BOB can't be configured or launched, or if it doesn't produce `ceo_data.ccd`.
1603fn run_bob(bob_exe: &Path, bob_dir: &Path, ceo_ccd: &Path) -> Result<()> {
1604    let cfg_path = bob_dir.join("BOB/default_configuration.xml");
1605
1606    // The `binaries\BOB` config dir is absent on a fresh Assembly Kit that never ran
1607    // BOB; create it up front so the config write below doesn't fail with OS error 3.
1608    if let Some(cfg_dir) = cfg_path.parent() {
1609        std::fs::create_dir_all(cfg_dir)
1610            .map_err(|e| anyhow!("Failed to create BOB config directory {}: {e}", cfg_dir.display()))?;
1611    }
1612
1613    let cfg_backup = bob_dir.join("BOB/default_configuration.xml.rpfm_bak");
1614    let cfg_existed = cfg_path.exists();
1615    if cfg_existed {
1616        std::fs::rename(&cfg_path, &cfg_backup)
1617            .map_err(|e| anyhow!("Failed to backup BOB config {}: {e}", cfg_path.display()))?;
1618    }
1619
1620    let restore_config = || {
1621        let _ = std::fs::remove_file(&cfg_path);
1622        if cfg_existed {
1623            let _ = std::fs::rename(&cfg_backup, &cfg_path);
1624        }
1625    };
1626
1627    if let Err(e) = std::fs::write(&cfg_path, BOB_CONFIG_XML) {
1628        restore_config();
1629        return Err(anyhow!("Failed to write BOB config {}: {e}", cfg_path.display()));
1630    }
1631
1632    info!("[BuildCeo] launching BOB: {} (cwd {})", bob_exe.display(), bob_dir.display());
1633    let bob_start = Instant::now();
1634
1635    // Use `.status()`, not `.output()`: it waits only on BOB's process handle, so it
1636    // returns the moment BOB exits without blocking to read captured output.
1637    let status = SysCommand::new(bob_exe)
1638        .current_dir(bob_dir)
1639        .stdin(Stdio::null())
1640        .status();
1641
1642    restore_config();
1643
1644    let status = status.map_err(|e| {
1645        info!("[BuildCeo] BOB spawn failed after {:?}: {e}", bob_start.elapsed());
1646        anyhow!("Failed to launch BOB: {e}")
1647    })?;
1648
1649    info!("[BuildCeo] BOB exited after {:?}, exit={:?}", bob_start.elapsed(), status.code());
1650
1651    // BOB is single-process and we waited on its handle above, so its writes are
1652    // already flushed and visible: the file either exists now or never will.
1653    if ceo_ccd.exists() {
1654        Ok(())
1655    } else {
1656        Err(anyhow!("BOB finished (exit {:?}) but ceo_data.ccd was not generated. \
1657            This usually means BOB hit an error in the exported tables.", status.code()))
1658    }
1659}
1660
1661/// Import ceo_data.ccd into the pack after BOB has run.
1662pub fn build_ceo_post(pack: &mut Pack, akit_path: &str) -> Result<Vec<ContainerPath>> {
1663    let ceo_ccd_path = PathBuf::from(akit_path)
1664        .join(r"working_data\campaigns\ceo_data.ccd");
1665
1666    if !ceo_ccd_path.exists() {
1667        return Err(anyhow!("ceo_data.ccd not found. Make sure BOB ran successfully."));
1668    }
1669
1670    let raw_bytes = std::fs::read(&ceo_ccd_path)
1671        .map_err(|e| anyhow!("Failed to read ceo_data.ccd: {e}"))?;
1672
1673    let mut rfile = RFile::new_from_vec(&raw_bytes, FileType::Unknown, 0, "campaigns/ceo_data.ccd");
1674    let _ = rfile.guess_file_type();
1675    match pack.insert(rfile) {
1676        Ok(Some(path)) => Ok(vec![path]),
1677        Ok(None) => Ok(vec![]),
1678        Err(e) => Err(anyhow!("{}", e)),
1679    }
1680}