Skip to main content

rgpre/import/
savegame.rs

1//! Lossless save containers and read-only save-slot directories.
2
3use crate::{Result, error::Error};
4use std::collections::BTreeMap;
5use std::path::Path;
6
7/// Slot metadata from the version 1.31 SVGM payload.
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub struct SaveMetadata {
10    pub version: [u8; 4],
11    pub unknown_04: u32,
12    pub unknown_08: u32,
13    /// NUL-terminated display text, decoded with UTF-8 replacement characters.
14    pub description: String,
15    pub world_id: u32,
16    /// Stored bitmap path; the loader never opens this path.
17    pub bitmap_filename: String,
18}
19
20/// One chunk, including its original payload without interpretation.
21#[derive(Debug, Clone, PartialEq, Eq)]
22pub struct SaveSection {
23    pub name: [u8; 4],
24    pub data: Vec<u8>,
25}
26
27/// A SAV or TSG container. Sections retain their order and original bytes.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub struct SaveGame {
30    pub metadata: SaveMetadata,
31    /// Includes SVGM; excludes the payload-free END terminator.
32    pub sections: Vec<SaveSection>,
33}
34
35/// A loaded slot, without modifying installed files or restoring engine state.
36#[derive(Debug, Clone, PartialEq, Eq)]
37pub struct SaveSlot {
38    pub game: SaveGame,
39    /// Per-map snapshots keyed by uppercase filename, including `.TSG`.
40    pub maps: BTreeMap<String, SaveGame>,
41    /// Optional LOGBOOK.TXT bytes, retaining its original encoding and line endings.
42    pub logbook: Option<Vec<u8>>,
43}
44
45/// Parse version 1.31 SAVEGAME.SAV or a per-map TSG snapshot.
46/// Validates the container and metadata, not the opaque runtime records.
47pub fn parse_savegame_file(mut bytes: &[u8]) -> Result<SaveGame> {
48    let invalid = |message: &str| Error::Parse(format!("save game: {message}"));
49    let mut metadata = None;
50    let mut sections = Vec::new();
51    loop {
52        if bytes.starts_with(b"END ") {
53            if bytes.len() != 4 {
54                return Err(invalid("trailing bytes after END"));
55            }
56            return Ok(SaveGame {
57                metadata: metadata.ok_or_else(|| invalid("missing SVGM metadata"))?,
58                sections,
59            });
60        }
61        let header = bytes
62            .get(..8)
63            .ok_or_else(|| invalid("truncated chunk header or missing END"))?;
64        let name: [u8; 4] = header[..4].try_into().unwrap();
65        let size = usize::try_from(u32::from_be_bytes(header[4..8].try_into().unwrap()))
66            .map_err(|_| invalid("chunk length exceeds address space"))?;
67        bytes = &bytes[8..];
68        let data = bytes
69            .get(..size)
70            .ok_or_else(|| invalid("chunk payload exceeds remaining file bytes"))?;
71        if sections.is_empty() && name != *b"SVGM" {
72            return Err(invalid("first chunk must be SVGM"));
73        }
74        if name == *b"SVGM" {
75            if metadata.is_some() {
76                return Err(invalid("duplicate SVGM metadata"));
77            }
78            metadata = Some(parse_metadata(data)?);
79        }
80        sections.push(SaveSection {
81            name,
82            data: data.to_vec(),
83        });
84        bytes = &bytes[size..];
85    }
86}
87
88/// Load a SAVEGAME.NNN directory, matching resource names case-insensitively.
89/// Requires SAVEGAME.SAV; TSG files and LOGBOOK.TXT are optional. Unrelated files
90/// are ignored. Duplicate resource names and non-regular resources are rejected.
91pub fn load_save_slot(path: impl AsRef<Path>) -> Result<SaveSlot> {
92    let path = path.as_ref();
93    let file_error = |path: &Path, message: String| Error::File {
94        path: path.to_path_buf(),
95        message,
96    };
97    let entries = std::fs::read_dir(path).map_err(|e| file_error(path, e.to_string()))?;
98    let mut resources = BTreeMap::new();
99    for entry in entries {
100        let entry = entry.map_err(|e| file_error(path, e.to_string()))?;
101        let Some(name) = entry.file_name().to_str().map(str::to_ascii_uppercase) else {
102            continue;
103        };
104        if name != "SAVEGAME.SAV" && name != "LOGBOOK.TXT" && !name.ends_with(".TSG") {
105            continue;
106        }
107        let resource_path = entry.path();
108        let kind = entry
109            .file_type()
110            .map_err(|e| file_error(&resource_path, e.to_string()))?;
111        if !kind.is_file() {
112            return Err(file_error(&resource_path, "expected a regular file".into()));
113        }
114        if resources.insert(name, resource_path.clone()).is_some() {
115            return Err(file_error(
116                &resource_path,
117                "duplicate resource filename".into(),
118            ));
119        }
120    }
121    let read = |path: &Path| std::fs::read(path).map_err(|e| file_error(path, e.to_string()));
122    let parse = |path: &Path| {
123        parse_savegame_file(&read(path)?).map_err(|e| file_error(path, e.to_string()))
124    };
125    let game_path = resources
126        .remove("SAVEGAME.SAV")
127        .ok_or_else(|| file_error(path, "missing SAVEGAME.SAV".into()))?;
128    let game = parse(&game_path)?;
129    let logbook = resources
130        .remove("LOGBOOK.TXT")
131        .map(|path| read(&path))
132        .transpose()?;
133    let maps = resources
134        .into_iter()
135        .map(|(name, path)| Ok((name, parse(&path)?)))
136        .collect::<Result<_>>()?;
137    Ok(SaveSlot {
138        game,
139        maps,
140        logbook,
141    })
142}
143
144/// Encode SAV or TSG sections without changing payload bytes, order, or duplicate
145/// non-metadata chunks. Pass `SaveGame::sections` for a lossless round trip.
146/// The sections include SVGM and exclude the END terminator. Runtime payloads
147/// remain opaque; encoding does not establish that the engine can restore them.
148pub fn encode_savegame_file(sections: &[SaveSection]) -> Result<Vec<u8>> {
149    let invalid = |message: &str| Error::Parse(format!("save game: {message}"));
150    let first = sections
151        .first()
152        .ok_or_else(|| invalid("missing SVGM metadata"))?;
153    if first.name != *b"SVGM" {
154        return Err(invalid("first chunk must be SVGM"));
155    }
156    parse_metadata(&first.data)?;
157    let mut length = 4usize;
158    for (index, section) in sections.iter().enumerate() {
159        if section.name == *b"END " {
160            return Err(invalid("END is a terminator, not a payload section"));
161        }
162        if index != 0 && section.name == *b"SVGM" {
163            return Err(invalid("duplicate SVGM metadata"));
164        }
165        u32::try_from(section.data.len()).map_err(|_| invalid("chunk length exceeds u32"))?;
166        length = length
167            .checked_add(8)
168            .and_then(|n| n.checked_add(section.data.len()))
169            .ok_or_else(|| invalid("container length exceeds address space"))?;
170    }
171    let mut bytes = Vec::new();
172    bytes
173        .try_reserve_exact(length)
174        .map_err(|e| Error::Conversion(format!("save game allocation: {e}")))?;
175    for section in sections {
176        bytes.extend_from_slice(&section.name);
177        bytes.extend_from_slice(&(section.data.len() as u32).to_be_bytes());
178        bytes.extend_from_slice(&section.data);
179    }
180    bytes.extend_from_slice(b"END ");
181    Ok(bytes)
182}
183
184fn parse_metadata(data: &[u8]) -> Result<SaveMetadata> {
185    let invalid = |message: &str| Error::Parse(format!("save game: {message}"));
186    if data.len() != 272 {
187        return Err(invalid("SVGM payload must be 272 bytes"));
188    }
189    if &data[..4] != b"1.31" {
190        return Err(invalid("unsupported save version; expected 1.31"));
191    }
192    let text = |raw: &[u8]| {
193        let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len());
194        String::from_utf8_lossy(&raw[..end]).into_owned()
195    };
196    let number = |offset| u32::from_le_bytes(data[offset..offset + 4].try_into().unwrap());
197    Ok(SaveMetadata {
198        version: data[..4].try_into().unwrap(),
199        unknown_04: number(4),
200        unknown_08: number(8),
201        description: text(&data[12..140]),
202        world_id: number(140),
203        bitmap_filename: text(&data[144..272]),
204    })
205}
206
207/// SVGF values followed by the native flag-state bytes in installed flag order.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct SaveFlags {
210    pub values: Vec<i32>,
211    /// Retained as bytes to preserve the original representation exactly.
212    pub states: Vec<u8>,
213}
214
215/// Decode one SVGF payload. The count must account for every remaining byte.
216pub fn parse_save_flags(data: &[u8]) -> Result<SaveFlags> {
217    let invalid = || Error::Parse("save game SVGF: invalid count or payload length".into());
218    let count = u32::from_le_bytes(data.get(..4).ok_or_else(invalid)?.try_into().unwrap());
219    let count = usize::try_from(count).map_err(|_| invalid())?;
220    let expected = count
221        .checked_mul(5)
222        .and_then(|n| n.checked_add(4))
223        .ok_or_else(invalid)?;
224    if data.len() != expected {
225        return Err(invalid());
226    }
227    let end = 4 + count * 4;
228    Ok(SaveFlags {
229        values: data[4..end]
230            .as_chunks::<4>()
231            .0
232            .iter()
233            .map(|value| i32::from_le_bytes(*value))
234            .collect(),
235        states: data[end..].to_vec(),
236    })
237}
238
239/// Encode one SVGF payload, requiring one state byte for each flag value.
240pub fn encode_save_flags(flags: &SaveFlags) -> Result<Vec<u8>> {
241    if flags.values.len() != flags.states.len() {
242        return Err(Error::Parse(
243            "save game SVGF: flag and state counts differ".into(),
244        ));
245    }
246    let count = u32::try_from(flags.values.len())
247        .map_err(|_| Error::Parse("save game SVGF: count exceeds u32".into()))?;
248    let length = flags
249        .values
250        .len()
251        .checked_mul(5)
252        .and_then(|n| n.checked_add(4))
253        .ok_or_else(|| Error::Parse("save game SVGF: payload exceeds address space".into()))?;
254    let mut data = Vec::new();
255    data.try_reserve_exact(length)
256        .map_err(|e| Error::Conversion(format!("save game SVGF allocation: {e}")))?;
257    data.extend_from_slice(&count.to_le_bytes());
258    for value in &flags.values {
259        data.extend_from_slice(&value.to_le_bytes());
260    }
261    data.extend_from_slice(&flags.states);
262    Ok(data)
263}