Skip to main content

fallout_core/core_api/
engine.rs

1use std::collections::{BTreeMap, BTreeSet};
2use std::io::Cursor;
3
4use crate::fallout1;
5use crate::fallout1::types as f1_types;
6use crate::fallout2;
7use crate::fallout2::types as f2_types;
8use crate::gender::Gender;
9
10use super::ItemCatalog;
11use super::error::{CoreError, CoreErrorCode};
12use super::types::{
13    Capabilities, CapabilityIssue, CharacterExport, DateParts, Game, InventoryEntry,
14    KillCountEntry, PerkEntry, ResolvedInventoryEntry, SkillEntry, Snapshot, StatEntry, TraitEntry,
15};
16
17const STAT_AGE_INDEX: usize = 33;
18const STAT_GENDER_INDEX: usize = 34;
19const GAME_TIME_TICKS_PER_YEAR: u32 = 315_360_000;
20const INVENTORY_CAPS_PID: i32 = -1;
21const TRAIT_SLOT_COUNT: usize = 2;
22
23#[derive(Debug, Default, Clone, Copy)]
24pub struct Engine;
25
26#[derive(Debug)]
27enum LoadedDocument {
28    Fallout1(Box<fallout1::Document>),
29    Fallout2(Box<fallout2::Document>),
30}
31
32#[derive(Debug)]
33pub struct Session {
34    game: Game,
35    snapshot: Snapshot,
36    capabilities: Capabilities,
37    document: LoadedDocument,
38}
39
40impl Engine {
41    pub fn new() -> Self {
42        Self
43    }
44
45    pub fn open_bytes<B: AsRef<[u8]>>(
46        &self,
47        bytes: B,
48        hint: Option<Game>,
49    ) -> Result<Session, CoreError> {
50        let bytes = bytes.as_ref();
51
52        match hint {
53            Some(Game::Fallout1) => parse_fallout1(bytes)
54                .map(session_from_fallout1)
55                .map_err(|e| {
56                    CoreError::new(
57                        CoreErrorCode::Parse,
58                        format!("failed to parse as Fallout 1: {e}"),
59                    )
60                }),
61            Some(Game::Fallout2) => parse_fallout2(bytes)
62                .map(session_from_fallout2)
63                .map_err(|e| {
64                    CoreError::new(
65                        CoreErrorCode::Parse,
66                        format!("failed to parse as Fallout 2: {e}"),
67                    )
68                }),
69            None => {
70                let f1 = parse_fallout1(bytes);
71                let f2 = parse_fallout2(bytes);
72
73                match (f1, f2) {
74                    (Ok(doc), Err(_)) => Ok(session_from_fallout1(doc)),
75                    (Err(_), Ok(doc)) => Ok(session_from_fallout2(doc)),
76                    (Ok(_), Ok(_)) => Err(CoreError::new(
77                        CoreErrorCode::GameDetectionAmbiguous,
78                        "input parsed as both Fallout 1 and Fallout 2; supply a game hint",
79                    )),
80                    (Err(e1), Err(e2)) => Err(CoreError::new(
81                        CoreErrorCode::Parse,
82                        format!("failed to parse input: Fallout 1: {e1}; Fallout 2: {e2}"),
83                    )),
84                }
85            }
86        }
87    }
88}
89
90impl Session {
91    pub fn game(&self) -> Game {
92        self.game
93    }
94
95    pub fn snapshot(&self) -> &Snapshot {
96        &self.snapshot
97    }
98
99    pub fn capabilities(&self) -> &Capabilities {
100        &self.capabilities
101    }
102
103    pub fn export_character(&self) -> CharacterExport {
104        let snapshot = self.snapshot();
105        CharacterExport {
106            game: self.game(),
107            description: snapshot.description.clone(),
108            game_date: snapshot.game_date,
109            save_date: snapshot.file_date,
110            game_time: snapshot.game_time,
111            name: snapshot.character_name.clone(),
112            gender: snapshot.gender,
113            level: snapshot.level,
114            xp: snapshot.experience,
115            next_level_xp: self.next_level_xp(),
116            skill_points: snapshot.unspent_skill_points,
117            map: snapshot.map_filename.clone(),
118            map_id: snapshot.map_id,
119            elevation: snapshot.elevation,
120            global_var_count: snapshot.global_var_count,
121            hp: self.current_hp(),
122            karma: snapshot.karma,
123            reputation: snapshot.reputation,
124            special: self.special_stats(),
125            stats: self.stats(),
126            traits: self.selected_traits(),
127            perks: self.active_perks(),
128            skills: self.skills(),
129            tagged_skills: self.tagged_skill_indices(),
130            kill_counts: self.nonzero_kill_counts(),
131            inventory: self.inventory(),
132        }
133    }
134
135    pub fn apply_character(&mut self, character: &CharacterExport) -> Result<(), CoreError> {
136        if character.game != self.game {
137            return Err(CoreError::new(
138                CoreErrorCode::UnsupportedOperation,
139                format!(
140                    "character export game mismatch: session is {:?}, input is {:?}",
141                    self.game, character.game
142                ),
143            ));
144        }
145
146        let current = self.export_character();
147
148        for stat in &character.special {
149            let current_base = current
150                .special
151                .iter()
152                .find(|entry| entry.index == stat.index)
153                .map(|entry| entry.base);
154            if current_base != Some(stat.base) {
155                self.set_base_stat(stat.index, stat.base)?;
156            }
157        }
158
159        if character.gender != current.gender {
160            self.set_gender(character.gender)?;
161        }
162        if character.level != current.level {
163            self.set_level(character.level)?;
164        }
165        if character.xp != current.xp {
166            self.set_experience(character.xp)?;
167        }
168        if character.skill_points != current.skill_points {
169            self.set_skill_points(character.skill_points)?;
170        }
171        if character.karma != current.karma {
172            self.set_karma(character.karma)?;
173        }
174        if character.reputation != current.reputation {
175            self.set_reputation(character.reputation)?;
176        }
177
178        if character.hp != current.hp {
179            let Some(hp) = character.hp else {
180                return Err(CoreError::new(
181                    CoreErrorCode::UnsupportedOperation,
182                    "cannot clear HP via character export",
183                ));
184            };
185            self.set_hp(hp)?;
186        }
187
188        if let Some(effective_age) = export_age_total(&character.stats) {
189            if Some(effective_age) != export_age_total(&current.stats) {
190                let base_age =
191                    effective_age.saturating_sub(elapsed_game_years(self.snapshot.game_time));
192                self.set_age(base_age)?;
193            }
194        }
195
196        if character.traits != current.traits {
197            self.apply_traits_from_export(&character.traits)?;
198        }
199        if character.perks != current.perks {
200            self.apply_perks_from_export(&character.perks)?;
201        }
202        if character.inventory != current.inventory {
203            self.apply_inventory_from_export(&character.inventory)?;
204        }
205        Ok(())
206    }
207
208    pub fn special_stats(&self) -> Vec<StatEntry> {
209        match &self.document {
210            LoadedDocument::Fallout1(doc) => collect_stat_entries(
211                &f1_types::STAT_NAMES,
212                &doc.save.critter_data.base_stats,
213                &doc.save.critter_data.bonus_stats,
214                0..7,
215                false,
216            ),
217            LoadedDocument::Fallout2(doc) => collect_stat_entries(
218                &f2_types::STAT_NAMES,
219                &doc.save.critter_data.base_stats,
220                &doc.save.critter_data.bonus_stats,
221                0..7,
222                false,
223            ),
224        }
225    }
226
227    pub fn derived_stats_nonzero(&self) -> Vec<StatEntry> {
228        self.stats()
229            .into_iter()
230            .filter(|stat| !(stat.total == 0 && stat.bonus == 0))
231            .collect()
232    }
233
234    pub fn skills(&self) -> Vec<SkillEntry> {
235        match &self.document {
236            LoadedDocument::Fallout1(doc) => {
237                let save = &doc.save;
238                let mut out = Vec::with_capacity(f1_types::SKILL_NAMES.len());
239                for (index, name) in f1_types::SKILL_NAMES.iter().enumerate() {
240                    let raw = save.critter_data.skills[index];
241                    let tag_bonus = save.skill_tag_bonus(index);
242                    let total = save.effective_skill_value(index);
243                    out.push(SkillEntry {
244                        index,
245                        name: (*name).to_string(),
246                        raw,
247                        tag_bonus,
248                        bonus: total - raw,
249                        total,
250                    });
251                }
252                out
253            }
254            LoadedDocument::Fallout2(doc) => {
255                let save = &doc.save;
256                let mut out = Vec::with_capacity(f2_types::SKILL_NAMES.len());
257                for (index, name) in f2_types::SKILL_NAMES.iter().enumerate() {
258                    let raw = save.critter_data.skills[index];
259                    let tag_bonus = save.skill_tag_bonus(index);
260                    let total = save.effective_skill_value(index);
261                    out.push(SkillEntry {
262                        index,
263                        name: (*name).to_string(),
264                        raw,
265                        tag_bonus,
266                        bonus: total - raw,
267                        total,
268                    });
269                }
270                out
271            }
272        }
273    }
274
275    pub fn tagged_skill_indices(&self) -> Vec<usize> {
276        match &self.document {
277            LoadedDocument::Fallout1(doc) => {
278                normalize_tagged_skill_indices(&doc.save.tagged_skills, f1_types::SKILL_NAMES.len())
279            }
280            LoadedDocument::Fallout2(doc) => {
281                normalize_tagged_skill_indices(&doc.save.tagged_skills, f2_types::SKILL_NAMES.len())
282            }
283        }
284    }
285
286    pub fn active_perks(&self) -> Vec<PerkEntry> {
287        match &self.document {
288            LoadedDocument::Fallout1(doc) => doc
289                .save
290                .perks
291                .iter()
292                .enumerate()
293                .filter_map(|(index, &rank)| {
294                    if rank <= 0 {
295                        return None;
296                    }
297                    Some(PerkEntry {
298                        index,
299                        name: f1_types::PERK_NAMES[index].to_string(),
300                        rank,
301                    })
302                })
303                .collect(),
304            LoadedDocument::Fallout2(doc) => doc
305                .save
306                .perks
307                .iter()
308                .enumerate()
309                .filter_map(|(index, &rank)| {
310                    if rank <= 0 {
311                        return None;
312                    }
313                    Some(PerkEntry {
314                        index,
315                        name: f2_types::PERK_NAMES[index].to_string(),
316                        rank,
317                    })
318                })
319                .collect(),
320        }
321    }
322
323    pub fn selected_traits(&self) -> Vec<TraitEntry> {
324        let traits = match &self.document {
325            LoadedDocument::Fallout1(doc) => doc.save.selected_traits,
326            LoadedDocument::Fallout2(doc) => doc.save.selected_traits,
327        };
328        let names = match &self.document {
329            LoadedDocument::Fallout1(_) => &f1_types::TRAIT_NAMES[..],
330            LoadedDocument::Fallout2(_) => &f2_types::TRAIT_NAMES[..],
331        };
332        traits
333            .iter()
334            .filter(|&&v| v >= 0 && (v as usize) < names.len())
335            .map(|&v| TraitEntry {
336                index: v as usize,
337                name: names[v as usize].to_string(),
338            })
339            .collect()
340    }
341
342    pub fn all_kill_counts(&self) -> Vec<KillCountEntry> {
343        match &self.document {
344            LoadedDocument::Fallout1(doc) => doc
345                .save
346                .kill_counts
347                .iter()
348                .enumerate()
349                .map(|(index, &count)| KillCountEntry {
350                    index,
351                    name: f1_types::KILL_TYPE_NAMES[index].to_string(),
352                    count,
353                })
354                .collect(),
355            LoadedDocument::Fallout2(doc) => doc
356                .save
357                .kill_counts
358                .iter()
359                .enumerate()
360                .map(|(index, &count)| KillCountEntry {
361                    index,
362                    name: f2_types::KILL_TYPE_NAMES[index].to_string(),
363                    count,
364                })
365                .collect(),
366        }
367    }
368
369    pub fn nonzero_kill_counts(&self) -> Vec<KillCountEntry> {
370        match &self.document {
371            LoadedDocument::Fallout1(doc) => doc
372                .save
373                .kill_counts
374                .iter()
375                .enumerate()
376                .filter_map(|(index, &count)| {
377                    if count <= 0 {
378                        return None;
379                    }
380                    Some(KillCountEntry {
381                        index,
382                        name: f1_types::KILL_TYPE_NAMES[index].to_string(),
383                        count,
384                    })
385                })
386                .collect(),
387            LoadedDocument::Fallout2(doc) => doc
388                .save
389                .kill_counts
390                .iter()
391                .enumerate()
392                .filter_map(|(index, &count)| {
393                    if count <= 0 {
394                        return None;
395                    }
396                    Some(KillCountEntry {
397                        index,
398                        name: f2_types::KILL_TYPE_NAMES[index].to_string(),
399                        count,
400                    })
401                })
402                .collect(),
403        }
404    }
405
406    pub fn map_files(&self) -> Vec<String> {
407        match &self.document {
408            LoadedDocument::Fallout1(doc) => doc.save.map_files.clone(),
409            LoadedDocument::Fallout2(doc) => doc.save.map_files.clone(),
410        }
411    }
412
413    pub fn age(&self) -> i32 {
414        self.stat(STAT_AGE_INDEX).total
415    }
416
417    pub fn max_hp(&self) -> i32 {
418        self.stat(7).total
419    }
420
421    pub fn next_level_xp(&self) -> i32 {
422        let l = self.snapshot.level;
423        (l + 1) * l / 2 * 1000
424    }
425
426    pub fn stat(&self, index: usize) -> StatEntry {
427        match &self.document {
428            LoadedDocument::Fallout1(doc) => {
429                let base = doc.save.critter_data.base_stats[index];
430                let bonus = doc.save.critter_data.bonus_stats[index];
431                StatEntry {
432                    index,
433                    name: f1_types::STAT_NAMES[index].to_string(),
434                    base,
435                    bonus,
436                    total: total_for_stat(index, base, bonus, self.snapshot.game_time),
437                }
438            }
439            LoadedDocument::Fallout2(doc) => {
440                let base = doc.save.critter_data.base_stats[index];
441                let bonus = doc.save.critter_data.bonus_stats[index];
442                StatEntry {
443                    index,
444                    name: f2_types::STAT_NAMES[index].to_string(),
445                    base,
446                    bonus,
447                    total: total_for_stat(index, base, bonus, self.snapshot.game_time),
448                }
449            }
450        }
451    }
452
453    pub fn stats(&self) -> Vec<StatEntry> {
454        (7..STAT_GENDER_INDEX)
455            .map(|index| self.stat(index))
456            .collect()
457    }
458
459    pub fn all_derived_stats(&self) -> Vec<StatEntry> {
460        self.stats()
461    }
462
463    pub fn inventory(&self) -> Vec<InventoryEntry> {
464        let items = match &self.document {
465            LoadedDocument::Fallout1(doc) => &doc.save.player_object.inventory,
466            LoadedDocument::Fallout2(doc) => &doc.save.player_object.inventory,
467        };
468        items
469            .iter()
470            .map(|item| InventoryEntry {
471                quantity: item.quantity,
472                pid: item.object.pid,
473            })
474            .collect()
475    }
476
477    pub fn inventory_resolved(&self, catalog: &ItemCatalog) -> Vec<ResolvedInventoryEntry> {
478        self.inventory()
479            .into_iter()
480            .map(|item| {
481                let meta = catalog.get(item.pid);
482                ResolvedInventoryEntry {
483                    quantity: item.quantity,
484                    pid: item.pid,
485                    name: meta.map(|entry| entry.name.clone()),
486                    base_weight: meta.map(|entry| entry.base_weight),
487                    item_type: meta.map(|entry| entry.item_type),
488                }
489            })
490            .collect()
491    }
492
493    /// Resolve inventory using the built-in well-known item table.
494    /// Falls back to pid-only entries for items not in the table.
495    pub fn inventory_resolved_builtin(&self) -> Vec<ResolvedInventoryEntry> {
496        let game = self.game();
497        self.inventory()
498            .into_iter()
499            .map(|item| {
500                let known = super::well_known_items::lookup(game, item.pid);
501                ResolvedInventoryEntry {
502                    quantity: item.quantity,
503                    pid: item.pid,
504                    name: known.map(|(name, _)| name.to_string()),
505                    base_weight: known.map(|(_, w)| w),
506                    item_type: None,
507                }
508            })
509            .collect()
510    }
511
512    pub fn inventory_total_weight_lbs(&self, catalog: &ItemCatalog) -> Option<i32> {
513        let mut total = 0i64;
514        for item in self.inventory() {
515            if item.pid == INVENTORY_CAPS_PID {
516                continue;
517            }
518            let meta = catalog.get(item.pid)?;
519            total = total.checked_add(i64::from(item.quantity) * i64::from(meta.base_weight))?;
520        }
521        i32::try_from(total).ok()
522    }
523
524    pub fn to_bytes_unmodified(&self) -> Result<Vec<u8>, CoreError> {
525        match &self.document {
526            LoadedDocument::Fallout1(doc) => doc.to_bytes_unmodified(),
527            LoadedDocument::Fallout2(doc) => doc.to_bytes_unmodified(),
528        }
529        .map_err(|e| {
530            CoreError::new(
531                CoreErrorCode::Io,
532                format!("failed to emit unmodified bytes: {e}"),
533            )
534        })
535    }
536
537    pub fn to_bytes_modified(&self) -> Result<Vec<u8>, CoreError> {
538        match &self.document {
539            LoadedDocument::Fallout1(doc) => doc.to_bytes_modified(),
540            LoadedDocument::Fallout2(doc) => doc.to_bytes_modified(),
541        }
542        .map_err(|e| {
543            CoreError::new(
544                CoreErrorCode::Io,
545                format!("failed to emit modified bytes: {e}"),
546            )
547        })
548    }
549
550    pub fn current_hp(&self) -> Option<i32> {
551        match &self.document {
552            LoadedDocument::Fallout1(doc) => extract_hp(&doc.save.player_object),
553            LoadedDocument::Fallout2(doc) => extract_hp(&doc.save.player_object),
554        }
555    }
556
557    pub fn set_hp(&mut self, hp: i32) -> Result<(), CoreError> {
558        match &mut self.document {
559            LoadedDocument::Fallout1(doc) => doc.set_hp(hp),
560            LoadedDocument::Fallout2(doc) => doc.set_hp(hp),
561        }
562        .map_err(|e| {
563            CoreError::new(
564                CoreErrorCode::UnsupportedOperation,
565                format!("failed to set HP: {e}"),
566            )
567        })?;
568
569        self.snapshot.hp = Some(hp);
570        Ok(())
571    }
572
573    pub fn set_base_stat(&mut self, stat_index: usize, value: i32) -> Result<(), CoreError> {
574        if stat_index > 6 {
575            return Err(CoreError::new(
576                CoreErrorCode::UnsupportedOperation,
577                format!("invalid SPECIAL stat index {stat_index}, expected 0-6"),
578            ));
579        }
580
581        match &mut self.document {
582            LoadedDocument::Fallout1(doc) => doc.set_base_stat(stat_index, value),
583            LoadedDocument::Fallout2(doc) => doc.set_base_stat(stat_index, value),
584        }
585        .map_err(|e| {
586            CoreError::new(
587                CoreErrorCode::UnsupportedOperation,
588                format!("failed to set stat {stat_index}: {e}"),
589            )
590        })
591    }
592
593    pub fn set_gender(&mut self, gender: Gender) -> Result<(), CoreError> {
594        match &mut self.document {
595            LoadedDocument::Fallout1(doc) => doc.set_gender(gender),
596            LoadedDocument::Fallout2(doc) => doc.set_gender(gender),
597        }
598        .map_err(|e| {
599            CoreError::new(
600                CoreErrorCode::UnsupportedOperation,
601                format!("failed to set gender: {e}"),
602            )
603        })?;
604
605        self.snapshot.gender = gender;
606        Ok(())
607    }
608
609    pub fn set_age(&mut self, age: i32) -> Result<(), CoreError> {
610        match &mut self.document {
611            LoadedDocument::Fallout1(doc) => doc.set_age(age),
612            LoadedDocument::Fallout2(doc) => doc.set_age(age),
613        }
614        .map_err(|e| {
615            CoreError::new(
616                CoreErrorCode::UnsupportedOperation,
617                format!("failed to set age: {e}"),
618            )
619        })
620    }
621
622    pub fn set_level(&mut self, level: i32) -> Result<(), CoreError> {
623        match &mut self.document {
624            LoadedDocument::Fallout1(doc) => doc.set_level(level),
625            LoadedDocument::Fallout2(doc) => doc.set_level(level),
626        }
627        .map_err(|e| {
628            CoreError::new(
629                CoreErrorCode::UnsupportedOperation,
630                format!("failed to set level: {e}"),
631            )
632        })?;
633
634        self.snapshot.level = level;
635        Ok(())
636    }
637
638    pub fn set_experience(&mut self, experience: i32) -> Result<(), CoreError> {
639        match &mut self.document {
640            LoadedDocument::Fallout1(doc) => doc.set_experience(experience),
641            LoadedDocument::Fallout2(doc) => doc.set_experience(experience),
642        }
643        .map_err(|e| {
644            CoreError::new(
645                CoreErrorCode::UnsupportedOperation,
646                format!("failed to set experience: {e}"),
647            )
648        })?;
649
650        self.snapshot.experience = experience;
651        Ok(())
652    }
653
654    pub fn set_skill_points(&mut self, skill_points: i32) -> Result<(), CoreError> {
655        match &mut self.document {
656            LoadedDocument::Fallout1(doc) => doc.set_skill_points(skill_points),
657            LoadedDocument::Fallout2(doc) => doc.set_skill_points(skill_points),
658        }
659        .map_err(|e| {
660            CoreError::new(
661                CoreErrorCode::UnsupportedOperation,
662                format!("failed to set skill points: {e}"),
663            )
664        })?;
665
666        self.snapshot.unspent_skill_points = skill_points;
667        Ok(())
668    }
669
670    pub fn set_reputation(&mut self, reputation: i32) -> Result<(), CoreError> {
671        match &mut self.document {
672            LoadedDocument::Fallout1(doc) => doc.set_reputation(reputation),
673            LoadedDocument::Fallout2(doc) => doc.set_reputation(reputation),
674        }
675        .map_err(|e| {
676            CoreError::new(
677                CoreErrorCode::UnsupportedOperation,
678                format!("failed to set reputation: {e}"),
679            )
680        })?;
681
682        self.snapshot.reputation = reputation;
683        Ok(())
684    }
685
686    pub fn set_karma(&mut self, karma: i32) -> Result<(), CoreError> {
687        match &mut self.document {
688            LoadedDocument::Fallout1(doc) => doc.set_karma(karma),
689            LoadedDocument::Fallout2(doc) => doc.set_karma(karma),
690        }
691        .map_err(|e| {
692            CoreError::new(
693                CoreErrorCode::UnsupportedOperation,
694                format!("failed to set karma: {e}"),
695            )
696        })?;
697
698        self.snapshot.karma = karma;
699        Ok(())
700    }
701
702    pub fn set_trait(&mut self, slot: usize, trait_index: usize) -> Result<(), CoreError> {
703        let trait_index_i32 = i32::try_from(trait_index).map_err(|_| {
704            CoreError::new(
705                CoreErrorCode::UnsupportedOperation,
706                format!("invalid trait index {trait_index}"),
707            )
708        })?;
709
710        match &mut self.document {
711            LoadedDocument::Fallout1(doc) => doc.set_trait(slot, trait_index_i32),
712            LoadedDocument::Fallout2(doc) => doc.set_trait(slot, trait_index_i32),
713        }
714        .map_err(|e| {
715            CoreError::new(
716                CoreErrorCode::UnsupportedOperation,
717                format!("failed to set trait in slot {slot}: {e}"),
718            )
719        })?;
720
721        self.sync_snapshot_selected_traits();
722        Ok(())
723    }
724
725    pub fn clear_trait(&mut self, slot: usize) -> Result<(), CoreError> {
726        match &mut self.document {
727            LoadedDocument::Fallout1(doc) => doc.clear_trait(slot),
728            LoadedDocument::Fallout2(doc) => doc.clear_trait(slot),
729        }
730        .map_err(|e| {
731            CoreError::new(
732                CoreErrorCode::UnsupportedOperation,
733                format!("failed to clear trait in slot {slot}: {e}"),
734            )
735        })?;
736
737        self.sync_snapshot_selected_traits();
738        Ok(())
739    }
740
741    pub fn set_perk_rank(&mut self, perk_index: usize, rank: i32) -> Result<(), CoreError> {
742        match &mut self.document {
743            LoadedDocument::Fallout1(doc) => doc.set_perk_rank(perk_index, rank),
744            LoadedDocument::Fallout2(doc) => doc.set_perk_rank(perk_index, rank),
745        }
746        .map_err(|e| {
747            CoreError::new(
748                CoreErrorCode::UnsupportedOperation,
749                format!("failed to set perk {perk_index} rank: {e}"),
750            )
751        })
752    }
753
754    pub fn clear_perk(&mut self, perk_index: usize) -> Result<(), CoreError> {
755        match &mut self.document {
756            LoadedDocument::Fallout1(doc) => doc.clear_perk(perk_index),
757            LoadedDocument::Fallout2(doc) => doc.clear_perk(perk_index),
758        }
759        .map_err(|e| {
760            CoreError::new(
761                CoreErrorCode::UnsupportedOperation,
762                format!("failed to clear perk {perk_index}: {e}"),
763            )
764        })
765    }
766
767    pub fn set_inventory_quantity(&mut self, pid: i32, quantity: i32) -> Result<(), CoreError> {
768        match &mut self.document {
769            LoadedDocument::Fallout1(doc) => doc.set_inventory_quantity(pid, quantity),
770            LoadedDocument::Fallout2(doc) => doc.set_inventory_quantity(pid, quantity),
771        }
772        .map_err(|e| {
773            CoreError::new(
774                CoreErrorCode::UnsupportedOperation,
775                format!("failed to set inventory quantity for pid={pid}: {e}"),
776            )
777        })
778    }
779
780    pub fn add_inventory_item(&mut self, pid: i32, quantity: i32) -> Result<(), CoreError> {
781        match &mut self.document {
782            LoadedDocument::Fallout1(doc) => doc.add_inventory_item(pid, quantity),
783            LoadedDocument::Fallout2(doc) => doc.add_inventory_item(pid, quantity),
784        }
785        .map_err(|e| {
786            CoreError::new(
787                CoreErrorCode::UnsupportedOperation,
788                format!("failed to add inventory item pid={pid}: {e}"),
789            )
790        })
791    }
792
793    pub fn remove_inventory_item(
794        &mut self,
795        pid: i32,
796        quantity: Option<i32>,
797    ) -> Result<(), CoreError> {
798        match &mut self.document {
799            LoadedDocument::Fallout1(doc) => doc.remove_inventory_item(pid, quantity),
800            LoadedDocument::Fallout2(doc) => doc.remove_inventory_item(pid, quantity),
801        }
802        .map_err(|e| {
803            CoreError::new(
804                CoreErrorCode::UnsupportedOperation,
805                format!("failed to remove inventory item pid={pid}: {e}"),
806            )
807        })
808    }
809
810    fn sync_snapshot_selected_traits(&mut self) {
811        self.snapshot.selected_traits = match &self.document {
812            LoadedDocument::Fallout1(doc) => doc.save.selected_traits,
813            LoadedDocument::Fallout2(doc) => doc.save.selected_traits,
814        };
815    }
816
817    fn apply_traits_from_export(&mut self, traits: &[TraitEntry]) -> Result<(), CoreError> {
818        for slot in 0..TRAIT_SLOT_COUNT {
819            self.clear_trait(slot)?;
820        }
821
822        for (slot, trait_entry) in traits.iter().take(TRAIT_SLOT_COUNT).enumerate() {
823            self.set_trait(slot, trait_entry.index)?;
824        }
825        Ok(())
826    }
827
828    fn apply_perks_from_export(&mut self, perks: &[PerkEntry]) -> Result<(), CoreError> {
829        for perk_index in 0..perk_count_for_game(self.game()) {
830            self.clear_perk(perk_index)?;
831        }
832
833        for perk in perks {
834            self.set_perk_rank(perk.index, perk.rank)?;
835        }
836        Ok(())
837    }
838
839    fn apply_inventory_from_export(
840        &mut self,
841        inventory: &[InventoryEntry],
842    ) -> Result<(), CoreError> {
843        let mut desired_by_pid: BTreeMap<i32, i32> = BTreeMap::new();
844        for item in inventory {
845            let quantity = desired_by_pid.entry(item.pid).or_insert(0);
846            *quantity = quantity.saturating_add(item.quantity);
847        }
848
849        let existing_pids: BTreeSet<i32> =
850            self.inventory().into_iter().map(|item| item.pid).collect();
851        for pid in existing_pids
852            .iter()
853            .copied()
854            .filter(|pid| !desired_by_pid.contains_key(pid))
855        {
856            self.remove_inventory_item(pid, None)?;
857        }
858
859        for (pid, quantity) in desired_by_pid {
860            if quantity <= 0 {
861                if existing_pids.contains(&pid) {
862                    self.remove_inventory_item(pid, None)?;
863                }
864                continue;
865            }
866
867            if existing_pids.contains(&pid) {
868                self.set_inventory_quantity(pid, quantity)?;
869            } else {
870                self.add_inventory_item(pid, quantity)?;
871            }
872        }
873        Ok(())
874    }
875}
876
877fn parse_fallout1(bytes: &[u8]) -> std::io::Result<fallout1::Document> {
878    fallout1::Document::parse_with_layout(Cursor::new(bytes))
879}
880
881fn parse_fallout2(bytes: &[u8]) -> std::io::Result<fallout2::Document> {
882    fallout2::Document::parse_with_layout(Cursor::new(bytes))
883}
884
885fn session_from_fallout1(doc: fallout1::Document) -> Session {
886    let save = &doc.save;
887    let snapshot = Snapshot {
888        game: Game::Fallout1,
889        character_name: save.header.character_name.clone(),
890        description: save.header.description.clone(),
891        map_filename: save.header.map_filename.clone(),
892        map_id: save.header.map,
893        elevation: save.header.elevation,
894        file_date: DateParts {
895            day: save.header.file_day,
896            month: save.header.file_month,
897            year: save.header.file_year,
898        },
899        game_date: DateParts {
900            day: save.header.game_day,
901            month: save.header.game_month,
902            year: save.header.game_year,
903        },
904        gender: save.gender,
905        level: save.pc_stats.level,
906        experience: save.pc_stats.experience,
907        unspent_skill_points: save.pc_stats.unspent_skill_points,
908        karma: save.pc_stats.karma,
909        reputation: save.pc_stats.reputation,
910        global_var_count: save.global_var_count,
911        selected_traits: save.selected_traits,
912        hp: extract_hp(&save.player_object),
913        game_time: save.header.game_time,
914    };
915
916    Session {
917        game: Game::Fallout1,
918        snapshot,
919        capabilities: Capabilities::editable(Vec::new()),
920        document: LoadedDocument::Fallout1(Box::new(doc)),
921    }
922}
923
924fn session_from_fallout2(doc: fallout2::Document) -> Session {
925    let save = &doc.save;
926    let mut issues = Vec::new();
927    if save.layout_detection_score <= 0 {
928        issues.push(CapabilityIssue::LowConfidenceLayout);
929    }
930
931    let snapshot = Snapshot {
932        game: Game::Fallout2,
933        character_name: save.header.character_name.clone(),
934        description: save.header.description.clone(),
935        map_filename: save.header.map_filename.clone(),
936        map_id: save.header.map,
937        elevation: save.header.elevation,
938        file_date: DateParts {
939            day: save.header.file_day,
940            month: save.header.file_month,
941            year: save.header.file_year,
942        },
943        game_date: DateParts {
944            day: save.header.game_day,
945            month: save.header.game_month,
946            year: save.header.game_year,
947        },
948        gender: save.gender,
949        level: save.pc_stats.level,
950        experience: save.pc_stats.experience,
951        unspent_skill_points: save.pc_stats.unspent_skill_points,
952        karma: save.pc_stats.karma,
953        reputation: save.pc_stats.reputation,
954        global_var_count: save.global_var_count,
955        selected_traits: save.selected_traits,
956        hp: extract_hp(&save.player_object),
957        game_time: save.header.game_time,
958    };
959
960    Session {
961        game: Game::Fallout2,
962        snapshot,
963        capabilities: Capabilities::editable(issues),
964        document: LoadedDocument::Fallout2(Box::new(doc)),
965    }
966}
967
968fn extract_hp(obj: &crate::object::GameObject) -> Option<i32> {
969    match &obj.object_data {
970        crate::object::ObjectData::Critter(data) => Some(data.hp),
971        _ => None,
972    }
973}
974
975fn collect_stat_entries(
976    names: &[&str],
977    base_stats: &[i32],
978    bonus_stats: &[i32],
979    indices: std::ops::Range<usize>,
980    hide_zero_totals: bool,
981) -> Vec<StatEntry> {
982    let mut out = Vec::new();
983    for index in indices {
984        let base = base_stats[index];
985        let bonus = bonus_stats[index];
986        let total = base + bonus;
987
988        if hide_zero_totals && total == 0 && bonus == 0 {
989            continue;
990        }
991
992        out.push(StatEntry {
993            index,
994            name: names[index].to_string(),
995            base,
996            bonus,
997            total,
998        });
999    }
1000    out
1001}
1002
1003fn total_for_stat(index: usize, base: i32, bonus: i32, game_time: u32) -> i32 {
1004    if index == STAT_AGE_INDEX {
1005        return effective_age_total(base, bonus, game_time);
1006    }
1007
1008    base + bonus
1009}
1010
1011fn effective_age_total(base: i32, bonus: i32, game_time: u32) -> i32 {
1012    base.saturating_add(bonus)
1013        .saturating_add(elapsed_game_years(game_time))
1014}
1015
1016fn elapsed_game_years(game_time: u32) -> i32 {
1017    i32::try_from(game_time / GAME_TIME_TICKS_PER_YEAR).unwrap_or(i32::MAX)
1018}
1019
1020fn normalize_tagged_skill_indices(tagged_skills: &[i32], skill_count: usize) -> Vec<usize> {
1021    let mut out = Vec::new();
1022    for raw in tagged_skills {
1023        let Ok(index) = usize::try_from(*raw) else {
1024            continue;
1025        };
1026        if index >= skill_count || out.contains(&index) {
1027            continue;
1028        }
1029        out.push(index);
1030    }
1031    out
1032}
1033
1034fn export_age_total(stats: &[StatEntry]) -> Option<i32> {
1035    stats
1036        .iter()
1037        .find(|stat| stat.index == STAT_AGE_INDEX)
1038        .map(|stat| stat.total)
1039}
1040
1041fn perk_count_for_game(game: Game) -> usize {
1042    match game {
1043        Game::Fallout1 => f1_types::PERK_NAMES.len(),
1044        Game::Fallout2 => f2_types::PERK_NAMES.len(),
1045    }
1046}