satisfactory_save_file/
lib.rs

1//! `SaveFile` represents save files in Satisfactory. Use `SaveFile::parse()` to read save files.
2
3use crate::zlib_reader::ChunkedZLibReader;
4use crate::SessionVisiblity::{SvFriendsOnly, SvInvalid, SvPrivate};
5use anyhow::{Error, Result};
6use byteorder::{LittleEndian as L, ReadBytesExt};
7use chrono::{DateTime, Duration, TimeZone, Utc};
8use std::collections::HashMap;
9use std::convert::TryInto;
10use std::io::{Read, Seek};
11
12pub mod zlib_reader;
13
14/// Satisfactory save file.
15#[derive(Debug, Clone, PartialEq)]
16pub struct SaveFile {
17    pub save_header: i32,
18    pub save_version: i32,
19    pub build_version: i32,
20    pub world_type: String,
21    pub world_properties: WorldProperties,
22    pub session_name: String,
23    pub play_time: Duration,
24    pub save_date: DateTime<Utc>,
25    pub session_visibility: SessionVisiblity,
26    pub editor_object_version: i32,
27    pub mod_meta_data: String,
28    pub is_modded_save: bool,
29    pub save_objects: Vec<SaveObject>,
30}
31
32impl SaveFile {
33    /// Reads satisfactory save file to SaveFile struct.
34    ///
35    /// Save files are stored in `%localappdata%\FactoryGame\Saved\SaveGames\<your id>` and it has a
36    /// `.sav` extension.
37    ///
38    /// Tested with build version 152331.
39    ///
40    /// Do not pass a BufReader. I don't know why this fails with BufReader. Create an issue if you
41    /// figured it out.
42    pub fn parse<R>(file: &mut R) -> Result<SaveFile>
43    where
44        R: Read + Seek,
45    {
46        // https://github.com/Goz3rr/SatisfactorySaveEditor
47        // https://satisfactory.fandom.com/wiki/Save_files (outdated info)
48
49        let mut save_file = SaveFile {
50            save_header: file.read_i32::<L>()?,
51            save_version: file.read_i32::<L>()?,
52            build_version: file.read_i32::<L>()?,
53            world_type: read_string(file)?,
54            world_properties: WorldProperties::parse(&read_string(file)?)?,
55            session_name: read_string(file)?,
56            play_time: Duration::seconds(file.read_i32::<L>()?.try_into()?),
57            save_date: SaveFile::convert_date(file.read_i64::<L>()?),
58            session_visibility: SessionVisiblity::from_u8(file.read_u8()?)?,
59            editor_object_version: file.read_i32::<L>()?,
60            mod_meta_data: read_string(file)?,
61            is_modded_save: file.read_i32::<L>()? > 0,
62            save_objects: Vec::new(),
63        };
64
65        let mut decoder = ChunkedZLibReader::new(file)?;
66        let world_object_count = decoder.read_u32::<L>()?;
67        save_file.save_objects.reserve(world_object_count as usize);
68        for _ in 0..world_object_count {
69            save_file
70                .save_objects
71                .push(SaveObject::parse(&mut decoder)?);
72        }
73        Ok(save_file)
74    }
75
76    fn zero_date() -> DateTime<Utc> {
77        chrono::Utc.ymd(1, 1, 1).and_hms(12, 0, 0)
78    }
79
80    fn convert_date(n: i64) -> DateTime<Utc> {
81        SaveFile::zero_date() + Duration::nanoseconds(n) * 100
82    }
83}
84
85impl Default for SaveFile {
86    fn default() -> Self {
87        Self {
88            save_header: Default::default(),
89            save_version: Default::default(),
90            build_version: Default::default(),
91            world_type: Default::default(),
92            world_properties: Default::default(),
93            session_name: Default::default(),
94            play_time: Duration::zero(),
95            save_date: SaveFile::zero_date(),
96            session_visibility: Default::default(),
97            editor_object_version: Default::default(),
98            mod_meta_data: Default::default(),
99            is_modded_save: Default::default(),
100            save_objects: Default::default(),
101        }
102    }
103}
104
105#[derive(Debug, Clone, PartialEq, Default)]
106pub struct WorldProperties {
107    pub start_loc: String,
108    pub session_name: String,
109    pub visibility: SessionVisiblity,
110}
111
112impl WorldProperties {
113    pub fn parse(s: &str) -> Result<WorldProperties> {
114        let mut map: HashMap<&str, &str> = s
115            .split('?')
116            .skip(1) // Nothing before first "?"
117            .map(|s| {
118                s.split_once("=")
119                    .ok_or_else(|| Error::msg(format!("invalid property: {}", s)))
120            })
121            .collect::<Result<HashMap<&str, &str>>>()?;
122
123        let not_found_error = || Error::msg("property not found");
124        Ok(WorldProperties {
125            start_loc: map
126                .remove("startloc")
127                .ok_or_else(not_found_error)?
128                .to_string(),
129            session_name: map
130                .remove("sessionName")
131                .ok_or_else(not_found_error)?
132                .to_string(),
133            visibility: SessionVisiblity::parse(
134                map.remove("Visibility").ok_or_else(not_found_error)?,
135            )?,
136        })
137    }
138}
139
140#[derive(Debug, Copy, Clone, Eq, PartialEq)]
141pub enum SessionVisiblity {
142    SvPrivate,
143    SvFriendsOnly,
144    SvInvalid,
145}
146
147impl SessionVisiblity {
148    pub fn from_u8(n: u8) -> Result<SessionVisiblity> {
149        Ok(match n {
150            0 => SvPrivate,
151            1 => SvFriendsOnly,
152            2 => SvInvalid,
153            _ => return Err(Error::msg(format!("invalid n: {}", n))),
154        })
155    }
156
157    pub fn parse(s: &str) -> Result<SessionVisiblity> {
158        Ok(match s {
159            "SV_Private" => SvPrivate,
160            "SV_FriendsOnly" => SvFriendsOnly,
161            "SV_Invalid" => SvInvalid,
162            _ => return Err(Error::msg(format!("invalid s: {}", s))),
163        })
164    }
165}
166
167impl Default for SessionVisiblity {
168    fn default() -> Self {
169        SvPrivate
170    }
171}
172
173#[derive(Debug, Clone, PartialEq)]
174pub enum SaveObject {
175    SaveComponent {
176        type_path: String,
177        root_object: String,
178        instance_name: String,
179        parent_entity_name: String,
180    },
181    SaveEntity {
182        type_path: String,
183        root_object: String,
184        instance_name: String,
185        need_transform: bool,
186        rotation: Vector4,
187        position: Vector3,
188        scale: Vector3,
189        was_placed_in_level: bool,
190    },
191}
192
193impl SaveObject {
194    pub fn parse<R>(file: &mut R) -> Result<Self>
195    where
196        R: Read,
197    {
198        let object_type = file.read_i32::<L>()?;
199        Ok(match object_type {
200            0 => SaveObject::SaveComponent {
201                type_path: read_string(file)?,
202                root_object: read_string(file)?,
203                instance_name: read_string(file)?,
204                parent_entity_name: read_string(file)?,
205            },
206            1 => SaveObject::SaveEntity {
207                type_path: read_string(file)?,
208                root_object: read_string(file)?,
209                instance_name: read_string(file)?,
210                need_transform: file.read_i32::<L>()? == 1,
211                rotation: Vector4::parse(file)?,
212                position: Vector3::parse(file)?,
213                scale: Vector3::parse(file)?,
214                was_placed_in_level: file.read_i32::<L>()? == 1,
215            },
216            n => return Err(Error::msg(format!("unknown object type: {}", n))),
217        })
218    }
219}
220
221pub fn read_string<R>(file: &mut R) -> Result<String>
222where
223    R: Read,
224{
225    const MAX_LENGTH: usize = 0x1000;
226    let length_error = || Error::msg("invalid length");
227    let signed_length = file.read_i32::<L>()?;
228
229    Ok(if signed_length < 0 {
230        // Negation fails with minimum i32
231        if signed_length == i32::MIN {
232            return Err(length_error());
233        }
234
235        let mut buffer: Vec<u16> = Vec::new();
236        let length = ((-signed_length) as usize).saturating_sub(1) / 2;
237        if length > MAX_LENGTH {
238            return Err(length_error());
239        }
240        buffer.resize(length, 0);
241        file.read_u16_into::<L>(&mut buffer)?;
242        String::from_utf16_lossy(&buffer)
243    } else {
244        let mut buffer: Vec<u8> = Vec::new();
245        let length = (signed_length as usize).saturating_sub(1);
246        if length > MAX_LENGTH {
247            return Err(length_error());
248        }
249        buffer.resize(length, b'\0');
250        file.read_exact(&mut buffer)?;
251        if length > 0 {
252            // Skip null char
253            file.read_u8()?;
254        }
255        String::from_utf8_lossy(&buffer).into_owned()
256    })
257}
258
259#[derive(Debug, Default, Copy, Clone, PartialEq, PartialOrd)]
260pub struct Vector2 {
261    pub x: f32,
262    pub y: f32,
263}
264
265impl Vector2 {
266    pub fn parse<R>(file: &mut R) -> Result<Self>
267    where
268        R: Read,
269    {
270        Ok(Self {
271            x: file.read_f32::<L>()?,
272            y: file.read_f32::<L>()?,
273        })
274    }
275}
276
277#[derive(Debug, Default, Copy, Clone, PartialEq, PartialOrd)]
278pub struct Vector3 {
279    pub x: f32,
280    pub y: f32,
281    pub z: f32,
282}
283
284impl Vector3 {
285    pub fn parse<R>(file: &mut R) -> Result<Self>
286    where
287        R: Read,
288    {
289        Ok(Self {
290            x: file.read_f32::<L>()?,
291            y: file.read_f32::<L>()?,
292            z: file.read_f32::<L>()?,
293        })
294    }
295}
296
297#[derive(Debug, Default, Copy, Clone, PartialEq, PartialOrd)]
298pub struct Vector4 {
299    pub x: f32,
300    pub y: f32,
301    pub z: f32,
302    pub w: f32,
303}
304
305impl Vector4 {
306    pub fn parse<R>(file: &mut R) -> Result<Self>
307    where
308        R: Read,
309    {
310        Ok(Self {
311            x: file.read_f32::<L>()?,
312            y: file.read_f32::<L>()?,
313            z: file.read_f32::<L>()?,
314            w: file.read_f32::<L>()?,
315        })
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use super::*;
322    use std::fs::File;
323    use std::io::{BufReader, Cursor};
324    use std::iter::once;
325
326    #[test]
327    fn parse() {
328        env_logger::builder().is_test(true).try_init().unwrap();
329        let mut file = File::open("test_files/new_world.sav").unwrap();
330        let save_file = SaveFile::parse(&mut file).unwrap();
331        assert_eq!(save_file.save_header, 8);
332        assert_eq!(save_file.save_version, 25);
333        assert_eq!(save_file.build_version, 152331);
334        assert_eq!(save_file.world_type, "Persistent_Level");
335        assert_eq!(save_file.session_name, "test_file");
336        assert_eq!(save_file.save_objects.len(), 13920);
337        assert!(matches!(
338            &save_file.save_objects[0],
339            SaveObject::SaveEntity { type_path, .. }
340                if type_path == "/Script/FactoryGame.FGFoliageRemoval"
341        ));
342
343        SaveFile::parse(&mut File::open("test_files/test_save2.sav").unwrap()).unwrap();
344
345        // Demonstrates how it fails when reading from BufReader
346        let file = File::open("test_files/new_world.sav").unwrap();
347        assert!(SaveFile::parse(&mut BufReader::new(file)).is_err());
348    }
349
350    #[test]
351    fn world_properties() {
352        assert!(WorldProperties::parse("").is_err());
353        let string = "?startloc=Grass Fields?sessionName=test_file?Visibility=SV_Private";
354        let result = WorldProperties::parse(string).unwrap();
355        assert_eq!(result.start_loc, "Grass Fields");
356        assert_eq!(result.session_name, "test_file");
357        assert_eq!(result.visibility, SessionVisiblity::SvPrivate);
358    }
359
360    fn to_encoding(b: &[u8]) -> Vec<u8> {
361        (b.len() as i32 + 1) // length prefix
362            .to_le_bytes()
363            .iter()
364            .chain(b.iter()) // data
365            .chain(once(&b'\0')) // null terminator
366            .copied()
367            .collect()
368    }
369
370    #[test]
371    fn test_read_string() {
372        {
373            // Empty file
374            assert!(read_string(&mut &Vec::new()[..]).is_err());
375        }
376        {
377            // Just the prefix
378            let mut data = &0_i32.to_le_bytes()[..];
379            assert_eq!(read_string(&mut data).unwrap(), "");
380        }
381
382        // Invalid lengths
383        let cases: &[&[u8]] = &[
384            &i32::MIN.to_le_bytes()[..],
385            &(i32::MIN + 1).to_le_bytes()[..],
386            &i32::MAX.to_le_bytes()[..],
387        ];
388        for data in cases {
389            assert!(read_string(&mut &data.to_vec()[..]).is_err());
390        }
391
392        // Various strings
393        for test_string in &["", "a", "abc"] {
394            let encoded = to_encoding(test_string.as_bytes());
395            assert_eq!(read_string(&mut encoded.as_slice()).unwrap(), *test_string);
396        }
397        {
398            // UTF-16
399            let test_string = "abc";
400            let utf16: Vec<u16> = test_string.encode_utf16().collect();
401            let mut utf16_bytes: Vec<u8> = Vec::new();
402            for n in utf16 {
403                utf16_bytes.extend_from_slice(&n.to_le_bytes());
404            }
405            let encoded: Vec<u8> = (-(utf16_bytes.len() as i32 + 2))
406                .to_le_bytes()
407                .iter()
408                .chain(utf16_bytes.iter())
409                .chain([b'\0', b'\0'].iter())
410                .copied()
411                .collect();
412            assert_eq!(read_string(&mut encoded.as_slice()).unwrap(), test_string);
413        }
414    }
415}