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
50const 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
68const TRAIT_GOOD_NATURED: i32 = 10;
70const TRAIT_SKILLED: i32 = 14;
71const TRAIT_GIFTED: i32 = 15;
72
73const 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#[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 }, SkillFormula { default_value: 10, stat_modifier: 1, stat1: STAT_AGILITY, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 10, stat_modifier: 1, stat1: STAT_AGILITY, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 65, stat_modifier: 1, stat1: STAT_AGILITY, stat2: STAT_STRENGTH as i32, points_modifier: 1 }, SkillFormula { default_value: 55, stat_modifier: 1, stat1: STAT_AGILITY, stat2: STAT_STRENGTH as i32, points_modifier: 1 }, SkillFormula { default_value: 40, stat_modifier: 1, stat1: STAT_AGILITY, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 30, stat_modifier: 1, stat1: STAT_PERCEPTION, stat2: STAT_INTELLIGENCE as i32, points_modifier: 1 }, SkillFormula { default_value: 15, stat_modifier: 1, stat1: STAT_PERCEPTION, stat2: STAT_INTELLIGENCE as i32, points_modifier: 1 }, SkillFormula { default_value: 25, stat_modifier: 1, stat1: STAT_AGILITY, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 20, stat_modifier: 1, stat1: STAT_PERCEPTION, stat2: STAT_AGILITY as i32, points_modifier: 1 }, SkillFormula { default_value: 20, stat_modifier: 1, stat1: STAT_AGILITY, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 20, stat_modifier: 1, stat1: STAT_PERCEPTION, stat2: STAT_AGILITY as i32, points_modifier: 1 }, SkillFormula { default_value: 25, stat_modifier: 2, stat1: STAT_INTELLIGENCE, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 20, stat_modifier: 1, stat1: STAT_INTELLIGENCE, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 25, stat_modifier: 2, stat1: STAT_CHARISMA, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 20, stat_modifier: 2, stat1: STAT_CHARISMA, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 20, stat_modifier: 3, stat1: STAT_LUCK, stat2: STAT_INVALID, points_modifier: 1 }, SkillFormula { default_value: 5, stat_modifier: 1, stat1: STAT_ENDURANCE, stat2: STAT_INTELLIGENCE as i32, points_modifier: 1 }, ];
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 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 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 if self.is_skill_tagged(skill_index) {
154 value += 20 + points * formula.points_modifier;
155
156 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}