1use std::io::{self, Read, Seek};
2
3use crate::reader::BigEndianReader;
4
5use super::types::{
6 KILL_TYPE_COUNT, PC_STAT_COUNT, PERK_COUNT, SAVEABLE_STAT_COUNT, SKILL_COUNT,
7 TAGGED_SKILL_COUNT,
8};
9use crate::object::GameObject;
10
11const MAX_GLOBAL_VAR_COUNT: usize = 5000;
12const MAX_MAP_FILE_COUNT: i32 = 512;
13const MAX_PARTY_MEMBER_COUNT: usize = 64;
14const AI_PACKET_INT_COUNT: usize = 45;
15const TRAITS_MAX_SELECTED_COUNT: usize = 2;
16const TRAIT_COUNT: i32 = 16;
17
18pub fn parse_player_combat_id<R: Read + Seek>(r: &mut BigEndianReader<R>) -> io::Result<i32> {
21 r.read_i32()
22}
23
24pub struct GlobalVarsSection {
27 pub global_vars: Vec<i32>,
28}
29
30pub fn parse_game_global_vars<R: Read + Seek>(
33 r: &mut BigEndianReader<R>,
34) -> io::Result<GlobalVarsSection> {
35 let start_pos = r.position()?;
36 let detected_n = detect_global_var_count(r, start_pos)?;
37
38 r.seek_to(start_pos)?;
39 let global_vars = r.read_i32_vec(detected_n)?;
40
41 Ok(GlobalVarsSection { global_vars })
42}
43
44fn detect_global_var_count<R: Read + Seek>(
45 r: &mut BigEndianReader<R>,
46 handler2_start: u64,
47) -> io::Result<usize> {
48 for n in 1..MAX_GLOBAL_VAR_COUNT {
49 r.seek_to(handler2_start)?;
50
51 let globals = match r.read_i32_vec(n) {
52 Ok(v) => v,
53 Err(_) => continue,
54 };
55
56 let map_section = match parse_map_file_list(r) {
57 Ok(v) => v,
58 Err(_) => continue,
59 };
60
61 if map_section.map_files.is_empty()
62 || map_section.map_files.len() > MAX_MAP_FILE_COUNT as usize
63 {
64 continue;
65 }
66 if !map_section
67 .map_files
68 .iter()
69 .all(|name| !name.is_empty() && name.to_ascii_uppercase().ends_with(".SAV"))
70 {
71 continue;
72 }
73 if !(0..=200_000_000).contains(&map_section.automap_size) {
74 continue;
75 }
76
77 let duplicate_globals = match r.read_i32_vec(n) {
79 Ok(v) => v,
80 Err(_) => continue,
81 };
82 if duplicate_globals != globals {
83 continue;
84 }
85
86 return Ok(n);
87 }
88
89 Err(io::Error::new(
90 io::ErrorKind::InvalidData,
91 "could not detect Fallout 2 global variable count",
92 ))
93}
94
95pub struct MapFileListSection {
98 pub map_files: Vec<String>,
99 pub automap_size: i32,
100}
101
102pub fn parse_map_file_list<R: Read + Seek>(
103 r: &mut BigEndianReader<R>,
104) -> io::Result<MapFileListSection> {
105 let map_file_count = r.read_i32()?;
106 if map_file_count <= 0 || map_file_count > MAX_MAP_FILE_COUNT {
107 return Err(io::Error::new(
108 io::ErrorKind::InvalidData,
109 "invalid map file count",
110 ));
111 }
112
113 let mut map_files = Vec::with_capacity(map_file_count as usize);
114 for _ in 0..map_file_count {
115 let filename = r.read_null_terminated_string(16)?;
116 if filename.is_empty() {
117 return Err(io::Error::new(
118 io::ErrorKind::InvalidData,
119 "empty map filename",
120 ));
121 }
122 map_files.push(filename);
123 }
124
125 let automap_size = r.read_i32()?;
126
127 Ok(MapFileListSection {
128 map_files,
129 automap_size,
130 })
131}
132
133pub struct PlayerObjectSection {
136 pub player_object: GameObject,
137 pub center_tile: i32,
138}
139
140pub fn parse_player_object<R: Read + Seek>(
141 r: &mut BigEndianReader<R>,
142) -> io::Result<PlayerObjectSection> {
143 let player_object = GameObject::parse(r)?;
144 let center_tile = r.read_i32()?;
145 Ok(PlayerObjectSection {
146 player_object,
147 center_tile,
148 })
149}
150
151#[derive(Debug, Clone)]
154pub struct CritterProtoData {
155 pub sneak_working: i32,
156 pub flags: i32,
157 pub base_stats: [i32; SAVEABLE_STAT_COUNT],
158 pub bonus_stats: [i32; SAVEABLE_STAT_COUNT],
159 pub skills: [i32; SKILL_COUNT],
160 pub body_type: i32,
161 pub experience: i32,
162 pub kill_type: i32,
163}
164
165pub fn parse_critter_proto<R: Read + Seek>(
166 r: &mut BigEndianReader<R>,
167) -> io::Result<CritterProtoData> {
168 let sneak_working = r.read_i32()?;
169 let flags = r.read_i32()?;
170 let base_stats = r.read_i32_array::<SAVEABLE_STAT_COUNT>()?;
171 let bonus_stats = r.read_i32_array::<SAVEABLE_STAT_COUNT>()?;
172 let skills = r.read_i32_array::<SKILL_COUNT>()?;
173 let body_type = r.read_i32()?;
174 let experience = r.read_i32()?;
175 let kill_type = r.read_i32()?;
176
177 Ok(CritterProtoData {
178 sneak_working,
179 flags,
180 base_stats,
181 bonus_stats,
182 skills,
183 body_type,
184 experience,
185 kill_type,
186 })
187}
188
189pub fn parse_critter_proto_nearby<R: Read + Seek>(
194 r: &mut BigEndianReader<R>,
195) -> io::Result<CritterProtoData> {
196 let guessed_pos = r.position()?;
197 let file_len = r.len()?;
198
199 let mut best_pos = None;
200 let mut best_score = i32::MIN;
201
202 for delta in -256i64..=1024i64 {
203 if delta % 4 != 0 {
204 continue;
205 }
206
207 let pos = if delta < 0 {
208 match guessed_pos.checked_sub((-delta) as u64) {
209 Some(v) => v,
210 None => continue,
211 }
212 } else {
213 guessed_pos + delta as u64
214 };
215
216 if pos + 372 > file_len {
217 continue;
218 }
219
220 r.seek_to(pos)?;
221 let candidate = match parse_critter_proto(r) {
222 Ok(v) => v,
223 Err(_) => continue,
224 };
225
226 let kills = match parse_kill_counts(r) {
227 Ok(v) => v,
228 Err(_) => continue,
229 };
230 let tagged = match parse_tagged_skills(r) {
231 Ok(v) => v,
232 Err(_) => continue,
233 };
234
235 let score = score_critter_proto_candidate(&candidate, &kills, &tagged);
236 if score > best_score {
237 best_score = score;
238 best_pos = Some(pos);
239 }
240 }
241
242 let pos = match best_pos {
243 Some(v) if best_score >= 12 => v,
244 _ => {
245 return Err(io::Error::new(
246 io::ErrorKind::InvalidData,
247 "could not align Fallout 2 critter proto section",
248 ));
249 }
250 };
251
252 r.seek_to(pos)?;
253 parse_critter_proto(r)
254}
255
256fn score_critter_proto_candidate(
257 candidate: &CritterProtoData,
258 kills: &[i32; KILL_TYPE_COUNT],
259 tagged: &[i32; TAGGED_SKILL_COUNT],
260) -> i32 {
261 let mut score = 0;
262
263 let special_ok = candidate
264 .base_stats
265 .iter()
266 .take(7)
267 .all(|v| (1..=10).contains(v));
268 if special_ok {
269 score += 12;
270 }
271
272 if candidate.skills.iter().all(|v| (0..=400).contains(v)) {
273 score += 6;
274 }
275
276 if (0..=100_000_000).contains(&candidate.experience) {
277 score += 2;
278 }
279 if (0..=64).contains(&candidate.body_type) {
280 score += 1;
281 }
282
283 if kills.iter().all(|v| (0..=1_000_000).contains(v)) {
284 score += 3;
285 }
286
287 if tagged
288 .iter()
289 .all(|&skill| skill == -1 || (0..SKILL_COUNT as i32).contains(&skill))
290 {
291 score += 4;
292 }
293
294 let non_negative_tagged: Vec<i32> = tagged.iter().copied().filter(|v| *v >= 0).collect();
295 if !non_negative_tagged.is_empty() {
296 score += 2;
297 }
298 let mut unique = non_negative_tagged.clone();
299 unique.sort_unstable();
300 unique.dedup();
301 if unique.len() == non_negative_tagged.len() {
302 score += 2;
303 }
304
305 score
306}
307
308pub fn parse_kill_counts<R: Read + Seek>(
311 r: &mut BigEndianReader<R>,
312) -> io::Result<[i32; KILL_TYPE_COUNT]> {
313 r.read_i32_array::<KILL_TYPE_COUNT>()
314}
315
316pub fn parse_tagged_skills<R: Read + Seek>(
319 r: &mut BigEndianReader<R>,
320) -> io::Result<[i32; TAGGED_SKILL_COUNT]> {
321 r.read_i32_array::<TAGGED_SKILL_COUNT>()
322}
323
324#[derive(Debug, Clone)]
327pub struct CombatState {
328 pub combat_state_flags: u32,
329 pub combat_data: Option<CombatData>,
330}
331
332#[derive(Debug, Clone)]
333pub struct CombatData {
334 pub turn_running: i32,
335 pub free_move: i32,
336 pub exps: i32,
337 pub list_com: i32,
338 pub list_noncom: i32,
339 pub list_total: i32,
340 pub dude_cid: i32,
341 pub combatant_cids: Vec<i32>,
342 pub ai_info: Vec<CombatAiInfo>,
343}
344
345#[derive(Debug, Clone)]
346pub struct CombatAiInfo {
347 pub friendly_dead_id: i32,
348 pub last_target_id: i32,
349 pub last_item_id: i32,
350 pub last_move: i32,
351}
352
353#[derive(Debug, Clone)]
354pub struct PcStats {
355 pub unspent_skill_points: i32,
356 pub level: i32,
357 pub experience: i32,
358 pub reputation: i32,
359 pub karma: i32,
360}
361
362pub struct PostTaggedSections {
363 pub perks: [i32; PERK_COUNT],
364 pub combat_state: CombatState,
365 pub pc_stats: PcStats,
366 pub selected_traits: [i32; TRAITS_MAX_SELECTED_COUNT],
367 pub game_difficulty: i32,
368 pub party_member_count: usize,
369 pub ai_packet_count: usize,
370 pub detection_score: i32,
371 pub h10_end: u64,
372 pub h11_end: u64,
373 pub h12_end: u64,
374 pub h13_end: u64,
375 pub h15_end: u64,
376 pub h16_end: u64,
377 pub h17_prefix_end: u64,
378}
379
380pub fn parse_post_tagged_sections<R: Read + Seek>(
381 r: &mut BigEndianReader<R>,
382) -> io::Result<PostTaggedSections> {
383 let start_pos = r.position()?;
384 let file_len = r.len()?;
385
386 let mut best_score = i32::MIN;
387 let mut best_party_count = 0usize;
388 let mut best_ai_packet_count = 0usize;
389 let mut best_combat_state: Option<CombatState> = None;
390 let mut best_pc_stats: Option<PcStats> = None;
391
392 for party_member_count in 1..=MAX_PARTY_MEMBER_COUNT {
393 r.seek_to(start_pos)?;
394
395 let perks = match parse_perks(r, party_member_count) {
396 Ok(v) => v,
397 Err(_) => continue,
398 };
399
400 let combat_state = match parse_combat_state(r) {
401 Ok(v) => v,
402 Err(_) => continue,
403 };
404 let after_combat_pos = r.position()?;
405
406 for ai_packet_count in 0..=party_member_count {
407 let pc_stats_pos =
408 after_combat_pos + (ai_packet_count * AI_PACKET_INT_COUNT * 4) as u64;
409 if pc_stats_pos + 32 > file_len {
410 break;
411 }
412
413 r.seek_to(pc_stats_pos)?;
414 let pc_stats = match parse_pc_stats(r) {
415 Ok(v) => v,
416 Err(_) => continue,
417 };
418
419 let post_pc = match parse_post_pc_sections(r) {
420 Ok(v) => v,
421 Err(_) => continue,
422 };
423
424 let score = match score_post_tagged_candidate(
425 &perks,
426 &combat_state,
427 &pc_stats,
428 &post_pc,
429 party_member_count,
430 ai_packet_count,
431 ) {
432 Ok(v) => v,
433 Err(_) => continue,
434 };
435
436 if score > best_score {
437 best_score = score;
438 best_party_count = party_member_count;
439 best_ai_packet_count = ai_packet_count;
440 best_combat_state = Some(combat_state.clone());
441 best_pc_stats = Some(pc_stats.clone());
442 }
443 }
444 }
445
446 if best_score == i32::MIN || best_combat_state.is_none() || best_pc_stats.is_none() {
447 return Err(io::Error::new(
448 io::ErrorKind::InvalidData,
449 "could not detect Fallout 2 handlers 10-13 layout",
450 ));
451 }
452
453 r.seek_to(start_pos)?;
456 let perks = parse_perks(r, best_party_count)?;
457 let h10_end = r.position()?;
458
459 let combat_state = parse_combat_state(r)?;
460 let h11_end = r.position()?;
461
462 r.skip((best_ai_packet_count * AI_PACKET_INT_COUNT * 4) as u64)?;
463 let h12_end = r.position()?;
464
465 let pc_stats = parse_pc_stats(r)?;
466 let h13_end = r.position()?;
467
468 let post_pc = parse_post_pc_sections(r)?;
469 let h17_prefix_end = r.position()?;
470 let h15_end = h13_end + 8;
471 let h16_end = h15_end + 4;
472
473 Ok(PostTaggedSections {
474 perks,
475 combat_state,
476 pc_stats,
477 selected_traits: post_pc.selected_traits,
478 game_difficulty: post_pc.game_difficulty,
479 party_member_count: best_party_count,
480 ai_packet_count: best_ai_packet_count,
481 detection_score: best_score,
482 h10_end,
483 h11_end,
484 h12_end,
485 h13_end,
486 h15_end,
487 h16_end,
488 h17_prefix_end,
489 })
490}
491
492fn parse_perks<R: Read + Seek>(
493 r: &mut BigEndianReader<R>,
494 party_member_count: usize,
495) -> io::Result<[i32; PERK_COUNT]> {
496 if party_member_count == 0 {
497 return Err(io::Error::new(
498 io::ErrorKind::InvalidData,
499 "invalid party member count",
500 ));
501 }
502
503 let perks = r.read_i32_array::<PERK_COUNT>()?;
504 let bytes_to_skip = (party_member_count.saturating_sub(1) * PERK_COUNT * 4) as u64;
505 r.skip(bytes_to_skip)?;
506 Ok(perks)
507}
508
509fn parse_combat_state<R: Read + Seek>(r: &mut BigEndianReader<R>) -> io::Result<CombatState> {
510 let combat_state_flags = r.read_u32()?;
511
512 if (combat_state_flags & 0x01) == 0 {
514 return Ok(CombatState {
515 combat_state_flags,
516 combat_data: None,
517 });
518 }
519
520 let turn_running = r.read_i32()?;
521 let free_move = r.read_i32()?;
522 let exps = r.read_i32()?;
523 let list_com = r.read_i32()?;
524 let list_noncom = r.read_i32()?;
525 let list_total = r.read_i32()?;
526 let dude_cid = r.read_i32()?;
527
528 if list_com < 0 || list_noncom < 0 || !(0..=500).contains(&list_total) {
529 return Err(io::Error::new(
530 io::ErrorKind::InvalidData,
531 "invalid combat list counters",
532 ));
533 }
534 if list_com + list_noncom != list_total {
535 return Err(io::Error::new(
536 io::ErrorKind::InvalidData,
537 "inconsistent combat list counters",
538 ));
539 }
540
541 let combatant_cids = r.read_i32_vec(list_total as usize)?;
542
543 let mut ai_info = Vec::with_capacity(list_total as usize);
544 for _ in 0..list_total {
545 ai_info.push(CombatAiInfo {
546 friendly_dead_id: r.read_i32()?,
547 last_target_id: r.read_i32()?,
548 last_item_id: r.read_i32()?,
549 last_move: r.read_i32()?,
550 });
551 }
552
553 Ok(CombatState {
554 combat_state_flags,
555 combat_data: Some(CombatData {
556 turn_running,
557 free_move,
558 exps,
559 list_com,
560 list_noncom,
561 list_total,
562 dude_cid,
563 combatant_cids,
564 ai_info,
565 }),
566 })
567}
568
569fn parse_pc_stats<R: Read + Seek>(r: &mut BigEndianReader<R>) -> io::Result<PcStats> {
570 let stats = r.read_i32_array::<PC_STAT_COUNT>()?;
571 Ok(PcStats {
572 unspent_skill_points: stats[0],
573 level: stats[1],
574 experience: stats[2],
575 reputation: stats[3],
576 karma: stats[4],
577 })
578}
579
580struct PostPcSections {
581 selected_traits: [i32; TRAITS_MAX_SELECTED_COUNT],
582 game_difficulty: i32,
583}
584
585fn parse_post_pc_sections<R: Read + Seek>(
586 r: &mut BigEndianReader<R>,
587) -> io::Result<PostPcSections> {
588 let trait1 = r.read_i32()?;
590 let trait2 = r.read_i32()?;
591 if !is_trait_value_valid(trait1) || !is_trait_value_valid(trait2) {
592 return Err(io::Error::new(
593 io::ErrorKind::InvalidData,
594 "invalid trait values",
595 ));
596 }
597
598 let _automap_flags = r.read_i32()?;
600
601 let game_difficulty = r.read_i32()?;
603 let _combat_difficulty = r.read_i32()?;
604 let _violence_level = r.read_i32()?;
605 let _target_highlight = r.read_i32()?;
606 let _combat_looks = r.read_i32()?;
607
608 Ok(PostPcSections {
609 selected_traits: [trait1, trait2],
610 game_difficulty,
611 })
612}
613
614fn score_post_tagged_candidate(
615 perks: &[i32; PERK_COUNT],
616 combat_state: &CombatState,
617 pc_stats: &PcStats,
618 post_pc: &PostPcSections,
619 party_member_count: usize,
620 ai_packet_count: usize,
621) -> io::Result<i32> {
622 if !perks.iter().all(|&rank| (-1..=20).contains(&rank)) {
623 return Ok(i32::MIN);
624 }
625
626 if !(1..=99).contains(&pc_stats.level) {
627 return Ok(i32::MIN);
628 }
629 if !(0..=100_000_000).contains(&pc_stats.experience) {
630 return Ok(i32::MIN);
631 }
632 if !(-10_000..=10_000).contains(&pc_stats.reputation) {
633 return Ok(i32::MIN);
634 }
635 if !(-100_000..=100_000).contains(&pc_stats.karma) {
636 return Ok(i32::MIN);
637 }
638
639 let mut score = 50;
640 score -= (party_member_count as i32) / 4;
641 score -= (ai_packet_count as i32) / 2;
642
643 if ai_packet_count <= party_member_count {
644 score += 4;
645 }
646 if combat_state.combat_data.is_none() {
647 score += 2;
648 }
649 if combat_state.combat_state_flags == 0x02 {
650 score += 2;
651 }
652 if pc_stats.unspent_skill_points <= 10_000 {
653 score += 2;
654 }
655 if perks.iter().all(|&rank| rank >= 0) {
656 score += 1;
657 }
658
659 if (0..=2).contains(&post_pc.game_difficulty) {
660 score += 2;
661 }
662 if post_pc.selected_traits[0] == -1 || post_pc.selected_traits[1] == -1 {
663 score += 1;
664 }
665
666 Ok(score)
667}
668
669fn is_trait_value_valid(v: i32) -> bool {
670 v == -1 || (0..TRAIT_COUNT).contains(&v)
671}