Skip to main content

fallout_core/core_api/
engine.rs

1use std::io::Cursor;
2
3use crate::fallout1;
4use crate::fallout1::types as f1_types;
5use crate::fallout2;
6use crate::fallout2::types as f2_types;
7use crate::gender::Gender;
8
9use super::ItemCatalog;
10use super::error::{CoreError, CoreErrorCode};
11use super::types::{
12    Capabilities, CapabilityIssue, DateParts, Game, InventoryEntry, KillCountEntry, PerkEntry,
13    ResolvedInventoryEntry, SkillEntry, Snapshot, StatEntry, TraitEntry,
14};
15
16const STAT_AGE_INDEX: usize = 33;
17const INVENTORY_CAPS_PID: i32 = -1;
18
19#[derive(Debug, Default, Clone, Copy)]
20pub struct Engine;
21
22#[derive(Debug)]
23enum LoadedDocument {
24    Fallout1(Box<fallout1::Document>),
25    Fallout2(Box<fallout2::Document>),
26}
27
28#[derive(Debug)]
29pub struct Session {
30    game: Game,
31    snapshot: Snapshot,
32    capabilities: Capabilities,
33    document: LoadedDocument,
34}
35
36impl Engine {
37    pub fn new() -> Self {
38        Self
39    }
40
41    pub fn open_bytes<B: AsRef<[u8]>>(
42        &self,
43        bytes: B,
44        hint: Option<Game>,
45    ) -> Result<Session, CoreError> {
46        let bytes = bytes.as_ref();
47
48        match hint {
49            Some(Game::Fallout1) => parse_fallout1(bytes)
50                .map(session_from_fallout1)
51                .map_err(|e| {
52                    CoreError::new(
53                        CoreErrorCode::Parse,
54                        format!("failed to parse as Fallout 1: {e}"),
55                    )
56                }),
57            Some(Game::Fallout2) => parse_fallout2(bytes)
58                .map(session_from_fallout2)
59                .map_err(|e| {
60                    CoreError::new(
61                        CoreErrorCode::Parse,
62                        format!("failed to parse as Fallout 2: {e}"),
63                    )
64                }),
65            None => {
66                let f1 = parse_fallout1(bytes);
67                let f2 = parse_fallout2(bytes);
68
69                match (f1, f2) {
70                    (Ok(doc), Err(_)) => Ok(session_from_fallout1(doc)),
71                    (Err(_), Ok(doc)) => Ok(session_from_fallout2(doc)),
72                    (Ok(_), Ok(_)) => Err(CoreError::new(
73                        CoreErrorCode::GameDetectionAmbiguous,
74                        "input parsed as both Fallout 1 and Fallout 2; supply a game hint",
75                    )),
76                    (Err(e1), Err(e2)) => Err(CoreError::new(
77                        CoreErrorCode::Parse,
78                        format!("failed to parse input: Fallout 1: {e1}; Fallout 2: {e2}"),
79                    )),
80                }
81            }
82        }
83    }
84}
85
86impl Session {
87    pub fn game(&self) -> Game {
88        self.game
89    }
90
91    pub fn snapshot(&self) -> &Snapshot {
92        &self.snapshot
93    }
94
95    pub fn capabilities(&self) -> &Capabilities {
96        &self.capabilities
97    }
98
99    pub fn special_stats(&self) -> Vec<StatEntry> {
100        match &self.document {
101            LoadedDocument::Fallout1(doc) => collect_stat_entries(
102                &f1_types::STAT_NAMES,
103                &doc.save.critter_data.base_stats,
104                &doc.save.critter_data.bonus_stats,
105                0..7,
106                false,
107            ),
108            LoadedDocument::Fallout2(doc) => collect_stat_entries(
109                &f2_types::STAT_NAMES,
110                &doc.save.critter_data.base_stats,
111                &doc.save.critter_data.bonus_stats,
112                0..7,
113                false,
114            ),
115        }
116    }
117
118    pub fn derived_stats_nonzero(&self) -> Vec<StatEntry> {
119        match &self.document {
120            LoadedDocument::Fallout1(doc) => collect_stat_entries(
121                &f1_types::STAT_NAMES,
122                &doc.save.critter_data.base_stats,
123                &doc.save.critter_data.bonus_stats,
124                7..f1_types::STAT_NAMES.len(),
125                true,
126            ),
127            LoadedDocument::Fallout2(doc) => collect_stat_entries(
128                &f2_types::STAT_NAMES,
129                &doc.save.critter_data.base_stats,
130                &doc.save.critter_data.bonus_stats,
131                7..f2_types::STAT_NAMES.len(),
132                true,
133            ),
134        }
135    }
136
137    pub fn skills(&self) -> Vec<SkillEntry> {
138        match &self.document {
139            LoadedDocument::Fallout1(doc) => {
140                let save = &doc.save;
141                let mut out = Vec::with_capacity(f1_types::SKILL_NAMES.len());
142                for (index, name) in f1_types::SKILL_NAMES.iter().enumerate() {
143                    let tagged = save
144                        .tagged_skills
145                        .iter()
146                        .any(|&s| s >= 0 && s as usize == index);
147                    out.push(SkillEntry {
148                        index,
149                        name: (*name).to_string(),
150                        value: save.critter_data.skills[index],
151                        tagged,
152                    });
153                }
154                out
155            }
156            LoadedDocument::Fallout2(doc) => {
157                let save = &doc.save;
158                let mut out = Vec::with_capacity(f2_types::SKILL_NAMES.len());
159                for (index, name) in f2_types::SKILL_NAMES.iter().enumerate() {
160                    let tagged = save
161                        .tagged_skills
162                        .iter()
163                        .any(|&s| s >= 0 && s as usize == index);
164                    out.push(SkillEntry {
165                        index,
166                        name: (*name).to_string(),
167                        value: save.effective_skill_value(index),
168                        tagged,
169                    });
170                }
171                out
172            }
173        }
174    }
175
176    pub fn active_perks(&self) -> Vec<PerkEntry> {
177        match &self.document {
178            LoadedDocument::Fallout1(doc) => doc
179                .save
180                .perks
181                .iter()
182                .enumerate()
183                .filter_map(|(index, &rank)| {
184                    if rank <= 0 {
185                        return None;
186                    }
187                    Some(PerkEntry {
188                        index,
189                        name: f1_types::PERK_NAMES[index].to_string(),
190                        rank,
191                    })
192                })
193                .collect(),
194            LoadedDocument::Fallout2(doc) => doc
195                .save
196                .perks
197                .iter()
198                .enumerate()
199                .filter_map(|(index, &rank)| {
200                    if rank <= 0 {
201                        return None;
202                    }
203                    Some(PerkEntry {
204                        index,
205                        name: f2_types::PERK_NAMES[index].to_string(),
206                        rank,
207                    })
208                })
209                .collect(),
210        }
211    }
212
213    pub fn selected_traits(&self) -> Vec<TraitEntry> {
214        let traits = match &self.document {
215            LoadedDocument::Fallout1(doc) => doc.save.selected_traits,
216            LoadedDocument::Fallout2(doc) => doc.save.selected_traits,
217        };
218        let names = match &self.document {
219            LoadedDocument::Fallout1(_) => &f1_types::TRAIT_NAMES[..],
220            LoadedDocument::Fallout2(_) => &f2_types::TRAIT_NAMES[..],
221        };
222        traits
223            .iter()
224            .filter(|&&v| v >= 0 && (v as usize) < names.len())
225            .map(|&v| TraitEntry {
226                index: v as usize,
227                name: names[v as usize].to_string(),
228            })
229            .collect()
230    }
231
232    pub fn all_kill_counts(&self) -> Vec<KillCountEntry> {
233        match &self.document {
234            LoadedDocument::Fallout1(doc) => doc
235                .save
236                .kill_counts
237                .iter()
238                .enumerate()
239                .map(|(index, &count)| KillCountEntry {
240                    index,
241                    name: f1_types::KILL_TYPE_NAMES[index].to_string(),
242                    count,
243                })
244                .collect(),
245            LoadedDocument::Fallout2(doc) => doc
246                .save
247                .kill_counts
248                .iter()
249                .enumerate()
250                .map(|(index, &count)| KillCountEntry {
251                    index,
252                    name: f2_types::KILL_TYPE_NAMES[index].to_string(),
253                    count,
254                })
255                .collect(),
256        }
257    }
258
259    pub fn nonzero_kill_counts(&self) -> Vec<KillCountEntry> {
260        match &self.document {
261            LoadedDocument::Fallout1(doc) => doc
262                .save
263                .kill_counts
264                .iter()
265                .enumerate()
266                .filter_map(|(index, &count)| {
267                    if count <= 0 {
268                        return None;
269                    }
270                    Some(KillCountEntry {
271                        index,
272                        name: f1_types::KILL_TYPE_NAMES[index].to_string(),
273                        count,
274                    })
275                })
276                .collect(),
277            LoadedDocument::Fallout2(doc) => doc
278                .save
279                .kill_counts
280                .iter()
281                .enumerate()
282                .filter_map(|(index, &count)| {
283                    if count <= 0 {
284                        return None;
285                    }
286                    Some(KillCountEntry {
287                        index,
288                        name: f2_types::KILL_TYPE_NAMES[index].to_string(),
289                        count,
290                    })
291                })
292                .collect(),
293        }
294    }
295
296    pub fn map_files(&self) -> Vec<String> {
297        match &self.document {
298            LoadedDocument::Fallout1(doc) => doc.save.map_files.clone(),
299            LoadedDocument::Fallout2(doc) => doc.save.map_files.clone(),
300        }
301    }
302
303    pub fn age(&self) -> i32 {
304        match &self.document {
305            LoadedDocument::Fallout1(doc) => doc.save.critter_data.base_stats[STAT_AGE_INDEX],
306            LoadedDocument::Fallout2(doc) => doc.save.critter_data.base_stats[STAT_AGE_INDEX],
307        }
308    }
309
310    pub fn max_hp(&self) -> i32 {
311        self.stat(7).total
312    }
313
314    pub fn next_level_xp(&self) -> i32 {
315        let l = self.snapshot.level;
316        (l + 1) * l / 2 * 1000
317    }
318
319    pub fn stat(&self, index: usize) -> StatEntry {
320        match &self.document {
321            LoadedDocument::Fallout1(doc) => {
322                let base = doc.save.critter_data.base_stats[index];
323                let bonus = doc.save.critter_data.bonus_stats[index];
324                StatEntry {
325                    index,
326                    name: f1_types::STAT_NAMES[index].to_string(),
327                    base,
328                    bonus,
329                    total: base + bonus,
330                }
331            }
332            LoadedDocument::Fallout2(doc) => {
333                let base = doc.save.critter_data.base_stats[index];
334                let bonus = doc.save.critter_data.bonus_stats[index];
335                StatEntry {
336                    index,
337                    name: f2_types::STAT_NAMES[index].to_string(),
338                    base,
339                    bonus,
340                    total: base + bonus,
341                }
342            }
343        }
344    }
345
346    pub fn all_derived_stats(&self) -> Vec<StatEntry> {
347        match &self.document {
348            LoadedDocument::Fallout1(doc) => collect_stat_entries(
349                &f1_types::STAT_NAMES,
350                &doc.save.critter_data.base_stats,
351                &doc.save.critter_data.bonus_stats,
352                7..f1_types::STAT_NAMES.len(),
353                false,
354            ),
355            LoadedDocument::Fallout2(doc) => collect_stat_entries(
356                &f2_types::STAT_NAMES,
357                &doc.save.critter_data.base_stats,
358                &doc.save.critter_data.bonus_stats,
359                7..f2_types::STAT_NAMES.len(),
360                false,
361            ),
362        }
363    }
364
365    pub fn inventory(&self) -> Vec<InventoryEntry> {
366        let items = match &self.document {
367            LoadedDocument::Fallout1(doc) => &doc.save.player_object.inventory,
368            LoadedDocument::Fallout2(doc) => &doc.save.player_object.inventory,
369        };
370        items
371            .iter()
372            .map(|item| InventoryEntry {
373                quantity: item.quantity,
374                pid: item.object.pid,
375            })
376            .collect()
377    }
378
379    pub fn inventory_resolved(&self, catalog: &ItemCatalog) -> Vec<ResolvedInventoryEntry> {
380        self.inventory()
381            .into_iter()
382            .map(|item| {
383                let meta = catalog.get(item.pid);
384                ResolvedInventoryEntry {
385                    quantity: item.quantity,
386                    pid: item.pid,
387                    name: meta.map(|entry| entry.name.clone()),
388                    base_weight: meta.map(|entry| entry.base_weight),
389                    item_type: meta.map(|entry| entry.item_type),
390                }
391            })
392            .collect()
393    }
394
395    pub fn inventory_total_weight_lbs(&self, catalog: &ItemCatalog) -> Option<i32> {
396        let mut total = 0i64;
397        for item in self.inventory() {
398            if item.pid == INVENTORY_CAPS_PID {
399                continue;
400            }
401            let meta = catalog.get(item.pid)?;
402            total = total.checked_add(i64::from(item.quantity) * i64::from(meta.base_weight))?;
403        }
404        i32::try_from(total).ok()
405    }
406
407    pub fn to_bytes_unmodified(&self) -> Result<Vec<u8>, CoreError> {
408        match &self.document {
409            LoadedDocument::Fallout1(doc) => doc.to_bytes_unmodified(),
410            LoadedDocument::Fallout2(doc) => doc.to_bytes_unmodified(),
411        }
412        .map_err(|e| {
413            CoreError::new(
414                CoreErrorCode::Io,
415                format!("failed to emit unmodified bytes: {e}"),
416            )
417        })
418    }
419
420    pub fn to_bytes_modified(&self) -> Result<Vec<u8>, CoreError> {
421        match &self.document {
422            LoadedDocument::Fallout1(doc) => doc.to_bytes_modified(),
423            LoadedDocument::Fallout2(doc) => doc.to_bytes_modified(),
424        }
425        .map_err(|e| {
426            CoreError::new(
427                CoreErrorCode::Io,
428                format!("failed to emit modified bytes: {e}"),
429            )
430        })
431    }
432
433    pub fn current_hp(&self) -> Option<i32> {
434        match &self.document {
435            LoadedDocument::Fallout1(doc) => extract_hp(&doc.save.player_object),
436            LoadedDocument::Fallout2(doc) => extract_hp(&doc.save.player_object),
437        }
438    }
439
440    pub fn set_hp(&mut self, hp: i32) -> Result<(), CoreError> {
441        match &mut self.document {
442            LoadedDocument::Fallout1(doc) => doc.set_hp(hp),
443            LoadedDocument::Fallout2(doc) => doc.set_hp(hp),
444        }
445        .map_err(|e| {
446            CoreError::new(
447                CoreErrorCode::UnsupportedOperation,
448                format!("failed to set HP: {e}"),
449            )
450        })?;
451
452        self.snapshot.hp = Some(hp);
453        Ok(())
454    }
455
456    pub fn set_base_stat(&mut self, stat_index: usize, value: i32) -> Result<(), CoreError> {
457        if stat_index > 6 {
458            return Err(CoreError::new(
459                CoreErrorCode::UnsupportedOperation,
460                format!("invalid SPECIAL stat index {stat_index}, expected 0-6"),
461            ));
462        }
463
464        match &mut self.document {
465            LoadedDocument::Fallout1(doc) => doc.set_base_stat(stat_index, value),
466            LoadedDocument::Fallout2(doc) => doc.set_base_stat(stat_index, value),
467        }
468        .map_err(|e| {
469            CoreError::new(
470                CoreErrorCode::UnsupportedOperation,
471                format!("failed to set stat {stat_index}: {e}"),
472            )
473        })
474    }
475
476    pub fn set_gender(&mut self, gender: Gender) -> Result<(), CoreError> {
477        match &mut self.document {
478            LoadedDocument::Fallout1(doc) => doc.set_gender(gender),
479            LoadedDocument::Fallout2(doc) => doc.set_gender(gender),
480        }
481        .map_err(|e| {
482            CoreError::new(
483                CoreErrorCode::UnsupportedOperation,
484                format!("failed to set gender: {e}"),
485            )
486        })?;
487
488        self.snapshot.gender = gender;
489        Ok(())
490    }
491
492    pub fn set_age(&mut self, age: i32) -> Result<(), CoreError> {
493        match &mut self.document {
494            LoadedDocument::Fallout1(doc) => doc.set_age(age),
495            LoadedDocument::Fallout2(doc) => doc.set_age(age),
496        }
497        .map_err(|e| {
498            CoreError::new(
499                CoreErrorCode::UnsupportedOperation,
500                format!("failed to set age: {e}"),
501            )
502        })
503    }
504
505    pub fn set_level(&mut self, level: i32) -> Result<(), CoreError> {
506        match &mut self.document {
507            LoadedDocument::Fallout1(doc) => doc.set_level(level),
508            LoadedDocument::Fallout2(doc) => doc.set_level(level),
509        }
510        .map_err(|e| {
511            CoreError::new(
512                CoreErrorCode::UnsupportedOperation,
513                format!("failed to set level: {e}"),
514            )
515        })?;
516
517        self.snapshot.level = level;
518        Ok(())
519    }
520
521    pub fn set_experience(&mut self, experience: i32) -> Result<(), CoreError> {
522        match &mut self.document {
523            LoadedDocument::Fallout1(doc) => doc.set_experience(experience),
524            LoadedDocument::Fallout2(doc) => doc.set_experience(experience),
525        }
526        .map_err(|e| {
527            CoreError::new(
528                CoreErrorCode::UnsupportedOperation,
529                format!("failed to set experience: {e}"),
530            )
531        })?;
532
533        self.snapshot.experience = experience;
534        Ok(())
535    }
536
537    pub fn set_skill_points(&mut self, skill_points: i32) -> Result<(), CoreError> {
538        match &mut self.document {
539            LoadedDocument::Fallout1(doc) => doc.set_skill_points(skill_points),
540            LoadedDocument::Fallout2(doc) => doc.set_skill_points(skill_points),
541        }
542        .map_err(|e| {
543            CoreError::new(
544                CoreErrorCode::UnsupportedOperation,
545                format!("failed to set skill points: {e}"),
546            )
547        })?;
548
549        self.snapshot.unspent_skill_points = skill_points;
550        Ok(())
551    }
552
553    pub fn set_reputation(&mut self, reputation: i32) -> Result<(), CoreError> {
554        match &mut self.document {
555            LoadedDocument::Fallout1(doc) => doc.set_reputation(reputation),
556            LoadedDocument::Fallout2(doc) => doc.set_reputation(reputation),
557        }
558        .map_err(|e| {
559            CoreError::new(
560                CoreErrorCode::UnsupportedOperation,
561                format!("failed to set reputation: {e}"),
562            )
563        })?;
564
565        self.snapshot.reputation = reputation;
566        Ok(())
567    }
568
569    pub fn set_karma(&mut self, karma: i32) -> Result<(), CoreError> {
570        match &mut self.document {
571            LoadedDocument::Fallout1(doc) => doc.set_karma(karma),
572            LoadedDocument::Fallout2(doc) => doc.set_karma(karma),
573        }
574        .map_err(|e| {
575            CoreError::new(
576                CoreErrorCode::UnsupportedOperation,
577                format!("failed to set karma: {e}"),
578            )
579        })?;
580
581        self.snapshot.karma = karma;
582        Ok(())
583    }
584
585    pub fn set_trait(&mut self, slot: usize, trait_index: usize) -> Result<(), CoreError> {
586        let trait_index_i32 = i32::try_from(trait_index).map_err(|_| {
587            CoreError::new(
588                CoreErrorCode::UnsupportedOperation,
589                format!("invalid trait index {trait_index}"),
590            )
591        })?;
592
593        match &mut self.document {
594            LoadedDocument::Fallout1(doc) => doc.set_trait(slot, trait_index_i32),
595            LoadedDocument::Fallout2(doc) => doc.set_trait(slot, trait_index_i32),
596        }
597        .map_err(|e| {
598            CoreError::new(
599                CoreErrorCode::UnsupportedOperation,
600                format!("failed to set trait in slot {slot}: {e}"),
601            )
602        })?;
603
604        self.sync_snapshot_selected_traits();
605        Ok(())
606    }
607
608    pub fn clear_trait(&mut self, slot: usize) -> Result<(), CoreError> {
609        match &mut self.document {
610            LoadedDocument::Fallout1(doc) => doc.clear_trait(slot),
611            LoadedDocument::Fallout2(doc) => doc.clear_trait(slot),
612        }
613        .map_err(|e| {
614            CoreError::new(
615                CoreErrorCode::UnsupportedOperation,
616                format!("failed to clear trait in slot {slot}: {e}"),
617            )
618        })?;
619
620        self.sync_snapshot_selected_traits();
621        Ok(())
622    }
623
624    pub fn set_perk_rank(&mut self, perk_index: usize, rank: i32) -> Result<(), CoreError> {
625        match &mut self.document {
626            LoadedDocument::Fallout1(doc) => doc.set_perk_rank(perk_index, rank),
627            LoadedDocument::Fallout2(doc) => doc.set_perk_rank(perk_index, rank),
628        }
629        .map_err(|e| {
630            CoreError::new(
631                CoreErrorCode::UnsupportedOperation,
632                format!("failed to set perk {perk_index} rank: {e}"),
633            )
634        })
635    }
636
637    pub fn clear_perk(&mut self, perk_index: usize) -> Result<(), CoreError> {
638        match &mut self.document {
639            LoadedDocument::Fallout1(doc) => doc.clear_perk(perk_index),
640            LoadedDocument::Fallout2(doc) => doc.clear_perk(perk_index),
641        }
642        .map_err(|e| {
643            CoreError::new(
644                CoreErrorCode::UnsupportedOperation,
645                format!("failed to clear perk {perk_index}: {e}"),
646            )
647        })
648    }
649
650    pub fn set_inventory_quantity(&mut self, pid: i32, quantity: i32) -> Result<(), CoreError> {
651        match &mut self.document {
652            LoadedDocument::Fallout1(doc) => doc.set_inventory_quantity(pid, quantity),
653            LoadedDocument::Fallout2(doc) => doc.set_inventory_quantity(pid, quantity),
654        }
655        .map_err(|e| {
656            CoreError::new(
657                CoreErrorCode::UnsupportedOperation,
658                format!("failed to set inventory quantity for pid={pid}: {e}"),
659            )
660        })
661    }
662
663    pub fn add_inventory_item(&mut self, pid: i32, quantity: i32) -> Result<(), CoreError> {
664        match &mut self.document {
665            LoadedDocument::Fallout1(doc) => doc.add_inventory_item(pid, quantity),
666            LoadedDocument::Fallout2(doc) => doc.add_inventory_item(pid, quantity),
667        }
668        .map_err(|e| {
669            CoreError::new(
670                CoreErrorCode::UnsupportedOperation,
671                format!("failed to add inventory item pid={pid}: {e}"),
672            )
673        })
674    }
675
676    pub fn remove_inventory_item(
677        &mut self,
678        pid: i32,
679        quantity: Option<i32>,
680    ) -> Result<(), CoreError> {
681        match &mut self.document {
682            LoadedDocument::Fallout1(doc) => doc.remove_inventory_item(pid, quantity),
683            LoadedDocument::Fallout2(doc) => doc.remove_inventory_item(pid, quantity),
684        }
685        .map_err(|e| {
686            CoreError::new(
687                CoreErrorCode::UnsupportedOperation,
688                format!("failed to remove inventory item pid={pid}: {e}"),
689            )
690        })
691    }
692
693    fn sync_snapshot_selected_traits(&mut self) {
694        self.snapshot.selected_traits = match &self.document {
695            LoadedDocument::Fallout1(doc) => doc.save.selected_traits,
696            LoadedDocument::Fallout2(doc) => doc.save.selected_traits,
697        };
698    }
699}
700
701fn parse_fallout1(bytes: &[u8]) -> std::io::Result<fallout1::Document> {
702    fallout1::Document::parse_with_layout(Cursor::new(bytes))
703}
704
705fn parse_fallout2(bytes: &[u8]) -> std::io::Result<fallout2::Document> {
706    fallout2::Document::parse_with_layout(Cursor::new(bytes))
707}
708
709fn session_from_fallout1(doc: fallout1::Document) -> Session {
710    let save = &doc.save;
711    let snapshot = Snapshot {
712        game: Game::Fallout1,
713        character_name: save.header.character_name.clone(),
714        description: save.header.description.clone(),
715        map_filename: save.header.map_filename.clone(),
716        map_id: save.header.map,
717        elevation: save.header.elevation,
718        file_date: DateParts {
719            day: save.header.file_day,
720            month: save.header.file_month,
721            year: save.header.file_year,
722        },
723        game_date: DateParts {
724            day: save.header.game_day,
725            month: save.header.game_month,
726            year: save.header.game_year,
727        },
728        gender: save.gender,
729        level: save.pc_stats.level,
730        experience: save.pc_stats.experience,
731        unspent_skill_points: save.pc_stats.unspent_skill_points,
732        karma: save.pc_stats.karma,
733        reputation: save.pc_stats.reputation,
734        global_var_count: save.global_var_count,
735        selected_traits: save.selected_traits,
736        hp: extract_hp(&save.player_object),
737        game_time: save.header.game_time,
738    };
739
740    Session {
741        game: Game::Fallout1,
742        snapshot,
743        capabilities: Capabilities::editable(Vec::new()),
744        document: LoadedDocument::Fallout1(Box::new(doc)),
745    }
746}
747
748fn session_from_fallout2(doc: fallout2::Document) -> Session {
749    let save = &doc.save;
750    let mut issues = Vec::new();
751    if save.layout_detection_score <= 0 {
752        issues.push(CapabilityIssue::LowConfidenceLayout);
753    }
754
755    let snapshot = Snapshot {
756        game: Game::Fallout2,
757        character_name: save.header.character_name.clone(),
758        description: save.header.description.clone(),
759        map_filename: save.header.map_filename.clone(),
760        map_id: save.header.map,
761        elevation: save.header.elevation,
762        file_date: DateParts {
763            day: save.header.file_day,
764            month: save.header.file_month,
765            year: save.header.file_year,
766        },
767        game_date: DateParts {
768            day: save.header.game_day,
769            month: save.header.game_month,
770            year: save.header.game_year,
771        },
772        gender: save.gender,
773        level: save.pc_stats.level,
774        experience: save.pc_stats.experience,
775        unspent_skill_points: save.pc_stats.unspent_skill_points,
776        karma: save.pc_stats.karma,
777        reputation: save.pc_stats.reputation,
778        global_var_count: save.global_var_count,
779        selected_traits: save.selected_traits,
780        hp: extract_hp(&save.player_object),
781        game_time: save.header.game_time,
782    };
783
784    Session {
785        game: Game::Fallout2,
786        snapshot,
787        capabilities: Capabilities::editable(issues),
788        document: LoadedDocument::Fallout2(Box::new(doc)),
789    }
790}
791
792fn extract_hp(obj: &crate::object::GameObject) -> Option<i32> {
793    match &obj.object_data {
794        crate::object::ObjectData::Critter(data) => Some(data.hp),
795        _ => None,
796    }
797}
798
799fn collect_stat_entries(
800    names: &[&str],
801    base_stats: &[i32],
802    bonus_stats: &[i32],
803    indices: std::ops::Range<usize>,
804    hide_zero_totals: bool,
805) -> Vec<StatEntry> {
806    let mut out = Vec::new();
807    for index in indices {
808        let base = base_stats[index];
809        let bonus = bonus_stats[index];
810        let total = base + bonus;
811
812        if hide_zero_totals && total == 0 && bonus == 0 {
813            continue;
814        }
815
816        out.push(StatEntry {
817            index,
818            name: names[index].to_string(),
819            base,
820            bonus,
821            total,
822        });
823    }
824    out
825}