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}