Skip to main content

fallout_core/fallout1/
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_combat_state, parse_critter_proto,
15    parse_game_global_vars, parse_kill_counts, parse_map_file_list, parse_pc_stats, parse_perks,
16    parse_player_combat_id, parse_player_object, parse_tagged_skills, parse_traits,
17    skip_event_queue,
18};
19use types::{KILL_TYPE_COUNT, PERK_COUNT, SAVEABLE_STAT_COUNT, SKILL_COUNT, TAGGED_SKILL_COUNT};
20
21const STAT_STRENGTH: usize = 0;
22const STAT_PERCEPTION: usize = 1;
23const STAT_ENDURANCE: usize = 2;
24const STAT_CHARISMA: usize = 3;
25const STAT_INTELLIGENCE: usize = 4;
26const STAT_AGILITY: usize = 5;
27const STAT_LUCK: usize = 6;
28const STAT_INVALID: i32 = -1;
29
30const STAT_AGE_INDEX: usize = 33;
31const STAT_GENDER_INDEX: usize = 34;
32const CRITTER_PROTO_BASE_STATS_OFFSET: usize = 8;
33const I32_WIDTH: usize = 4;
34const CRITTER_PROTO_AGE_OFFSET: usize =
35    CRITTER_PROTO_BASE_STATS_OFFSET + STAT_AGE_INDEX * I32_WIDTH;
36const GENDER_OFFSET_IN_HANDLER6: usize =
37    CRITTER_PROTO_BASE_STATS_OFFSET + STAT_GENDER_INDEX * I32_WIDTH;
38const CRITTER_PROTO_EXPERIENCE_OFFSET: usize = CRITTER_PROTO_BASE_STATS_OFFSET
39    + SAVEABLE_STAT_COUNT * I32_WIDTH
40    + SAVEABLE_STAT_COUNT * I32_WIDTH
41    + SKILL_COUNT * I32_WIDTH
42    + I32_WIDTH;
43const PC_STATS_UNSPENT_SKILL_POINTS_OFFSET: usize = 0;
44const PC_STATS_LEVEL_OFFSET: usize = I32_WIDTH;
45const PC_STATS_EXPERIENCE_OFFSET: usize = I32_WIDTH * 2;
46const PC_STATS_REPUTATION_OFFSET: usize = I32_WIDTH * 3;
47const PC_STATS_KARMA_OFFSET: usize = I32_WIDTH * 4;
48const PLAYER_HP_OFFSET_IN_HANDLER5: usize = 116;
49
50// Skill indices
51const SKILL_SMALL_GUNS: usize = 0;
52const SKILL_BIG_GUNS: usize = 1;
53const SKILL_ENERGY_WEAPONS: usize = 2;
54const SKILL_UNARMED: usize = 3;
55const SKILL_MELEE_WEAPONS: usize = 4;
56const SKILL_THROWING: usize = 5;
57const SKILL_FIRST_AID: usize = 6;
58const SKILL_DOCTOR: usize = 7;
59const SKILL_SNEAK: usize = 8;
60const SKILL_LOCKPICK: usize = 9;
61const SKILL_STEAL: usize = 10;
62const SKILL_TRAPS: usize = 11;
63const SKILL_SCIENCE: usize = 12;
64const SKILL_REPAIR: usize = 13;
65const SKILL_SPEECH: usize = 14;
66const SKILL_BARTER: usize = 15;
67
68// Trait indices
69const TRAIT_GOOD_NATURED: i32 = 10;
70const TRAIT_SKILLED: i32 = 14;
71const TRAIT_GIFTED: i32 = 15;
72
73// Perk indices
74const PERK_MR_FIXIT: usize = 31;
75const PERK_MEDIC: usize = 32;
76const PERK_MASTER_THIEF: usize = 33;
77const PERK_SPEAKER: usize = 34;
78const PERK_GHOST: usize = 38;
79const PERK_TAG: usize = 51;
80
81#[derive(Copy, Clone)]
82struct SkillFormula {
83    default_value: i32,
84    stat_modifier: i32,
85    stat1: usize,
86    stat2: i32,
87    points_modifier: i32,
88}
89
90// From fallout1-ce skill.cc skill_data[]. F1 uses (stat1+stat2)*mod/2 when stat2 is valid.
91#[rustfmt::skip]
92const SKILL_FORMULAS: [SkillFormula; SKILL_COUNT] = [
93    SkillFormula { default_value: 35, stat_modifier: 1, stat1: STAT_AGILITY,      stat2: STAT_INVALID,                    points_modifier: 1 }, // Small Guns
94    SkillFormula { default_value: 10, stat_modifier: 1, stat1: STAT_AGILITY,      stat2: STAT_INVALID,                    points_modifier: 1 }, // Big Guns
95    SkillFormula { default_value: 10, stat_modifier: 1, stat1: STAT_AGILITY,      stat2: STAT_INVALID,                    points_modifier: 1 }, // Energy Weapons
96    SkillFormula { default_value: 65, stat_modifier: 1, stat1: STAT_AGILITY,      stat2: STAT_STRENGTH as i32,            points_modifier: 1 }, // Unarmed
97    SkillFormula { default_value: 55, stat_modifier: 1, stat1: STAT_AGILITY,      stat2: STAT_STRENGTH as i32,            points_modifier: 1 }, // Melee Weapons
98    SkillFormula { default_value: 40, stat_modifier: 1, stat1: STAT_AGILITY,      stat2: STAT_INVALID,                    points_modifier: 1 }, // Throwing
99    SkillFormula { default_value: 30, stat_modifier: 1, stat1: STAT_PERCEPTION,   stat2: STAT_INTELLIGENCE as i32,        points_modifier: 1 }, // First Aid
100    SkillFormula { default_value: 15, stat_modifier: 1, stat1: STAT_PERCEPTION,   stat2: STAT_INTELLIGENCE as i32,        points_modifier: 1 }, // Doctor
101    SkillFormula { default_value: 25, stat_modifier: 1, stat1: STAT_AGILITY,      stat2: STAT_INVALID,                    points_modifier: 1 }, // Sneak
102    SkillFormula { default_value: 20, stat_modifier: 1, stat1: STAT_PERCEPTION,   stat2: STAT_AGILITY as i32,             points_modifier: 1 }, // Lockpick
103    SkillFormula { default_value: 20, stat_modifier: 1, stat1: STAT_AGILITY,      stat2: STAT_INVALID,                    points_modifier: 1 }, // Steal
104    SkillFormula { default_value: 20, stat_modifier: 1, stat1: STAT_PERCEPTION,   stat2: STAT_AGILITY as i32,             points_modifier: 1 }, // Traps
105    SkillFormula { default_value: 25, stat_modifier: 2, stat1: STAT_INTELLIGENCE, stat2: STAT_INVALID,                    points_modifier: 1 }, // Science
106    SkillFormula { default_value: 20, stat_modifier: 1, stat1: STAT_INTELLIGENCE, stat2: STAT_INVALID,                    points_modifier: 1 }, // Repair
107    SkillFormula { default_value: 25, stat_modifier: 2, stat1: STAT_CHARISMA,     stat2: STAT_INVALID,                    points_modifier: 1 }, // Speech
108    SkillFormula { default_value: 20, stat_modifier: 2, stat1: STAT_CHARISMA,     stat2: STAT_INVALID,                    points_modifier: 1 }, // Barter
109    SkillFormula { default_value: 20, stat_modifier: 3, stat1: STAT_LUCK,         stat2: STAT_INVALID,                    points_modifier: 1 }, // Gambling
110    SkillFormula { default_value:  5, stat_modifier: 1, stat1: STAT_ENDURANCE,    stat2: STAT_INTELLIGENCE as i32,        points_modifier: 1 }, // Outdoorsman
111];
112
113#[derive(Debug)]
114pub struct SaveGame {
115    pub header: SaveHeader,
116    pub player_combat_id: i32,
117    pub global_var_count: usize,
118    pub map_files: Vec<String>,
119    pub player_object: GameObject,
120    pub center_tile: i32,
121    pub critter_data: CritterProtoData,
122    pub gender: Gender,
123    pub kill_counts: [i32; KILL_TYPE_COUNT],
124    pub tagged_skills: [i32; TAGGED_SKILL_COUNT],
125    pub perks: [i32; PERK_COUNT],
126    pub combat_state: CombatState,
127    pub pc_stats: PcStats,
128    pub selected_traits: [i32; 2],
129}
130
131impl SaveGame {
132    /// Compute the effective (displayed) skill value, matching fallout1-ce skill_level().
133    pub fn effective_skill_value(&self, skill_index: usize) -> i32 {
134        if skill_index >= SKILL_COUNT {
135            return 0;
136        }
137
138        let formula = SKILL_FORMULAS[skill_index];
139        let points = self.critter_data.skills[skill_index];
140
141        // Stat bonus: F1 uses (stat1+stat2)*mod/2 when two stats are involved
142        let stat_bonus = if formula.stat2 != STAT_INVALID {
143            (self.total_stat(formula.stat1) + self.total_stat(formula.stat2 as usize))
144                * formula.stat_modifier
145                / 2
146        } else {
147            self.total_stat(formula.stat1) * formula.stat_modifier
148        };
149
150        let mut value = formula.default_value + stat_bonus + points * formula.points_modifier;
151
152        // Tagged skills get +20 and double the points contribution
153        if self.is_skill_tagged(skill_index) {
154            value += 20 + points * formula.points_modifier;
155
156            // If the Tag! perk granted the 4th tag slot, that tag doesn't get the +20
157            let has_tag_perk = self.has_perk_rank(PERK_TAG);
158            if has_tag_perk && skill_index as i32 == self.tagged_skills[3] {
159                value -= 20;
160            }
161        }
162
163        value += self.trait_skill_modifier(skill_index);
164        value += self.perk_skill_modifier(skill_index);
165
166        value.min(200)
167    }
168
169    /// Return only the contribution from tagging logic for one skill.
170    pub fn skill_tag_bonus(&self, skill_index: usize) -> i32 {
171        if skill_index >= SKILL_COUNT || !self.is_skill_tagged(skill_index) {
172            return 0;
173        }
174
175        let formula = SKILL_FORMULAS[skill_index];
176        let points = self.critter_data.skills[skill_index];
177        let mut bonus = points * formula.points_modifier;
178
179        let has_tag_perk = self.has_perk_rank(PERK_TAG);
180        if !has_tag_perk || skill_index as i32 != self.tagged_skills[3] {
181            bonus += 20;
182        }
183
184        bonus
185    }
186
187    fn total_stat(&self, stat_index: usize) -> i32 {
188        self.critter_data.base_stats[stat_index] + self.critter_data.bonus_stats[stat_index]
189    }
190
191    fn is_skill_tagged(&self, skill_index: usize) -> bool {
192        self.tagged_skills
193            .iter()
194            .any(|&s| s >= 0 && s as usize == skill_index)
195    }
196
197    fn has_perk_rank(&self, perk_index: usize) -> bool {
198        self.perks.get(perk_index).copied().unwrap_or(0) > 0
199    }
200
201    fn has_trait(&self, trait_index: i32) -> bool {
202        self.selected_traits.contains(&trait_index)
203    }
204
205    fn trait_skill_modifier(&self, skill_index: usize) -> i32 {
206        let mut modifier = 0;
207
208        if self.has_trait(TRAIT_GIFTED) {
209            modifier -= 10;
210        }
211
212        if self.has_trait(TRAIT_SKILLED) {
213            modifier += 10;
214        }
215
216        if self.has_trait(TRAIT_GOOD_NATURED) {
217            match skill_index {
218                SKILL_SMALL_GUNS | SKILL_BIG_GUNS | SKILL_ENERGY_WEAPONS | SKILL_UNARMED
219                | SKILL_MELEE_WEAPONS | SKILL_THROWING => modifier -= 10,
220                SKILL_FIRST_AID | SKILL_DOCTOR | SKILL_SPEECH | SKILL_BARTER => modifier += 15,
221                _ => {}
222            }
223        }
224
225        modifier
226    }
227
228    fn perk_skill_modifier(&self, skill_index: usize) -> i32 {
229        let mut modifier = 0;
230
231        match skill_index {
232            SKILL_FIRST_AID | SKILL_DOCTOR => {
233                if self.has_perk_rank(PERK_MEDIC) {
234                    modifier += 20;
235                }
236            }
237            SKILL_SNEAK => {
238                // Ghost perk depends on light level at runtime; skip it for save files.
239                let _ = self.has_perk_rank(PERK_GHOST);
240                if self.has_perk_rank(PERK_MASTER_THIEF) {
241                    modifier += 10;
242                }
243            }
244            SKILL_LOCKPICK | SKILL_STEAL | SKILL_TRAPS => {
245                if self.has_perk_rank(PERK_MASTER_THIEF) {
246                    modifier += 10;
247                }
248            }
249            SKILL_SCIENCE | SKILL_REPAIR => {
250                if self.has_perk_rank(PERK_MR_FIXIT) {
251                    modifier += 20;
252                }
253            }
254            SKILL_SPEECH | SKILL_BARTER => {
255                if self.has_perk_rank(PERK_SPEAKER) {
256                    modifier += 20;
257                }
258            }
259            _ => {}
260        }
261
262        modifier
263    }
264}
265
266#[derive(Debug)]
267pub struct Document {
268    pub save: SaveGame,
269    layout: FileLayout,
270    section_blobs: Vec<SectionBlob>,
271    original_section_blobs: Vec<SectionBlob>,
272    original_file_len: usize,
273}
274
275#[derive(Debug, Clone)]
276struct SectionBlob {
277    bytes: Vec<u8>,
278}
279
280struct Capture<'a> {
281    source: &'a [u8],
282    sections: Vec<SectionLayout>,
283    blobs: Vec<SectionBlob>,
284}
285
286impl<'a> Capture<'a> {
287    fn new(source: &'a [u8]) -> Self {
288        Self {
289            source,
290            sections: Vec::new(),
291            blobs: Vec::new(),
292        }
293    }
294
295    fn record(&mut self, id: SectionId, start: usize, end: usize) {
296        self.sections.push(SectionLayout {
297            id,
298            range: ByteRange { start, end },
299        });
300        self.blobs.push(SectionBlob {
301            bytes: self.source[start..end].to_vec(),
302        });
303    }
304}
305
306impl SaveGame {
307    pub fn parse<R: Read + Seek>(reader: R) -> io::Result<Self> {
308        let mut r = BigEndianReader::new(reader);
309        parse_internal(&mut r, None)
310    }
311}
312
313impl Document {
314    pub fn parse_with_layout<R: Read + Seek>(mut reader: R) -> io::Result<Self> {
315        let mut bytes = Vec::new();
316        reader.read_to_end(&mut bytes)?;
317
318        let mut capture = Capture::new(&bytes);
319        let mut r = BigEndianReader::new(Cursor::new(bytes.as_slice()));
320        let save = parse_internal(&mut r, Some(&mut capture))?;
321
322        let consumed = r.position()? as usize;
323        let file_len = bytes.len();
324        if consumed < file_len {
325            capture.record(SectionId::Tail, consumed, file_len);
326        }
327
328        let layout = FileLayout {
329            file_len,
330            sections: capture.sections,
331        };
332        layout.validate()?;
333
334        let original_section_blobs = capture.blobs.clone();
335
336        Ok(Self {
337            save,
338            layout,
339            section_blobs: capture.blobs,
340            original_section_blobs,
341            original_file_len: file_len,
342        })
343    }
344
345    pub fn layout(&self) -> &FileLayout {
346        &self.layout
347    }
348
349    pub fn supports_editing(&self) -> bool {
350        true
351    }
352
353    pub fn to_bytes_unmodified(&self) -> io::Result<Vec<u8>> {
354        emit_from_blobs(
355            &self.original_section_blobs,
356            self.original_file_len,
357            "unmodified",
358        )
359    }
360
361    pub fn to_bytes_modified(&self) -> io::Result<Vec<u8>> {
362        self.validate_modified_state()?;
363        emit_from_blobs(&self.section_blobs, self.layout.file_len, "modified")
364    }
365
366    pub fn set_hp(&mut self, hp: i32) -> io::Result<()> {
367        let blob = self.section_blob_mut(SectionId::Handler(5))?;
368        patch_i32_in_blob(blob, PLAYER_HP_OFFSET_IN_HANDLER5, hp, "handler 5", "hp")?;
369        if let object::ObjectData::Critter(ref mut data) = self.save.player_object.object_data {
370            data.hp = hp;
371        }
372        Ok(())
373    }
374
375    pub fn set_base_stat(&mut self, stat_index: usize, value: i32) -> io::Result<()> {
376        let offset = CRITTER_PROTO_BASE_STATS_OFFSET + stat_index * I32_WIDTH;
377        self.patch_handler6_i32(offset, value, &format!("stat {stat_index}"))?;
378        self.save.critter_data.base_stats[stat_index] = value;
379        Ok(())
380    }
381
382    pub fn set_age(&mut self, age: i32) -> io::Result<()> {
383        self.patch_handler6_i32(CRITTER_PROTO_AGE_OFFSET, age, "age")?;
384        self.save.critter_data.base_stats[STAT_AGE_INDEX] = age;
385        Ok(())
386    }
387
388    pub fn set_gender(&mut self, gender: Gender) -> io::Result<()> {
389        let raw = gender.raw();
390        self.patch_handler6_i32(GENDER_OFFSET_IN_HANDLER6, raw, "gender")?;
391        self.save.critter_data.base_stats[STAT_GENDER_INDEX] = raw;
392        self.save.gender = Gender::from_raw(raw);
393        Ok(())
394    }
395
396    pub fn set_level(&mut self, level: i32) -> io::Result<()> {
397        self.patch_handler13_i32(PC_STATS_LEVEL_OFFSET, level, "level")?;
398        self.save.pc_stats.level = level;
399        Ok(())
400    }
401
402    pub fn set_experience(&mut self, experience: i32) -> io::Result<()> {
403        self.patch_handler6_i32(CRITTER_PROTO_EXPERIENCE_OFFSET, experience, "experience")?;
404        self.patch_handler13_i32(PC_STATS_EXPERIENCE_OFFSET, experience, "experience")?;
405        self.save.critter_data.experience = experience;
406        self.save.pc_stats.experience = experience;
407        Ok(())
408    }
409
410    pub fn set_skill_points(&mut self, skill_points: i32) -> io::Result<()> {
411        self.patch_handler13_i32(
412            PC_STATS_UNSPENT_SKILL_POINTS_OFFSET,
413            skill_points,
414            "skill points",
415        )?;
416        self.save.pc_stats.unspent_skill_points = skill_points;
417        Ok(())
418    }
419
420    pub fn set_reputation(&mut self, reputation: i32) -> io::Result<()> {
421        self.patch_handler13_i32(PC_STATS_REPUTATION_OFFSET, reputation, "reputation")?;
422        self.save.pc_stats.reputation = reputation;
423        Ok(())
424    }
425
426    pub fn set_karma(&mut self, karma: i32) -> io::Result<()> {
427        self.patch_handler13_i32(PC_STATS_KARMA_OFFSET, karma, "karma")?;
428        self.save.pc_stats.karma = karma;
429        Ok(())
430    }
431
432    pub fn set_trait(&mut self, slot: usize, trait_index: i32) -> io::Result<()> {
433        if slot >= self.save.selected_traits.len() {
434            return Err(io::Error::new(
435                io::ErrorKind::InvalidInput,
436                format!(
437                    "invalid trait slot {slot}, expected 0..{}",
438                    self.save.selected_traits.len() - 1
439                ),
440            ));
441        }
442        if trait_index < 0 || trait_index as usize >= types::TRAIT_NAMES.len() {
443            return Err(io::Error::new(
444                io::ErrorKind::InvalidInput,
445                format!(
446                    "invalid trait index {trait_index}, expected 0..{}",
447                    types::TRAIT_NAMES.len() - 1
448                ),
449            ));
450        }
451
452        self.patch_trait_slot(slot, trait_index)?;
453        self.save.selected_traits[slot] = trait_index;
454        Ok(())
455    }
456
457    pub fn clear_trait(&mut self, slot: usize) -> io::Result<()> {
458        if slot >= self.save.selected_traits.len() {
459            return Err(io::Error::new(
460                io::ErrorKind::InvalidInput,
461                format!(
462                    "invalid trait slot {slot}, expected 0..{}",
463                    self.save.selected_traits.len() - 1
464                ),
465            ));
466        }
467
468        self.patch_trait_slot(slot, -1)?;
469        self.save.selected_traits[slot] = -1;
470        Ok(())
471    }
472
473    pub fn set_perk_rank(&mut self, perk_index: usize, rank: i32) -> io::Result<()> {
474        if perk_index >= self.save.perks.len() {
475            return Err(io::Error::new(
476                io::ErrorKind::InvalidInput,
477                format!(
478                    "invalid perk index {perk_index}, expected 0..{}",
479                    self.save.perks.len() - 1
480                ),
481            ));
482        }
483        if !(0..=20).contains(&rank) {
484            return Err(io::Error::new(
485                io::ErrorKind::InvalidInput,
486                format!("invalid perk rank {rank}, expected 0..20"),
487            ));
488        }
489
490        let offset = perk_index * I32_WIDTH;
491        let blob = self.section_blob_mut(SectionId::Handler(10))?;
492        patch_i32_in_blob(blob, offset, rank, "handler 10", "perk rank")?;
493        self.save.perks[perk_index] = rank;
494        Ok(())
495    }
496
497    pub fn clear_perk(&mut self, perk_index: usize) -> io::Result<()> {
498        self.set_perk_rank(perk_index, 0)
499    }
500
501    pub fn set_inventory_quantity(&mut self, pid: i32, quantity: i32) -> io::Result<()> {
502        if quantity < 0 {
503            return Err(io::Error::new(
504                io::ErrorKind::InvalidInput,
505                format!("invalid inventory quantity {quantity}, expected >= 0"),
506            ));
507        }
508
509        let mut found = false;
510        let mut assigned = false;
511        self.save.player_object.inventory.retain_mut(|item| {
512            if item.object.pid != pid {
513                return true;
514            }
515            found = true;
516            if quantity == 0 {
517                return false;
518            }
519            if assigned {
520                return false;
521            }
522
523            item.quantity = quantity;
524            assigned = true;
525            true
526        });
527
528        if !found {
529            return Err(io::Error::new(
530                io::ErrorKind::InvalidInput,
531                format!("inventory item pid={pid} not found"),
532            ));
533        }
534
535        self.rewrite_handler5_from_player_object()
536    }
537
538    pub fn add_inventory_item(&mut self, pid: i32, quantity: i32) -> io::Result<()> {
539        if quantity <= 0 {
540            return Err(io::Error::new(
541                io::ErrorKind::InvalidInput,
542                format!("invalid inventory quantity {quantity}, expected > 0"),
543            ));
544        }
545
546        let mut found = false;
547        for item in &mut self.save.player_object.inventory {
548            if item.object.pid == pid {
549                item.quantity = item.quantity.checked_add(quantity).ok_or_else(|| {
550                    io::Error::new(
551                        io::ErrorKind::InvalidInput,
552                        format!(
553                            "inventory quantity overflow for pid={pid}: {} + {quantity}",
554                            item.quantity
555                        ),
556                    )
557                })?;
558                found = true;
559                break;
560            }
561        }
562
563        if !found {
564            return Err(io::Error::new(
565                io::ErrorKind::InvalidInput,
566                format!("cannot add new inventory pid={pid}: no existing template item in save"),
567            ));
568        }
569
570        self.rewrite_handler5_from_player_object()
571    }
572
573    pub fn remove_inventory_item(&mut self, pid: i32, quantity: Option<i32>) -> io::Result<()> {
574        let total_before: i64 = self
575            .save
576            .player_object
577            .inventory
578            .iter()
579            .filter(|item| item.object.pid == pid)
580            .map(|item| item.quantity as i64)
581            .sum();
582
583        if total_before == 0 {
584            return Err(io::Error::new(
585                io::ErrorKind::InvalidInput,
586                format!("inventory item pid={pid} not found"),
587            ));
588        }
589
590        let target_total = match quantity {
591            None => 0i64,
592            Some(qty) => {
593                if qty <= 0 {
594                    return Err(io::Error::new(
595                        io::ErrorKind::InvalidInput,
596                        format!("invalid inventory removal quantity {qty}, expected > 0"),
597                    ));
598                }
599                (total_before - qty as i64).max(0)
600            }
601        };
602
603        let mut reassigned = false;
604        self.save.player_object.inventory.retain_mut(|item| {
605            if item.object.pid != pid {
606                return true;
607            }
608            if reassigned {
609                return false;
610            }
611            if target_total <= 0 {
612                return false;
613            }
614
615            item.quantity = target_total as i32;
616            reassigned = true;
617            true
618        });
619
620        self.rewrite_handler5_from_player_object()
621    }
622
623    fn patch_handler6_i32(&mut self, offset: usize, raw: i32, field: &str) -> io::Result<()> {
624        let blob = self.section_blob_mut(SectionId::Handler(6))?;
625        patch_i32_in_blob(blob, offset, raw, "handler 6", field)
626    }
627
628    fn patch_handler13_i32(&mut self, offset: usize, raw: i32, field: &str) -> io::Result<()> {
629        let blob = self.section_blob_mut(SectionId::Handler(13))?;
630        patch_i32_in_blob(blob, offset, raw, "handler 13", field)
631    }
632
633    fn section_blob_mut(&mut self, id: SectionId) -> io::Result<&mut SectionBlob> {
634        let section_index = self.section_index(id)?;
635
636        self.section_blobs.get_mut(section_index).ok_or_else(|| {
637            io::Error::new(
638                io::ErrorKind::InvalidData,
639                "section blob list does not match recorded layout",
640            )
641        })
642    }
643
644    fn section_index(&self, id: SectionId) -> io::Result<usize> {
645        self.layout
646            .sections
647            .iter()
648            .position(|section| section.id == id)
649            .ok_or_else(|| {
650                io::Error::new(
651                    io::ErrorKind::InvalidData,
652                    format!("missing section {id:?}"),
653                )
654            })
655    }
656
657    fn patch_trait_slot(&mut self, slot: usize, value: i32) -> io::Result<()> {
658        let offset = slot * I32_WIDTH;
659        let blob = self.section_blob_mut(SectionId::Handler(16))?;
660        patch_i32_in_blob(blob, offset, value, "handler 16", "trait")
661    }
662
663    fn rewrite_handler5_from_player_object(&mut self) -> io::Result<()> {
664        let mut blob = Vec::new();
665        self.save.player_object.emit_to_vec(&mut blob)?;
666        blob.extend_from_slice(&self.save.center_tile.to_be_bytes());
667        self.replace_section_blob(SectionId::Handler(5), blob)
668    }
669
670    fn replace_section_blob(&mut self, id: SectionId, bytes: Vec<u8>) -> io::Result<()> {
671        let section_index = self.section_index(id)?;
672        let section = self.layout.sections.get_mut(section_index).ok_or_else(|| {
673            io::Error::new(
674                io::ErrorKind::InvalidData,
675                "section blob list does not match recorded layout",
676            )
677        })?;
678        let old_len = section.range.len();
679        let new_len = bytes.len();
680        section.range.end = section.range.start + new_len;
681
682        if new_len != old_len {
683            if new_len > old_len {
684                let delta = new_len - old_len;
685                for later in self.layout.sections.iter_mut().skip(section_index + 1) {
686                    later.range.start = later.range.start.checked_add(delta).ok_or_else(|| {
687                        io::Error::new(io::ErrorKind::InvalidData, "section start overflow")
688                    })?;
689                    later.range.end = later.range.end.checked_add(delta).ok_or_else(|| {
690                        io::Error::new(io::ErrorKind::InvalidData, "section end overflow")
691                    })?;
692                }
693                self.layout.file_len =
694                    self.layout.file_len.checked_add(delta).ok_or_else(|| {
695                        io::Error::new(io::ErrorKind::InvalidData, "layout file_len overflow")
696                    })?;
697            } else {
698                let delta = old_len - new_len;
699                for later in self.layout.sections.iter_mut().skip(section_index + 1) {
700                    later.range.start = later.range.start.checked_sub(delta).ok_or_else(|| {
701                        io::Error::new(io::ErrorKind::InvalidData, "section start underflow")
702                    })?;
703                    later.range.end = later.range.end.checked_sub(delta).ok_or_else(|| {
704                        io::Error::new(io::ErrorKind::InvalidData, "section end underflow")
705                    })?;
706                }
707                self.layout.file_len =
708                    self.layout.file_len.checked_sub(delta).ok_or_else(|| {
709                        io::Error::new(io::ErrorKind::InvalidData, "layout file_len underflow")
710                    })?;
711            }
712        }
713
714        let slot = self.section_blobs.get_mut(section_index).ok_or_else(|| {
715            io::Error::new(
716                io::ErrorKind::InvalidData,
717                "section blob list does not match recorded layout",
718            )
719        })?;
720        slot.bytes = bytes;
721
722        Ok(())
723    }
724
725    fn validate_modified_state(&self) -> io::Result<()> {
726        if self.layout.sections.len() != self.section_blobs.len() {
727            return Err(io::Error::new(
728                io::ErrorKind::InvalidData,
729                format!(
730                    "layout/blob section count mismatch: {} layout sections, {} blobs",
731                    self.layout.sections.len(),
732                    self.section_blobs.len()
733                ),
734            ));
735        }
736
737        for (idx, (section, blob)) in self
738            .layout
739            .sections
740            .iter()
741            .zip(self.section_blobs.iter())
742            .enumerate()
743        {
744            let expected = section.range.len();
745            let actual = blob.bytes.len();
746            if expected != actual {
747                return Err(io::Error::new(
748                    io::ErrorKind::InvalidData,
749                    format!(
750                        "section/blob length mismatch at index {idx} ({:?}): layout={}, blob={}",
751                        section.id, expected, actual
752                    ),
753                ));
754            }
755        }
756
757        self.layout.validate()
758    }
759}
760
761fn patch_i32_in_blob(
762    blob: &mut SectionBlob,
763    offset: usize,
764    raw: i32,
765    section_label: &str,
766    field_label: &str,
767) -> io::Result<()> {
768    if blob.bytes.len() < offset + I32_WIDTH {
769        return Err(io::Error::new(
770            io::ErrorKind::InvalidData,
771            format!(
772                "{section_label} too short for {field_label} patch: len={}, need at least {}",
773                blob.bytes.len(),
774                offset + I32_WIDTH
775            ),
776        ));
777    }
778
779    blob.bytes[offset..offset + I32_WIDTH].copy_from_slice(&raw.to_be_bytes());
780    Ok(())
781}
782
783fn emit_from_blobs(
784    blobs: &[SectionBlob],
785    expected_len: usize,
786    mode_label: &str,
787) -> io::Result<Vec<u8>> {
788    let mut out = Vec::with_capacity(expected_len);
789    for blob in blobs {
790        out.extend_from_slice(&blob.bytes);
791    }
792
793    if out.len() != expected_len {
794        return Err(io::Error::new(
795            io::ErrorKind::InvalidData,
796            format!(
797                "{mode_label} emit length mismatch: got {}, expected {}",
798                out.len(),
799                expected_len
800            ),
801        ));
802    }
803
804    Ok(out)
805}
806
807fn parse_handlers_14_to_16<R: Read + Seek>(
808    r: &mut BigEndianReader<R>,
809    capture: &mut Option<&mut Capture<'_>>,
810) -> io::Result<[i32; 2]> {
811    // Handler 14: no-op (0 bytes)
812    let h14_pos = r.position()? as usize;
813    if let Some(c) = capture.as_deref_mut() {
814        c.record(SectionId::Handler(14), h14_pos, h14_pos);
815    }
816
817    // Handler 15: event queue (variable)
818    let h15_start = r.position()? as usize;
819    skip_event_queue(r)?;
820    let h15_end = r.position()? as usize;
821    if let Some(c) = capture.as_deref_mut() {
822        c.record(SectionId::Handler(15), h15_start, h15_end);
823    }
824
825    // Handler 16: traits (8 bytes)
826    let h16_start = r.position()? as usize;
827    let traits = parse_traits(r)?;
828    let h16_end = r.position()? as usize;
829    if let Some(c) = capture.as_deref_mut() {
830        c.record(SectionId::Handler(16), h16_start, h16_end);
831    }
832
833    Ok(traits)
834}
835
836fn parse_internal<R: Read + Seek>(
837    r: &mut BigEndianReader<R>,
838    mut capture: Option<&mut Capture<'_>>,
839) -> io::Result<SaveGame> {
840    // Header (30,051 bytes)
841    let header_start = r.position()? as usize;
842    let header = SaveHeader::parse(r)?;
843    let header_end = r.position()? as usize;
844    if let Some(c) = capture.as_deref_mut() {
845        c.record(SectionId::Header, header_start, header_end);
846    }
847
848    // Handler 1: Player combat ID (4 bytes)
849    let h1_start = r.position()? as usize;
850    let player_combat_id = parse_player_combat_id(r)?;
851    let h1_end = r.position()? as usize;
852    if let Some(c) = capture.as_deref_mut() {
853        c.record(SectionId::Handler(1), h1_start, h1_end);
854    }
855
856    // Handler 2: Game global variables (variable length)
857    let h2_start = r.position()? as usize;
858    let globals = parse_game_global_vars(r)?;
859    let h2_end = r.position()? as usize;
860    let global_var_count = globals.global_vars.len();
861    if let Some(c) = capture.as_deref_mut() {
862        c.record(SectionId::Handler(2), h2_start, h2_end);
863    }
864
865    // Handler 3: Map file list (variable length)
866    let h3_start = r.position()? as usize;
867    let map_list = parse_map_file_list(r)?;
868    let h3_end = r.position()? as usize;
869    if let Some(c) = capture.as_deref_mut() {
870        c.record(SectionId::Handler(3), h3_start, h3_end);
871    }
872
873    // Handler 4: Game globals duplicate — skip (same size as handler 2)
874    let h4_start = r.position()? as usize;
875    let skip_size = (global_var_count * 4 + 1) as u64;
876    r.skip(skip_size)?;
877    let h4_end = r.position()? as usize;
878    if let Some(c) = capture.as_deref_mut() {
879        c.record(SectionId::Handler(4), h4_start, h4_end);
880    }
881
882    // Handler 5: Player object (variable length, recursive)
883    let h5_start = r.position()? as usize;
884    let player_section = parse_player_object(r)?;
885    let h5_end = r.position()? as usize;
886    if let Some(c) = capture.as_deref_mut() {
887        c.record(SectionId::Handler(5), h5_start, h5_end);
888    }
889
890    // Handler 6: Critter proto data (372 bytes)
891    let h6_start = r.position()? as usize;
892    let critter_data = parse_critter_proto(r)?;
893    let h6_end = r.position()? as usize;
894    let gender = Gender::from_raw(critter_data.base_stats[STAT_GENDER_INDEX]);
895    if let Some(c) = capture.as_deref_mut() {
896        c.record(SectionId::Handler(6), h6_start, h6_end);
897    }
898
899    // Handler 7: Kill counts (64 bytes)
900    let h7_start = r.position()? as usize;
901    let kill_counts = parse_kill_counts(r)?;
902    let h7_end = r.position()? as usize;
903    if let Some(c) = capture.as_deref_mut() {
904        c.record(SectionId::Handler(7), h7_start, h7_end);
905    }
906
907    // Handler 8: Tagged skills (16 bytes)
908    let h8_start = r.position()? as usize;
909    let tagged_skills = parse_tagged_skills(r)?;
910    let h8_end = r.position()? as usize;
911    if let Some(c) = capture.as_deref_mut() {
912        c.record(SectionId::Handler(8), h8_start, h8_end);
913    }
914
915    // Handler 9: Roll — no-op (0 bytes)
916    let h9_pos = r.position()? as usize;
917    if let Some(c) = capture.as_deref_mut() {
918        c.record(SectionId::Handler(9), h9_pos, h9_pos);
919    }
920
921    // Handler 10: Perks (252 bytes)
922    let h10_start = r.position()? as usize;
923    let perks = parse_perks(r)?;
924    let h10_end = r.position()? as usize;
925    if let Some(c) = capture.as_deref_mut() {
926        c.record(SectionId::Handler(10), h10_start, h10_end);
927    }
928
929    // Handler 11: Combat state (variable, min 4 bytes)
930    let h11_start = r.position()? as usize;
931    let combat_state = parse_combat_state(r)?;
932    let h11_end = r.position()? as usize;
933    if let Some(c) = capture.as_deref_mut() {
934        c.record(SectionId::Handler(11), h11_start, h11_end);
935    }
936
937    // Handler 12: Combat AI — no-op (0 bytes)
938    let h12_pos = r.position()? as usize;
939    if let Some(c) = capture.as_deref_mut() {
940        c.record(SectionId::Handler(12), h12_pos, h12_pos);
941    }
942
943    // Handler 13: PC stats (20 bytes)
944    let h13_start = r.position()? as usize;
945    let pc_stats = parse_pc_stats(r)?;
946    let h13_end = r.position()? as usize;
947    if let Some(c) = capture.as_deref_mut() {
948        c.record(SectionId::Handler(13), h13_start, h13_end);
949    }
950
951    // Handlers 14-16: try to parse traits; fall back to [-1, -1] on failure.
952    let pre_traits_pos = r.position()?;
953    let pre_traits_capture_len = capture.as_deref().map(|c| c.sections.len());
954    let selected_traits = match parse_handlers_14_to_16(r, &mut capture) {
955        Ok(traits) => traits,
956        Err(_) => {
957            r.seek_to(pre_traits_pos)?;
958            if let (Some(c), Some(len)) = (capture, pre_traits_capture_len) {
959                c.sections.truncate(len);
960                c.blobs.truncate(len);
961            }
962            [-1, -1]
963        }
964    };
965
966    Ok(SaveGame {
967        header,
968        player_combat_id,
969        global_var_count,
970        map_files: map_list.map_files,
971        player_object: player_section.player_object,
972        center_tile: player_section.center_tile,
973        critter_data,
974        gender,
975        kill_counts,
976        tagged_skills,
977        perks,
978        combat_state,
979        pc_stats,
980        selected_traits,
981    })
982}