Skip to main content

fallout_core/
object.rs

1use std::io::{self, Read, Seek};
2
3use crate::reader::BigEndianReader;
4
5// Object type extracted from PID: (pid >> 24) & 0x0F
6pub const OBJ_TYPE_ITEM: i32 = 0;
7pub const OBJ_TYPE_CRITTER: i32 = 1;
8pub const OBJ_TYPE_MISC: i32 = 5;
9
10pub fn obj_type_from_pid(pid: i32) -> i32 {
11    (pid >> 24) & 0x0F
12}
13
14#[derive(Debug)]
15pub struct GameObject {
16    pub id: i32,
17    pub tile: i32,
18    pub x: i32,
19    pub y: i32,
20    pub sx: i32,
21    pub sy: i32,
22    pub frame: i32,
23    pub rotation: i32,
24    pub fid: i32,
25    pub flags: i32,
26    pub elevation: i32,
27    pub pid: i32,
28    pub cid: i32,
29    pub light_distance: i32,
30    pub light_intensity: i32,
31    pub outline: i32,
32    pub sid: i32,
33    pub script_index: i32,
34    pub inventory_length: i32,
35    pub inventory_capacity: i32,
36    pub object_data: ObjectData,
37    pub inventory: Vec<InventoryItem>,
38}
39
40#[derive(Debug)]
41pub enum ObjectData {
42    Critter(CritterObjectData),
43    Item(ItemObjectData),
44    Scenery(SceneryObjectData),
45    Misc(MiscObjectData),
46    Other,
47}
48
49#[derive(Debug)]
50pub struct CritterObjectData {
51    pub field_0: i32,
52    pub damage_last_turn: i32,
53    pub maneuver: i32,
54    pub ap: i32,
55    pub results: i32,
56    pub ai_packet: i32,
57    pub team: i32,
58    pub who_hit_me_cid: i32,
59    pub hp: i32,
60    pub radiation: i32,
61    pub poison: i32,
62}
63
64#[derive(Debug)]
65pub struct ItemObjectData {
66    pub flags: i32,
67    pub extra_bytes: u8, // 0, 4, or 8
68    pub extra_data: Vec<u8>,
69}
70
71#[derive(Debug)]
72pub struct SceneryObjectData {
73    pub flags: i32,
74}
75
76#[derive(Debug)]
77pub struct MiscObjectData {
78    pub map: i32,
79    pub tile: i32,
80    pub elevation: i32,
81    pub rotation: i32,
82}
83
84#[derive(Debug)]
85pub struct InventoryItem {
86    pub quantity: i32,
87    pub object: GameObject,
88}
89
90impl GameObject {
91    pub fn parse<R: Read + Seek>(r: &mut BigEndianReader<R>) -> io::Result<Self> {
92        // 18 base fields (72 bytes)
93        let id = r.read_i32()?;
94        let tile = r.read_i32()?;
95        let x = r.read_i32()?;
96        let y = r.read_i32()?;
97        let sx = r.read_i32()?;
98        let sy = r.read_i32()?;
99        let frame = r.read_i32()?;
100        let rotation = r.read_i32()?;
101        let fid = r.read_i32()?;
102        let flags = r.read_i32()?;
103        let elevation = r.read_i32()?;
104        let pid = r.read_i32()?;
105        let cid = r.read_i32()?;
106        let light_distance = r.read_i32()?;
107        let light_intensity = r.read_i32()?;
108        let outline = r.read_i32()?;
109        let sid = r.read_i32()?;
110        let script_index = r.read_i32()?;
111
112        // Inventory header (12 bytes)
113        let inventory_length = r.read_i32()?;
114        let inventory_capacity = r.read_i32()?;
115        let _placeholder = r.read_i32()?;
116
117        if !(-1..=1000).contains(&inventory_length) {
118            return Err(io::Error::new(
119                io::ErrorKind::InvalidData,
120                format!(
121                    "invalid inventory length {} for object pid=0x{:08x} at pos={}",
122                    inventory_length,
123                    pid,
124                    r.position().unwrap_or(0)
125                ),
126            ));
127        }
128
129        // Type-specific proto update data
130        let obj_type = obj_type_from_pid(pid);
131        let object_data = match obj_type {
132            OBJ_TYPE_CRITTER => ObjectData::Critter(parse_critter_object_data(r)?),
133            OBJ_TYPE_ITEM => ObjectData::Item(parse_item_object_data(r)?),
134            OBJ_TYPE_MISC => {
135                // Only exit grids (PID 0x5000010..0x5000017) have extra data
136                if (0x500_0010..=0x500_0017).contains(&pid) {
137                    ObjectData::Misc(parse_misc_object_data(r)?)
138                } else {
139                    ObjectData::Other
140                }
141            }
142            _ => {
143                // Scenery, walls, etc.: read the flags field
144                // Without proto files we can't determine the exact subtype,
145                // but all non-critter types write at least the flags field.
146                let scenery_flags = r.read_i32()?;
147                ObjectData::Scenery(SceneryObjectData {
148                    flags: scenery_flags,
149                })
150            }
151        };
152
153        // Fallout saves can contain inventory_length == -1; treat as empty.
154        let normalized_inventory_length = inventory_length.max(0);
155        let mut inventory = Vec::with_capacity(normalized_inventory_length as usize);
156        for _ in 0..normalized_inventory_length {
157            let quantity = r.read_i32()?;
158            let object = GameObject::parse(r)?;
159            inventory.push(InventoryItem { quantity, object });
160        }
161
162        Ok(Self {
163            id,
164            tile,
165            x,
166            y,
167            sx,
168            sy,
169            frame,
170            rotation,
171            fid,
172            flags,
173            elevation,
174            pid,
175            cid,
176            light_distance,
177            light_intensity,
178            outline,
179            sid,
180            script_index,
181            inventory_length,
182            inventory_capacity,
183            object_data,
184            inventory,
185        })
186    }
187
188    pub fn emit_to_vec(&self, out: &mut Vec<u8>) -> io::Result<()> {
189        out.extend_from_slice(&self.id.to_be_bytes());
190        out.extend_from_slice(&self.tile.to_be_bytes());
191        out.extend_from_slice(&self.x.to_be_bytes());
192        out.extend_from_slice(&self.y.to_be_bytes());
193        out.extend_from_slice(&self.sx.to_be_bytes());
194        out.extend_from_slice(&self.sy.to_be_bytes());
195        out.extend_from_slice(&self.frame.to_be_bytes());
196        out.extend_from_slice(&self.rotation.to_be_bytes());
197        out.extend_from_slice(&self.fid.to_be_bytes());
198        out.extend_from_slice(&self.flags.to_be_bytes());
199        out.extend_from_slice(&self.elevation.to_be_bytes());
200        out.extend_from_slice(&self.pid.to_be_bytes());
201        out.extend_from_slice(&self.cid.to_be_bytes());
202        out.extend_from_slice(&self.light_distance.to_be_bytes());
203        out.extend_from_slice(&self.light_intensity.to_be_bytes());
204        out.extend_from_slice(&self.outline.to_be_bytes());
205        out.extend_from_slice(&self.sid.to_be_bytes());
206        out.extend_from_slice(&self.script_index.to_be_bytes());
207
208        let inventory_length = if self.inventory_length < 0 && self.inventory.is_empty() {
209            -1
210        } else {
211            self.inventory.len() as i32
212        };
213        let inventory_capacity = self.inventory_capacity.max(inventory_length.max(0));
214        out.extend_from_slice(&inventory_length.to_be_bytes());
215        out.extend_from_slice(&inventory_capacity.to_be_bytes());
216        out.extend_from_slice(&0i32.to_be_bytes());
217
218        match &self.object_data {
219            ObjectData::Critter(data) => {
220                out.extend_from_slice(&data.field_0.to_be_bytes());
221                out.extend_from_slice(&data.damage_last_turn.to_be_bytes());
222                out.extend_from_slice(&data.maneuver.to_be_bytes());
223                out.extend_from_slice(&data.ap.to_be_bytes());
224                out.extend_from_slice(&data.results.to_be_bytes());
225                out.extend_from_slice(&data.ai_packet.to_be_bytes());
226                out.extend_from_slice(&data.team.to_be_bytes());
227                out.extend_from_slice(&data.who_hit_me_cid.to_be_bytes());
228                out.extend_from_slice(&data.hp.to_be_bytes());
229                out.extend_from_slice(&data.radiation.to_be_bytes());
230                out.extend_from_slice(&data.poison.to_be_bytes());
231            }
232            ObjectData::Item(data) => {
233                if data.extra_data.len() != data.extra_bytes as usize {
234                    return Err(io::Error::new(
235                        io::ErrorKind::InvalidData,
236                        format!(
237                            "item extra data length mismatch: extra_bytes={}, extra_data={}",
238                            data.extra_bytes,
239                            data.extra_data.len()
240                        ),
241                    ));
242                }
243                out.extend_from_slice(&data.flags.to_be_bytes());
244                out.extend_from_slice(&data.extra_data);
245            }
246            ObjectData::Scenery(data) => {
247                out.extend_from_slice(&data.flags.to_be_bytes());
248            }
249            ObjectData::Misc(data) => {
250                out.extend_from_slice(&data.map.to_be_bytes());
251                out.extend_from_slice(&data.tile.to_be_bytes());
252                out.extend_from_slice(&data.elevation.to_be_bytes());
253                out.extend_from_slice(&data.rotation.to_be_bytes());
254            }
255            ObjectData::Other => {}
256        }
257
258        for item in &self.inventory {
259            out.extend_from_slice(&item.quantity.to_be_bytes());
260            item.object.emit_to_vec(out)?;
261        }
262
263        Ok(())
264    }
265
266    pub fn emit_bytes(&self) -> io::Result<Vec<u8>> {
267        let mut out = Vec::new();
268        self.emit_to_vec(&mut out)?;
269        Ok(out)
270    }
271}
272
273fn parse_critter_object_data<R: Read + Seek>(
274    r: &mut BigEndianReader<R>,
275) -> io::Result<CritterObjectData> {
276    Ok(CritterObjectData {
277        field_0: r.read_i32()?,
278        damage_last_turn: r.read_i32()?,
279        maneuver: r.read_i32()?,
280        ap: r.read_i32()?,
281        results: r.read_i32()?,
282        ai_packet: r.read_i32()?,
283        team: r.read_i32()?,
284        who_hit_me_cid: r.read_i32()?,
285        hp: r.read_i32()?,
286        radiation: r.read_i32()?,
287        poison: r.read_i32()?,
288    })
289}
290
291/// Parse item proto update data using probe-and-backtrack.
292///
293/// Without access to .PRO files, we can't know the item subtype directly.
294/// Item subtypes have 0, 4, or 8 extra bytes after the flags field:
295///   - Armor/Container/Drug: 0 extra bytes
296///   - Ammo/Misc/Key: 4 extra bytes
297///   - Weapon: 8 extra bytes
298///
299/// We try each size and pick the one with the best validation score.
300fn parse_item_object_data<R: Read + Seek>(
301    r: &mut BigEndianReader<R>,
302) -> io::Result<ItemObjectData> {
303    let flags = r.read_i32()?;
304    let pos_after_flags = r.position()?;
305
306    // Score each candidate extra data size
307    let mut best_extra = 0u8;
308    let mut best_score = -1i32;
309
310    for extra in [0u8, 4, 8] {
311        r.seek_to(pos_after_flags + extra as u64)?;
312        let score = score_next_data(r)?;
313        if score > best_score {
314            best_score = score;
315            best_extra = extra;
316        }
317    }
318
319    r.seek_to(pos_after_flags)?;
320    let extra_data = r.read_bytes(best_extra as usize)?;
321    Ok(ItemObjectData {
322        flags,
323        extra_bytes: best_extra,
324        extra_data,
325    })
326}
327
328/// Score how well the data at the current position looks like valid
329/// subsequent data (another inventory item or section data).
330///
331/// Returns a score: higher is better.
332///   3 = qty valid, PID type valid, inventory_length valid
333///   2 = qty valid, PID type valid
334///   1 = qty valid only
335///   0 = nothing valid
336fn score_next_data<R: Read + Seek>(r: &mut BigEndianReader<R>) -> io::Result<i32> {
337    let peek_pos = r.position()?;
338
339    // Read what would be the next quantity
340    let next_qty = match r.read_i32() {
341        Ok(v) => v,
342        Err(_) => {
343            r.seek_to(peek_pos)?;
344            return Ok(1); // At EOF, accept minimally
345        }
346    };
347
348    if next_qty <= 0 || next_qty > 10_000 {
349        r.seek_to(peek_pos)?;
350        return Ok(0);
351    }
352
353    let mut score = 1;
354
355    // Check PID at offset 11*4=44 bytes into base fields (after qty)
356    let pid_pos = peek_pos + 4 + 44;
357    if r.seek_to(pid_pos).is_ok()
358        && let Ok(next_pid) = r.read_i32()
359    {
360        let next_type = obj_type_from_pid(next_pid);
361        if (0..=5).contains(&next_type) {
362            score = 2;
363
364            // Also check inventory_length at base + 72 bytes from base start
365            let inv_len_pos = peek_pos + 4 + 72;
366            if r.seek_to(inv_len_pos).is_ok()
367                && let Ok(inv_len) = r.read_i32()
368                && (0..1000).contains(&inv_len)
369            {
370                score = 3;
371            }
372        }
373    }
374
375    r.seek_to(peek_pos)?;
376    Ok(score)
377}
378
379fn parse_misc_object_data<R: Read + Seek>(
380    r: &mut BigEndianReader<R>,
381) -> io::Result<MiscObjectData> {
382    Ok(MiscObjectData {
383        map: r.read_i32()?,
384        tile: r.read_i32()?,
385        elevation: r.read_i32()?,
386        rotation: r.read_i32()?,
387    })
388}
389
390#[cfg(test)]
391mod tests {
392    use std::io::Cursor;
393
394    use super::GameObject;
395    use crate::reader::BigEndianReader;
396
397    #[test]
398    fn parse_object_allows_negative_one_inventory_length() {
399        let mut bytes = Vec::new();
400
401        // 18 base object fields.
402        for v in [
403            1i32, 100, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0x20000001, // PID type 2 (scenery)
404            -1, 0, 0, 0, -1, -1,
405        ] {
406            bytes.extend_from_slice(&v.to_be_bytes());
407        }
408
409        // Inventory header.
410        bytes.extend_from_slice(&(-1i32).to_be_bytes());
411        bytes.extend_from_slice(&(0i32).to_be_bytes());
412        bytes.extend_from_slice(&(0i32).to_be_bytes());
413
414        // Scenery/object flags field.
415        bytes.extend_from_slice(&(0i32).to_be_bytes());
416
417        let mut reader = BigEndianReader::new(Cursor::new(bytes));
418        let parsed = GameObject::parse(&mut reader).expect("object should parse");
419
420        assert_eq!(parsed.inventory_length, -1);
421        assert!(parsed.inventory.is_empty());
422    }
423}