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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}