Skip to main content

fallout_core/fallout2/
mod.rs

1pub mod header;
2pub mod object;
3pub mod sections;
4pub mod types;
5
6use std::io::{self, Cursor, Read, Seek};
7
8use crate::gender::Gender;
9use crate::layout::{ByteRange, FileLayout, SectionId, SectionLayout};
10use crate::object::GameObject;
11use crate::reader::BigEndianReader;
12use header::SaveHeader;
13use sections::{
14    CombatState, CritterProtoData, PcStats, parse_critter_proto_nearby, parse_game_global_vars,
15    parse_kill_counts, parse_map_file_list, parse_player_combat_id, parse_player_object,
16    parse_post_tagged_sections, parse_tagged_skills,
17};
18use types::{KILL_TYPE_COUNT, PERK_COUNT, SAVEABLE_STAT_COUNT, SKILL_COUNT, TAGGED_SKILL_COUNT};
19
20const STAT_STRENGTH: usize = 0;
21const STAT_PERCEPTION: usize = 1;
22const STAT_ENDURANCE: usize = 2;
23const STAT_CHARISMA: usize = 3;
24const STAT_INTELLIGENCE: usize = 4;
25const STAT_AGILITY: usize = 5;
26const STAT_LUCK: usize = 6;
27const STAT_INVALID: i32 = -1;
28
29const SKILL_SMALL_GUNS: usize = 0;
30const SKILL_BIG_GUNS: usize = 1;
31const SKILL_ENERGY_WEAPONS: usize = 2;
32const SKILL_UNARMED: usize = 3;
33const SKILL_MELEE_WEAPONS: usize = 4;
34const SKILL_THROWING: usize = 5;
35const SKILL_FIRST_AID: usize = 6;
36const SKILL_DOCTOR: usize = 7;
37const SKILL_SNEAK: usize = 8;
38const SKILL_LOCKPICK: usize = 9;
39const SKILL_STEAL: usize = 10;
40const SKILL_TRAPS: usize = 11;
41const SKILL_SCIENCE: usize = 12;
42const SKILL_REPAIR: usize = 13;
43const SKILL_SPEECH: usize = 14;
44const SKILL_BARTER: usize = 15;
45const SKILL_GAMBLING: usize = 16;
46const SKILL_OUTDOORSMAN: usize = 17;
47
48const TRAIT_GOOD_NATURED: i32 = 10;
49const TRAIT_GIFTED: i32 = 15;
50
51const GAME_DIFFICULTY_EASY: i32 = 0;
52const GAME_DIFFICULTY_HARD: i32 = 2;
53
54const PERK_SURVIVALIST: usize = 16;
55const PERK_MR_FIXIT: usize = 31;
56const PERK_MEDIC: usize = 32;
57const PERK_MASTER_THIEF: usize = 33;
58const PERK_SPEAKER: usize = 34;
59const PERK_GHOST: usize = 38;
60const PERK_RANGER: usize = 47;
61const PERK_TAG: usize = 51;
62const PERK_GAMBLER: usize = 84;
63const PERK_HARMLESS: usize = 92;
64const PERK_LIVING_ANATOMY: usize = 98;
65const PERK_NEGOTIATOR: usize = 100;
66const PERK_SALESMAN: usize = 104;
67const PERK_THIEF: usize = 106;
68const PERK_VAULT_CITY_TRAINING: usize = 108;
69const PERK_EXPERT_EXCREMENT_EXPEDITOR: usize = 117;
70const STAT_AGE_INDEX: usize = 33;
71const STAT_GENDER_INDEX: usize = 34;
72const I32_WIDTH: usize = 4;
73const PC_STATS_UNSPENT_SKILL_POINTS_OFFSET: usize = 0;
74const PC_STATS_LEVEL_OFFSET: usize = I32_WIDTH;
75const PC_STATS_EXPERIENCE_OFFSET: usize = I32_WIDTH * 2;
76const PC_STATS_REPUTATION_OFFSET: usize = I32_WIDTH * 3;
77const PC_STATS_KARMA_OFFSET: usize = I32_WIDTH * 4;
78const PLAYER_HP_OFFSET_IN_HANDLER5: usize = 116;
79
80#[derive(Copy, Clone)]
81struct SkillFormula {
82    default_value: i32,
83    stat_modifier: i32,
84    stat1: usize,
85    stat2: i32,
86    base_value_mult: i32,
87}
88
89const SKILL_FORMULAS: [SkillFormula; SKILL_COUNT] = [
90    SkillFormula {
91        default_value: 5,
92        stat_modifier: 4,
93        stat1: STAT_AGILITY,
94        stat2: STAT_INVALID,
95        base_value_mult: 1,
96    },
97    SkillFormula {
98        default_value: 0,
99        stat_modifier: 2,
100        stat1: STAT_AGILITY,
101        stat2: STAT_INVALID,
102        base_value_mult: 1,
103    },
104    SkillFormula {
105        default_value: 0,
106        stat_modifier: 2,
107        stat1: STAT_AGILITY,
108        stat2: STAT_INVALID,
109        base_value_mult: 1,
110    },
111    SkillFormula {
112        default_value: 30,
113        stat_modifier: 2,
114        stat1: STAT_AGILITY,
115        stat2: STAT_STRENGTH as i32,
116        base_value_mult: 1,
117    },
118    SkillFormula {
119        default_value: 20,
120        stat_modifier: 2,
121        stat1: STAT_AGILITY,
122        stat2: STAT_STRENGTH as i32,
123        base_value_mult: 1,
124    },
125    SkillFormula {
126        default_value: 0,
127        stat_modifier: 4,
128        stat1: STAT_AGILITY,
129        stat2: STAT_INVALID,
130        base_value_mult: 1,
131    },
132    SkillFormula {
133        default_value: 0,
134        stat_modifier: 2,
135        stat1: STAT_PERCEPTION,
136        stat2: STAT_INTELLIGENCE as i32,
137        base_value_mult: 1,
138    },
139    SkillFormula {
140        default_value: 5,
141        stat_modifier: 1,
142        stat1: STAT_PERCEPTION,
143        stat2: STAT_INTELLIGENCE as i32,
144        base_value_mult: 1,
145    },
146    SkillFormula {
147        default_value: 5,
148        stat_modifier: 3,
149        stat1: STAT_AGILITY,
150        stat2: STAT_INVALID,
151        base_value_mult: 1,
152    },
153    SkillFormula {
154        default_value: 10,
155        stat_modifier: 1,
156        stat1: STAT_PERCEPTION,
157        stat2: STAT_AGILITY as i32,
158        base_value_mult: 1,
159    },
160    SkillFormula {
161        default_value: 0,
162        stat_modifier: 3,
163        stat1: STAT_AGILITY,
164        stat2: STAT_INVALID,
165        base_value_mult: 1,
166    },
167    SkillFormula {
168        default_value: 10,
169        stat_modifier: 1,
170        stat1: STAT_PERCEPTION,
171        stat2: STAT_AGILITY as i32,
172        base_value_mult: 1,
173    },
174    SkillFormula {
175        default_value: 0,
176        stat_modifier: 4,
177        stat1: STAT_INTELLIGENCE,
178        stat2: STAT_INVALID,
179        base_value_mult: 1,
180    },
181    SkillFormula {
182        default_value: 0,
183        stat_modifier: 3,
184        stat1: STAT_INTELLIGENCE,
185        stat2: STAT_INVALID,
186        base_value_mult: 1,
187    },
188    SkillFormula {
189        default_value: 0,
190        stat_modifier: 5,
191        stat1: STAT_CHARISMA,
192        stat2: STAT_INVALID,
193        base_value_mult: 1,
194    },
195    SkillFormula {
196        default_value: 0,
197        stat_modifier: 4,
198        stat1: STAT_CHARISMA,
199        stat2: STAT_INVALID,
200        base_value_mult: 1,
201    },
202    SkillFormula {
203        default_value: 0,
204        stat_modifier: 5,
205        stat1: STAT_LUCK,
206        stat2: STAT_INVALID,
207        base_value_mult: 1,
208    },
209    SkillFormula {
210        default_value: 0,
211        stat_modifier: 2,
212        stat1: STAT_ENDURANCE,
213        stat2: STAT_INTELLIGENCE as i32,
214        base_value_mult: 1,
215    },
216];
217
218#[derive(Debug)]
219pub struct SaveGame {
220    pub header: SaveHeader,
221    pub player_combat_id: i32,
222    pub global_var_count: usize,
223    pub map_files: Vec<String>,
224    pub automap_size: i32,
225    pub player_object: GameObject,
226    pub center_tile: i32,
227    pub critter_data: CritterProtoData,
228    pub gender: Gender,
229    pub kill_counts: [i32; KILL_TYPE_COUNT],
230    pub tagged_skills: [i32; TAGGED_SKILL_COUNT],
231    pub perks: [i32; PERK_COUNT],
232    pub combat_state: CombatState,
233    pub pc_stats: PcStats,
234    pub selected_traits: [i32; 2],
235    pub game_difficulty: i32,
236    pub party_member_count: usize,
237    pub ai_packet_count: usize,
238    pub layout_detection_score: i32,
239}
240
241#[derive(Debug)]
242pub struct Document {
243    pub save: SaveGame,
244    layout: FileLayout,
245    section_blobs: Vec<SectionBlob>,
246    original_section_blobs: Vec<SectionBlob>,
247    original_file_len: usize,
248}
249
250#[derive(Debug, Clone)]
251struct SectionBlob {
252    bytes: Vec<u8>,
253}
254
255struct Capture<'a> {
256    source: &'a [u8],
257    sections: Vec<SectionLayout>,
258    blobs: Vec<SectionBlob>,
259}
260
261impl<'a> Capture<'a> {
262    fn new(source: &'a [u8]) -> Self {
263        Self {
264            source,
265            sections: Vec::new(),
266            blobs: Vec::new(),
267        }
268    }
269
270    fn record(&mut self, id: SectionId, start: usize, end: usize) {
271        self.sections.push(SectionLayout {
272            id,
273            range: ByteRange { start, end },
274        });
275        self.blobs.push(SectionBlob {
276            bytes: self.source[start..end].to_vec(),
277        });
278    }
279}
280
281impl SaveGame {
282    pub fn parse<R: Read + Seek>(reader: R) -> io::Result<Self> {
283        let mut r = BigEndianReader::new(reader);
284        parse_internal(&mut r, None)
285    }
286}
287
288impl Document {
289    pub fn parse_with_layout<R: Read + Seek>(mut reader: R) -> io::Result<Self> {
290        let mut bytes = Vec::new();
291        reader.read_to_end(&mut bytes)?;
292
293        let mut capture = Capture::new(&bytes);
294        let mut r = BigEndianReader::new(Cursor::new(bytes.as_slice()));
295        let save = parse_internal(&mut r, Some(&mut capture))?;
296
297        let consumed = r.position()? as usize;
298        let file_len = bytes.len();
299        if consumed < file_len {
300            capture.record(SectionId::Tail, consumed, file_len);
301        }
302
303        let layout = FileLayout {
304            file_len,
305            sections: capture.sections,
306        };
307        layout.validate()?;
308
309        let original_section_blobs = capture.blobs.clone();
310
311        Ok(Self {
312            save,
313            layout,
314            section_blobs: capture.blobs,
315            original_section_blobs,
316            original_file_len: file_len,
317        })
318    }
319
320    pub fn layout(&self) -> &FileLayout {
321        &self.layout
322    }
323
324    pub fn supports_editing(&self) -> bool {
325        true
326    }
327
328    pub fn to_bytes_unmodified(&self) -> io::Result<Vec<u8>> {
329        emit_from_blobs(
330            &self.original_section_blobs,
331            self.original_file_len,
332            "unmodified",
333        )
334    }
335
336    pub fn to_bytes_modified(&self) -> io::Result<Vec<u8>> {
337        self.validate_modified_state()?;
338        emit_from_blobs(&self.section_blobs, self.layout.file_len, "modified")
339    }
340
341    pub fn set_hp(&mut self, hp: i32) -> io::Result<()> {
342        let blob = self.section_blob_mut(SectionId::Handler(5))?;
343        patch_i32_in_blob(blob, PLAYER_HP_OFFSET_IN_HANDLER5, hp, "handler 5", "hp")?;
344        if let object::ObjectData::Critter(ref mut data) = self.save.player_object.object_data {
345            data.hp = hp;
346        }
347        Ok(())
348    }
349
350    pub fn set_base_stat(&mut self, stat_index: usize, value: i32) -> io::Result<()> {
351        self.patch_base_stat_handler(stat_index, value, &format!("stat {stat_index}"))?;
352        self.save.critter_data.base_stats[stat_index] = value;
353        Ok(())
354    }
355
356    pub fn set_age(&mut self, age: i32) -> io::Result<()> {
357        self.patch_base_stat_handler(STAT_AGE_INDEX, age, "age")?;
358        self.save.critter_data.base_stats[STAT_AGE_INDEX] = age;
359        Ok(())
360    }
361
362    pub fn set_gender(&mut self, gender: Gender) -> io::Result<()> {
363        let raw = gender.raw();
364        self.patch_base_stat_handler(STAT_GENDER_INDEX, raw, "gender")?;
365        self.save.critter_data.base_stats[STAT_GENDER_INDEX] = raw;
366        self.save.gender = Gender::from_raw(raw);
367        Ok(())
368    }
369
370    pub fn set_level(&mut self, level: i32) -> io::Result<()> {
371        self.patch_handler13_i32(PC_STATS_LEVEL_OFFSET, level, "level")?;
372        self.save.pc_stats.level = level;
373        Ok(())
374    }
375
376    pub fn set_experience(&mut self, experience: i32) -> io::Result<()> {
377        self.patch_handler6_experience(experience)?;
378        self.patch_handler13_i32(PC_STATS_EXPERIENCE_OFFSET, experience, "experience")?;
379        self.save.critter_data.experience = experience;
380        self.save.pc_stats.experience = experience;
381        Ok(())
382    }
383
384    pub fn set_skill_points(&mut self, skill_points: i32) -> io::Result<()> {
385        self.patch_handler13_i32(
386            PC_STATS_UNSPENT_SKILL_POINTS_OFFSET,
387            skill_points,
388            "skill points",
389        )?;
390        self.save.pc_stats.unspent_skill_points = skill_points;
391        Ok(())
392    }
393
394    pub fn set_reputation(&mut self, reputation: i32) -> io::Result<()> {
395        self.patch_handler13_i32(PC_STATS_REPUTATION_OFFSET, reputation, "reputation")?;
396        self.save.pc_stats.reputation = reputation;
397        Ok(())
398    }
399
400    pub fn set_karma(&mut self, karma: i32) -> io::Result<()> {
401        self.patch_handler13_i32(PC_STATS_KARMA_OFFSET, karma, "karma")?;
402        self.save.pc_stats.karma = karma;
403        Ok(())
404    }
405
406    pub fn set_trait(&mut self, slot: usize, trait_index: i32) -> io::Result<()> {
407        if slot >= self.save.selected_traits.len() {
408            return Err(io::Error::new(
409                io::ErrorKind::InvalidInput,
410                format!(
411                    "invalid trait slot {slot}, expected 0..{}",
412                    self.save.selected_traits.len() - 1
413                ),
414            ));
415        }
416        if trait_index < 0 || trait_index as usize >= types::TRAIT_NAMES.len() {
417            return Err(io::Error::new(
418                io::ErrorKind::InvalidInput,
419                format!(
420                    "invalid trait index {trait_index}, expected 0..{}",
421                    types::TRAIT_NAMES.len() - 1
422                ),
423            ));
424        }
425
426        self.patch_trait_slot(slot, trait_index)?;
427        self.save.selected_traits[slot] = trait_index;
428        Ok(())
429    }
430
431    pub fn clear_trait(&mut self, slot: usize) -> io::Result<()> {
432        if slot >= self.save.selected_traits.len() {
433            return Err(io::Error::new(
434                io::ErrorKind::InvalidInput,
435                format!(
436                    "invalid trait slot {slot}, expected 0..{}",
437                    self.save.selected_traits.len() - 1
438                ),
439            ));
440        }
441
442        self.patch_trait_slot(slot, -1)?;
443        self.save.selected_traits[slot] = -1;
444        Ok(())
445    }
446
447    pub fn set_perk_rank(&mut self, perk_index: usize, rank: i32) -> io::Result<()> {
448        if perk_index >= self.save.perks.len() {
449            return Err(io::Error::new(
450                io::ErrorKind::InvalidInput,
451                format!(
452                    "invalid perk index {perk_index}, expected 0..{}",
453                    self.save.perks.len() - 1
454                ),
455            ));
456        }
457        if !(-1..=20).contains(&rank) {
458            return Err(io::Error::new(
459                io::ErrorKind::InvalidInput,
460                format!("invalid perk rank {rank}, expected -1..20"),
461            ));
462        }
463
464        let offset = perk_index * I32_WIDTH;
465        let blob = self.section_blob_mut(SectionId::Handler(10))?;
466        patch_i32_in_blob(blob, offset, rank, "handler 10", "perk rank")?;
467        self.save.perks[perk_index] = rank;
468        Ok(())
469    }
470
471    pub fn clear_perk(&mut self, perk_index: usize) -> io::Result<()> {
472        self.set_perk_rank(perk_index, 0)
473    }
474
475    pub fn set_inventory_quantity(&mut self, pid: i32, quantity: i32) -> io::Result<()> {
476        if quantity < 0 {
477            return Err(io::Error::new(
478                io::ErrorKind::InvalidInput,
479                format!("invalid inventory quantity {quantity}, expected >= 0"),
480            ));
481        }
482
483        let mut found = false;
484        let mut assigned = false;
485        self.save.player_object.inventory.retain_mut(|item| {
486            if item.object.pid != pid {
487                return true;
488            }
489            found = true;
490            if quantity == 0 {
491                return false;
492            }
493            if assigned {
494                return false;
495            }
496
497            item.quantity = quantity;
498            assigned = true;
499            true
500        });
501
502        if !found {
503            return Err(io::Error::new(
504                io::ErrorKind::InvalidInput,
505                format!("inventory item pid={pid} not found"),
506            ));
507        }
508
509        self.rewrite_handler5_from_player_object()
510    }
511
512    pub fn add_inventory_item(&mut self, pid: i32, quantity: i32) -> io::Result<()> {
513        if quantity <= 0 {
514            return Err(io::Error::new(
515                io::ErrorKind::InvalidInput,
516                format!("invalid inventory quantity {quantity}, expected > 0"),
517            ));
518        }
519
520        let mut found = false;
521        for item in &mut self.save.player_object.inventory {
522            if item.object.pid == pid {
523                item.quantity = item.quantity.checked_add(quantity).ok_or_else(|| {
524                    io::Error::new(
525                        io::ErrorKind::InvalidInput,
526                        format!(
527                            "inventory quantity overflow for pid={pid}: {} + {quantity}",
528                            item.quantity
529                        ),
530                    )
531                })?;
532                found = true;
533                break;
534            }
535        }
536
537        if !found {
538            return Err(io::Error::new(
539                io::ErrorKind::InvalidInput,
540                format!("cannot add new inventory pid={pid}: no existing template item in save"),
541            ));
542        }
543
544        self.rewrite_handler5_from_player_object()
545    }
546
547    pub fn remove_inventory_item(&mut self, pid: i32, quantity: Option<i32>) -> io::Result<()> {
548        let total_before: i64 = self
549            .save
550            .player_object
551            .inventory
552            .iter()
553            .filter(|item| item.object.pid == pid)
554            .map(|item| item.quantity as i64)
555            .sum();
556
557        if total_before == 0 {
558            return Err(io::Error::new(
559                io::ErrorKind::InvalidInput,
560                format!("inventory item pid={pid} not found"),
561            ));
562        }
563
564        let target_total = match quantity {
565            None => 0i64,
566            Some(qty) => {
567                if qty <= 0 {
568                    return Err(io::Error::new(
569                        io::ErrorKind::InvalidInput,
570                        format!("invalid inventory removal quantity {qty}, expected > 0"),
571                    ));
572                }
573                (total_before - qty as i64).max(0)
574            }
575        };
576
577        let mut reassigned = false;
578        self.save.player_object.inventory.retain_mut(|item| {
579            if item.object.pid != pid {
580                return true;
581            }
582            if reassigned {
583                return false;
584            }
585            if target_total <= 0 {
586                return false;
587            }
588
589            item.quantity = target_total as i32;
590            reassigned = true;
591            true
592        });
593
594        self.rewrite_handler5_from_player_object()
595    }
596
597    fn patch_base_stat_handler(
598        &mut self,
599        stat_index: usize,
600        raw: i32,
601        field: &str,
602    ) -> io::Result<()> {
603        let critter_data = self.save.critter_data.clone();
604        let blob = self.section_blob_mut(SectionId::Handler(6))?;
605        let offset = find_base_stat_offset_in_handler6(&blob.bytes, &critter_data, stat_index)?;
606        patch_i32_in_blob(blob, offset, raw, "handler 6", field)
607    }
608
609    fn patch_handler6_experience(&mut self, experience: i32) -> io::Result<()> {
610        let critter_data = self.save.critter_data.clone();
611        let blob = self.section_blob_mut(SectionId::Handler(6))?;
612        let offset = find_experience_offset_in_handler6(&blob.bytes, &critter_data)?;
613        patch_i32_in_blob(blob, offset, experience, "handler 6", "experience")
614    }
615
616    fn patch_handler13_i32(&mut self, offset: usize, raw: i32, field: &str) -> io::Result<()> {
617        let blob = self.section_blob_mut(SectionId::Handler(13))?;
618        patch_i32_in_blob(blob, offset, raw, "handler 13", field)
619    }
620
621    fn section_blob_mut(&mut self, id: SectionId) -> io::Result<&mut SectionBlob> {
622        let section_index = self.section_index(id)?;
623
624        self.section_blobs.get_mut(section_index).ok_or_else(|| {
625            io::Error::new(
626                io::ErrorKind::InvalidData,
627                "section blob list does not match recorded layout",
628            )
629        })
630    }
631
632    fn section_index(&self, id: SectionId) -> io::Result<usize> {
633        self.layout
634            .sections
635            .iter()
636            .position(|section| section.id == id)
637            .ok_or_else(|| {
638                io::Error::new(
639                    io::ErrorKind::InvalidData,
640                    format!("missing section {id:?}"),
641                )
642            })
643    }
644
645    fn patch_trait_slot(&mut self, slot: usize, value: i32) -> io::Result<()> {
646        let offset = slot * I32_WIDTH;
647        let blob = self.section_blob_mut(SectionId::Handler(15))?;
648        patch_i32_in_blob(blob, offset, value, "handler 15", "trait")
649    }
650
651    fn rewrite_handler5_from_player_object(&mut self) -> io::Result<()> {
652        let mut blob = Vec::new();
653        self.save.player_object.emit_to_vec(&mut blob)?;
654        blob.extend_from_slice(&self.save.center_tile.to_be_bytes());
655        self.replace_section_blob(SectionId::Handler(5), blob)
656    }
657
658    fn replace_section_blob(&mut self, id: SectionId, bytes: Vec<u8>) -> io::Result<()> {
659        let section_index = self.section_index(id)?;
660        let section = self.layout.sections.get_mut(section_index).ok_or_else(|| {
661            io::Error::new(
662                io::ErrorKind::InvalidData,
663                "section blob list does not match recorded layout",
664            )
665        })?;
666        let old_len = section.range.len();
667        let new_len = bytes.len();
668        section.range.end = section.range.start + new_len;
669
670        if new_len != old_len {
671            if new_len > old_len {
672                let delta = new_len - old_len;
673                for later in self.layout.sections.iter_mut().skip(section_index + 1) {
674                    later.range.start = later.range.start.checked_add(delta).ok_or_else(|| {
675                        io::Error::new(io::ErrorKind::InvalidData, "section start overflow")
676                    })?;
677                    later.range.end = later.range.end.checked_add(delta).ok_or_else(|| {
678                        io::Error::new(io::ErrorKind::InvalidData, "section end overflow")
679                    })?;
680                }
681                self.layout.file_len =
682                    self.layout.file_len.checked_add(delta).ok_or_else(|| {
683                        io::Error::new(io::ErrorKind::InvalidData, "layout file_len overflow")
684                    })?;
685            } else {
686                let delta = old_len - new_len;
687                for later in self.layout.sections.iter_mut().skip(section_index + 1) {
688                    later.range.start = later.range.start.checked_sub(delta).ok_or_else(|| {
689                        io::Error::new(io::ErrorKind::InvalidData, "section start underflow")
690                    })?;
691                    later.range.end = later.range.end.checked_sub(delta).ok_or_else(|| {
692                        io::Error::new(io::ErrorKind::InvalidData, "section end underflow")
693                    })?;
694                }
695                self.layout.file_len =
696                    self.layout.file_len.checked_sub(delta).ok_or_else(|| {
697                        io::Error::new(io::ErrorKind::InvalidData, "layout file_len underflow")
698                    })?;
699            }
700        }
701
702        let slot = self.section_blobs.get_mut(section_index).ok_or_else(|| {
703            io::Error::new(
704                io::ErrorKind::InvalidData,
705                "section blob list does not match recorded layout",
706            )
707        })?;
708        slot.bytes = bytes;
709
710        Ok(())
711    }
712
713    fn validate_modified_state(&self) -> io::Result<()> {
714        if self.layout.sections.len() != self.section_blobs.len() {
715            return Err(io::Error::new(
716                io::ErrorKind::InvalidData,
717                format!(
718                    "layout/blob section count mismatch: {} layout sections, {} blobs",
719                    self.layout.sections.len(),
720                    self.section_blobs.len()
721                ),
722            ));
723        }
724
725        for (idx, (section, blob)) in self
726            .layout
727            .sections
728            .iter()
729            .zip(self.section_blobs.iter())
730            .enumerate()
731        {
732            let expected = section.range.len();
733            let actual = blob.bytes.len();
734            if expected != actual {
735                return Err(io::Error::new(
736                    io::ErrorKind::InvalidData,
737                    format!(
738                        "section/blob length mismatch at index {idx} ({:?}): layout={}, blob={}",
739                        section.id, expected, actual
740                    ),
741                ));
742            }
743        }
744
745        self.layout.validate()
746    }
747}
748
749fn emit_from_blobs(
750    blobs: &[SectionBlob],
751    expected_len: usize,
752    mode_label: &str,
753) -> io::Result<Vec<u8>> {
754    let mut out = Vec::with_capacity(expected_len);
755    for blob in blobs {
756        out.extend_from_slice(&blob.bytes);
757    }
758
759    if out.len() != expected_len {
760        return Err(io::Error::new(
761            io::ErrorKind::InvalidData,
762            format!(
763                "{mode_label} emit length mismatch: got {}, expected {}",
764                out.len(),
765                expected_len
766            ),
767        ));
768    }
769
770    Ok(out)
771}
772
773fn patch_i32_in_blob(
774    blob: &mut SectionBlob,
775    offset: usize,
776    raw: i32,
777    section_label: &str,
778    field_label: &str,
779) -> io::Result<()> {
780    if blob.bytes.len() < offset + I32_WIDTH {
781        return Err(io::Error::new(
782            io::ErrorKind::InvalidData,
783            format!(
784                "{section_label} too short for {field_label} patch: len={}, need at least {}",
785                blob.bytes.len(),
786                offset + I32_WIDTH
787            ),
788        ));
789    }
790
791    blob.bytes[offset..offset + I32_WIDTH].copy_from_slice(&raw.to_be_bytes());
792    Ok(())
793}
794
795fn find_base_stat_offset_in_handler6(
796    handler6_bytes: &[u8],
797    critter_data: &CritterProtoData,
798    stat_index: usize,
799) -> io::Result<usize> {
800    if stat_index >= SAVEABLE_STAT_COUNT {
801        return Err(io::Error::new(
802            io::ErrorKind::InvalidInput,
803            format!("unsupported base stat index {stat_index}"),
804        ));
805    }
806
807    // For index 0, use sneak_working + flags as prefix to locate the start of base_stats.
808    // For index > 0, use the preceding base stat values as prefix.
809    let mut prefix = Vec::new();
810    if stat_index == 0 {
811        prefix.extend_from_slice(&critter_data.sneak_working.to_be_bytes());
812        prefix.extend_from_slice(&critter_data.flags.to_be_bytes());
813    } else {
814        for value in critter_data.base_stats.iter().take(stat_index) {
815            prefix.extend_from_slice(&value.to_be_bytes());
816        }
817    }
818
819    let mut matches = handler6_bytes
820        .windows(prefix.len())
821        .enumerate()
822        .filter_map(|(idx, window)| (window == prefix.as_slice()).then_some(idx));
823
824    let first = matches.next().ok_or_else(|| {
825        io::Error::new(
826            io::ErrorKind::InvalidData,
827            "could not locate base stat prefix in handler 6 blob",
828        )
829    })?;
830
831    if matches.next().is_some() {
832        return Err(io::Error::new(
833            io::ErrorKind::InvalidData,
834            format!("ambiguous base stat prefix match in handler 6 blob for index {stat_index}"),
835        ));
836    }
837
838    Ok(first + prefix.len())
839}
840
841fn find_experience_offset_in_handler6(
842    handler6_bytes: &[u8],
843    critter_data: &CritterProtoData,
844) -> io::Result<usize> {
845    let mut prefix = Vec::new();
846    prefix.extend_from_slice(&critter_data.sneak_working.to_be_bytes());
847    prefix.extend_from_slice(&critter_data.flags.to_be_bytes());
848    for value in &critter_data.base_stats {
849        prefix.extend_from_slice(&value.to_be_bytes());
850    }
851    for value in &critter_data.bonus_stats {
852        prefix.extend_from_slice(&value.to_be_bytes());
853    }
854    for value in &critter_data.skills {
855        prefix.extend_from_slice(&value.to_be_bytes());
856    }
857    prefix.extend_from_slice(&critter_data.body_type.to_be_bytes());
858
859    let mut matches = handler6_bytes
860        .windows(prefix.len())
861        .enumerate()
862        .filter_map(|(idx, window)| (window == prefix.as_slice()).then_some(idx));
863
864    let first = matches.next().ok_or_else(|| {
865        io::Error::new(
866            io::ErrorKind::InvalidData,
867            "could not locate critter proto prefix in handler 6 blob",
868        )
869    })?;
870
871    if matches.next().is_some() {
872        return Err(io::Error::new(
873            io::ErrorKind::InvalidData,
874            "ambiguous critter proto prefix match in handler 6 blob",
875        ));
876    }
877
878    Ok(first + prefix.len())
879}
880
881fn parse_internal<R: Read + Seek>(
882    r: &mut BigEndianReader<R>,
883    mut capture: Option<&mut Capture<'_>>,
884) -> io::Result<SaveGame> {
885    // Header (30,051 bytes)
886    let header_start = r.position()? as usize;
887    let header = SaveHeader::parse(r)?;
888    let header_end = r.position()? as usize;
889    if let Some(c) = capture.as_deref_mut() {
890        c.record(SectionId::Header, header_start, header_end);
891    }
892
893    // Handler 1: Player combat ID (4 bytes)
894    let h1_start = r.position()? as usize;
895    let player_combat_id = parse_player_combat_id(r)?;
896    let h1_end = r.position()? as usize;
897    if let Some(c) = capture.as_deref_mut() {
898        c.record(SectionId::Handler(1), h1_start, h1_end);
899    }
900
901    // Handler 2: Game global variables (variable)
902    let h2_start = r.position()? as usize;
903    let globals = parse_game_global_vars(r)?;
904    let h2_end = r.position()? as usize;
905    let global_var_count = globals.global_vars.len();
906    if let Some(c) = capture.as_deref_mut() {
907        c.record(SectionId::Handler(2), h2_start, h2_end);
908    }
909
910    // Handler 3: Map file list + automap size (variable + 4 bytes)
911    let h3_start = r.position()? as usize;
912    let map_info = parse_map_file_list(r)?;
913    let h3_end = r.position()? as usize;
914    if let Some(c) = capture.as_deref_mut() {
915        c.record(SectionId::Handler(3), h3_start, h3_end);
916    }
917
918    // Handler 4: Game global variables duplicate
919    let h4_start = r.position()? as usize;
920    let skip_size = (global_var_count * 4) as u64;
921    r.skip(skip_size)?;
922    let h4_end = r.position()? as usize;
923    if let Some(c) = capture.as_deref_mut() {
924        c.record(SectionId::Handler(4), h4_start, h4_end);
925    }
926
927    // Handler 5: Player object (variable length, recursive)
928    let h5_start = r.position()? as usize;
929    let player_section = parse_player_object(r)?;
930    let h5_end = r.position()? as usize;
931    if let Some(c) = capture.as_deref_mut() {
932        c.record(SectionId::Handler(5), h5_start, h5_end);
933    }
934
935    // Handler 6: Critter proto data (372 bytes)
936    let h6_start = r.position()? as usize;
937    let critter_data = parse_critter_proto_nearby(r)?;
938    let h6_end = r.position()? as usize;
939    let gender = Gender::from_raw(critter_data.base_stats[STAT_GENDER_INDEX]);
940    if let Some(c) = capture.as_deref_mut() {
941        c.record(SectionId::Handler(6), h6_start, h6_end);
942    }
943
944    // Handler 7: Kill counts (76 bytes)
945    let h7_start = r.position()? as usize;
946    let kill_counts = parse_kill_counts(r)?;
947    let h7_end = r.position()? as usize;
948    if let Some(c) = capture.as_deref_mut() {
949        c.record(SectionId::Handler(7), h7_start, h7_end);
950    }
951
952    // Handler 8: Tagged skills (16 bytes)
953    let h8_start = r.position()? as usize;
954    let tagged_skills = parse_tagged_skills(r)?;
955    let h8_end = r.position()? as usize;
956    if let Some(c) = capture.as_deref_mut() {
957        c.record(SectionId::Handler(8), h8_start, h8_end);
958    }
959
960    // Handler 9: roll check/no-op.
961    let h9_pos = r.position()? as usize;
962    if let Some(c) = capture.as_deref_mut() {
963        c.record(SectionId::Handler(9), h9_pos, h9_pos);
964    }
965
966    // Handlers 10-13 contain variable-size sections in Fallout 2
967    // (party perks and AI packets). Detect and parse them together.
968    let post_start = h9_pos;
969    let post_tagged = parse_post_tagged_sections(r)?;
970
971    if let Some(c) = capture {
972        let h10_end = post_tagged.h10_end as usize;
973        let h11_end = post_tagged.h11_end as usize;
974        let h12_end = post_tagged.h12_end as usize;
975        let h13_end = post_tagged.h13_end as usize;
976        let h15_end = post_tagged.h15_end as usize;
977        let h16_end = post_tagged.h16_end as usize;
978        let h17_prefix_end = post_tagged.h17_prefix_end as usize;
979
980        c.record(SectionId::Handler(10), post_start, h10_end);
981        c.record(SectionId::Handler(11), h10_end, h11_end);
982        c.record(SectionId::Handler(12), h11_end, h12_end);
983        c.record(SectionId::Handler(13), h12_end, h13_end);
984        c.record(SectionId::Handler(14), h13_end, h13_end);
985        c.record(SectionId::Handler(15), h13_end, h15_end);
986        c.record(SectionId::Handler(16), h15_end, h16_end);
987        c.record(SectionId::Handler(17), h16_end, h17_prefix_end);
988    }
989
990    Ok(SaveGame {
991        header,
992        player_combat_id,
993        global_var_count,
994        map_files: map_info.map_files,
995        automap_size: map_info.automap_size,
996        player_object: player_section.player_object,
997        center_tile: player_section.center_tile,
998        critter_data,
999        gender,
1000        kill_counts,
1001        tagged_skills,
1002        perks: post_tagged.perks,
1003        combat_state: post_tagged.combat_state,
1004        pc_stats: post_tagged.pc_stats,
1005        selected_traits: post_tagged.selected_traits,
1006        game_difficulty: post_tagged.game_difficulty,
1007        party_member_count: post_tagged.party_member_count,
1008        ai_packet_count: post_tagged.ai_packet_count,
1009        layout_detection_score: post_tagged.detection_score,
1010    })
1011}
1012
1013impl SaveGame {
1014    pub fn effective_skill_value(&self, skill_index: usize) -> i32 {
1015        if skill_index >= SKILL_COUNT {
1016            return 0;
1017        }
1018
1019        let formula = SKILL_FORMULAS[skill_index];
1020        let mut stat_sum = self.total_stat(formula.stat1);
1021        if formula.stat2 != STAT_INVALID {
1022            stat_sum += self.total_stat(formula.stat2 as usize);
1023        }
1024
1025        let base_value = self.critter_data.skills[skill_index];
1026        let mut value = formula.default_value
1027            + formula.stat_modifier * stat_sum
1028            + base_value * formula.base_value_mult;
1029
1030        if self.is_skill_tagged(skill_index) {
1031            value += base_value * formula.base_value_mult;
1032
1033            let has_tag_perk = self.has_perk_rank(PERK_TAG);
1034            if !has_tag_perk || skill_index as i32 != self.tagged_skills[3] {
1035                value += 20;
1036            }
1037        }
1038
1039        value += self.trait_skill_modifier(skill_index);
1040        value += self.perk_skill_modifier(skill_index);
1041        value += self.game_difficulty_skill_modifier(skill_index);
1042
1043        if value > 300 {
1044            value = 300;
1045        }
1046
1047        value
1048    }
1049
1050    /// Return only the contribution from tagging logic for one skill.
1051    pub fn skill_tag_bonus(&self, skill_index: usize) -> i32 {
1052        if skill_index >= SKILL_COUNT || !self.is_skill_tagged(skill_index) {
1053            return 0;
1054        }
1055
1056        let formula = SKILL_FORMULAS[skill_index];
1057        let base_value = self.critter_data.skills[skill_index];
1058        let mut bonus = base_value * formula.base_value_mult;
1059
1060        let has_tag_perk = self.has_perk_rank(PERK_TAG);
1061        if !has_tag_perk || skill_index as i32 != self.tagged_skills[3] {
1062            bonus += 20;
1063        }
1064
1065        bonus
1066    }
1067
1068    fn total_stat(&self, stat_index: usize) -> i32 {
1069        self.critter_data.base_stats[stat_index] + self.critter_data.bonus_stats[stat_index]
1070    }
1071
1072    fn is_skill_tagged(&self, skill_index: usize) -> bool {
1073        self.tagged_skills
1074            .iter()
1075            .any(|&s| s >= 0 && s as usize == skill_index)
1076    }
1077
1078    fn has_perk_rank(&self, perk_index: usize) -> bool {
1079        self.perks.get(perk_index).copied().unwrap_or(0) > 0
1080    }
1081
1082    fn has_trait(&self, trait_index: i32) -> bool {
1083        self.selected_traits.contains(&trait_index)
1084    }
1085
1086    fn trait_skill_modifier(&self, skill_index: usize) -> i32 {
1087        let mut modifier = 0;
1088
1089        if self.has_trait(TRAIT_GIFTED) {
1090            modifier -= 10;
1091        }
1092
1093        if self.has_trait(TRAIT_GOOD_NATURED) {
1094            match skill_index {
1095                SKILL_SMALL_GUNS | SKILL_BIG_GUNS | SKILL_ENERGY_WEAPONS | SKILL_UNARMED
1096                | SKILL_MELEE_WEAPONS | SKILL_THROWING => modifier -= 10,
1097                SKILL_FIRST_AID | SKILL_DOCTOR | SKILL_SPEECH | SKILL_BARTER => modifier += 15,
1098                _ => {}
1099            }
1100        }
1101
1102        modifier
1103    }
1104
1105    fn perk_skill_modifier(&self, skill_index: usize) -> i32 {
1106        let mut modifier = 0;
1107
1108        match skill_index {
1109            SKILL_FIRST_AID => {
1110                if self.has_perk_rank(PERK_MEDIC) {
1111                    modifier += 10;
1112                }
1113                if self.has_perk_rank(PERK_VAULT_CITY_TRAINING) {
1114                    modifier += 5;
1115                }
1116            }
1117            SKILL_DOCTOR => {
1118                if self.has_perk_rank(PERK_MEDIC) {
1119                    modifier += 10;
1120                }
1121                if self.has_perk_rank(PERK_LIVING_ANATOMY) {
1122                    modifier += 10;
1123                }
1124                if self.has_perk_rank(PERK_VAULT_CITY_TRAINING) {
1125                    modifier += 5;
1126                }
1127            }
1128            SKILL_SNEAK | SKILL_LOCKPICK | SKILL_STEAL | SKILL_TRAPS => {
1129                // Ghost depends on dynamic light level, which is not available in SAVE.DAT.
1130                if self.has_perk_rank(PERK_THIEF) {
1131                    modifier += 10;
1132                }
1133                if matches!(skill_index, SKILL_LOCKPICK | SKILL_STEAL)
1134                    && self.has_perk_rank(PERK_MASTER_THIEF)
1135                {
1136                    modifier += 15;
1137                }
1138                if skill_index == SKILL_STEAL && self.has_perk_rank(PERK_HARMLESS) {
1139                    modifier += 20;
1140                }
1141                let _ = self.has_perk_rank(PERK_GHOST);
1142            }
1143            SKILL_SCIENCE | SKILL_REPAIR => {
1144                if self.has_perk_rank(PERK_MR_FIXIT) {
1145                    modifier += 10;
1146                }
1147            }
1148            SKILL_SPEECH | SKILL_BARTER => {
1149                if skill_index == SKILL_SPEECH {
1150                    if self.has_perk_rank(PERK_SPEAKER) {
1151                        modifier += 20;
1152                    }
1153                    if self.has_perk_rank(PERK_EXPERT_EXCREMENT_EXPEDITOR) {
1154                        modifier += 5;
1155                    }
1156                }
1157                if self.has_perk_rank(PERK_NEGOTIATOR) {
1158                    modifier += 10;
1159                }
1160                if skill_index == SKILL_BARTER && self.has_perk_rank(PERK_SALESMAN) {
1161                    modifier += 20;
1162                }
1163            }
1164            SKILL_GAMBLING => {
1165                if self.has_perk_rank(PERK_GAMBLER) {
1166                    modifier += 20;
1167                }
1168            }
1169            SKILL_OUTDOORSMAN => {
1170                if self.has_perk_rank(PERK_RANGER) {
1171                    modifier += 15;
1172                }
1173                if self.has_perk_rank(PERK_SURVIVALIST) {
1174                    modifier += 25;
1175                }
1176            }
1177            _ => {}
1178        }
1179
1180        modifier
1181    }
1182
1183    fn game_difficulty_skill_modifier(&self, skill_index: usize) -> i32 {
1184        let is_difficulty_affected = matches!(
1185            skill_index,
1186            SKILL_FIRST_AID
1187                | SKILL_DOCTOR
1188                | SKILL_SNEAK
1189                | SKILL_LOCKPICK
1190                | SKILL_STEAL
1191                | SKILL_TRAPS
1192                | SKILL_SCIENCE
1193                | SKILL_REPAIR
1194                | SKILL_SPEECH
1195                | SKILL_BARTER
1196                | SKILL_GAMBLING
1197                | SKILL_OUTDOORSMAN
1198        );
1199
1200        if !is_difficulty_affected {
1201            return 0;
1202        }
1203
1204        if self.game_difficulty == GAME_DIFFICULTY_HARD {
1205            -10
1206        } else if self.game_difficulty == GAME_DIFFICULTY_EASY {
1207            20
1208        } else {
1209            0
1210        }
1211    }
1212}