Skip to main content

fallout_core/fallout2/
sections.rs

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
18// --- Handler 1: Player Combat ID ---
19
20pub fn parse_player_combat_id<R: Read + Seek>(r: &mut BigEndianReader<R>) -> io::Result<i32> {
21    r.read_i32()
22}
23
24// --- Handler 2: Game Global Variables ---
25
26pub struct GlobalVarsSection {
27    pub global_vars: Vec<i32>,
28}
29
30/// Auto-detect handler 2 length by validating handler 3 map payload and
31/// handler 4 duplicate globals block.
32pub 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        // Handler 4 duplicates handler 2 exactly.
78        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
95// --- Handler 3: Map Data ---
96
97pub 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
133// --- Handler 5: Player Object ---
134
135pub 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// --- Handler 6: Critter Proto Data ---
152
153#[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
189/// Parse handler 6 by searching around the current offset.
190///
191/// Fallout 2 object inventories can be hard to parse without full proto
192/// metadata, so we anchor on the highly-structured critter proto block.
193pub 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
308// --- Handler 7: Kill Counts ---
309
310pub 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
316// --- Handler 8: Tagged Skills ---
317
318pub 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// --- Handlers 10-13 ---
325
326#[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    // Replay the winning path to leave stream correctly positioned
454    // after handler 13.
455    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    // Bit 0x01 means in-combat.
513    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    // Handler 15: traits
589    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    // Handler 16: automap flags.
599    let _automap_flags = r.read_i32()?;
600
601    // Handler 17: preferences (we only need game difficulty).
602    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}