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(¤t.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 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}