verticopolis_engine/load/
container.rs1use super::*;
4
5pub(crate) const EXPANDS_TOO_FAR: &str =
7 "this .vctower expands to more data than we will hold; it looks crafted rather than saved";
8
9pub fn decode_vctower(text: &str) -> Result<Value, String> {
13 parse_save_json(&inflate_vctower(text)?).map_err(|e| format!("json: {e}"))
14}
15
16pub(crate) enum InflateError {
18 NotAContainer,
20 ExpandsTooFar,
22 Damaged(String),
24}
25
26pub(crate) fn inflate_vctower(text: &str) -> Result<Vec<u8>, String> {
28 inflate_container(text).map_err(|e| match e {
29 InflateError::NotAContainer => "not a VCTOWER1 file".to_string(),
30 InflateError::ExpandsTooFar => EXPANDS_TOO_FAR.to_string(),
31 InflateError::Damaged(why) => why,
32 })
33}
34
35pub(crate) fn inflate_container(text: &str) -> Result<Vec<u8>, InflateError> {
38 let trimmed = js_trim(text);
39 if !trimmed.starts_with(TOWER_FILE_MAGIC) {
40 return Err(InflateError::NotAContainer);
41 }
42 let packed = lenient_base64(&trimmed[TOWER_FILE_MAGIC.len()..]);
43 use std::io::Read;
44 let mut out = Vec::new();
45 flate2::read::DeflateDecoder::new(&packed[..])
46 .take(MAX_SAVE_INFLATED_BYTES + 1)
47 .read_to_end(&mut out)
48 .map_err(|e| InflateError::Damaged(format!("inflate: {e}")))?;
49 if out.len() as u64 > MAX_SAVE_INFLATED_BYTES {
50 return Err(InflateError::ExpandsTooFar);
51 }
52 if out.starts_with(b"\xEF\xBB\xBF") {
54 out.drain(..3);
55 }
56 Ok(out)
57}
58
59#[doc(hidden)]
60pub fn parse_save_json(text: &[u8]) -> Result<Value, serde_json::Error> {
70 match repair_lone_surrogates(text) {
71 Some(fixed) => serde_json::from_slice(&fixed),
72 None => serde_json::from_slice(text),
73 }
74}
75
76pub(super) fn hex4(text: &[u8], at: usize) -> Option<u16> {
78 let digits = text.get(at..at + 4)?;
79 let s = std::str::from_utf8(digits).ok()?;
80 if !s.bytes().all(|b| b.is_ascii_hexdigit()) {
81 return None;
82 }
83 u16::from_str_radix(s, 16).ok()
84}
85
86pub(crate) fn repair_lone_surrogates(text: &[u8]) -> Option<Vec<u8>> {
90 let mut out: Option<Vec<u8>> = None;
91 let mut copied = 0;
92 let mut in_string = false;
93 let mut i = 0;
94 while i < text.len() {
95 let b = text[i];
96 if !in_string {
97 in_string = b == b'"';
98 i += 1;
99 continue;
100 }
101 match b {
102 b'"' => {
103 in_string = false;
104 i += 1;
105 }
106 b'\\' if text.get(i + 1) == Some(&b'u') => {
107 let Some(unit) = hex4(text, i + 2) else {
108 i += 2;
110 continue;
111 };
112 let paired_low = || {
113 text.get(i + 6) == Some(&b'\\')
114 && text.get(i + 7) == Some(&b'u')
115 && hex4(text, i + 8).is_some_and(|u| (0xDC00..=0xDFFF).contains(&u))
116 };
117 if (0xD800..=0xDBFF).contains(&unit) && paired_low() {
118 i += 12;
119 } else if (0xD800..=0xDFFF).contains(&unit) {
120 let buf = out.get_or_insert_with(|| Vec::with_capacity(text.len()));
121 buf.extend_from_slice(&text[copied..i]);
122 buf.extend_from_slice(b"\\ufffd");
123 i += 6;
124 copied = i;
125 } else {
126 i += 6;
127 }
128 }
129 b'\\' => i += 2,
132 _ => i += 1,
133 }
134 }
135 out.map(|mut buf| {
136 buf.extend_from_slice(&text[copied..]);
137 buf
138 })
139}
140
141pub const TOWER_FILE_MAGIC: &str = "VCTOWER1";
144
145pub fn encode_vctower(save: &Value) -> Result<Vec<u8>, String> {
150 pack_json(&crate::canonical::js_json(save))
151}
152
153pub(crate) fn pack_json(json: &str) -> Result<Vec<u8>, String> {
157 if json.len() as u64 > MAX_SAVE_INFLATED_BYTES {
158 return Err(format!(
159 "This tower's save data is larger than {} MB, the most a save can hold.",
160 MAX_SAVE_INFLATED_BYTES / (1024 * 1024)
161 ));
162 }
163 let packed = crate::fflate::deflate_level1(json.as_bytes());
164 let b64 = base64::engine::general_purpose::STANDARD.encode(packed);
165 let mut out = Vec::with_capacity(TOWER_FILE_MAGIC.len() + b64.len() + 2);
166 out.extend_from_slice(TOWER_FILE_MAGIC.as_bytes());
167 out.push(b'\n');
168 out.extend_from_slice(b64.as_bytes());
169 out.push(b'\n');
170 Ok(out)
171}
172
173pub fn stamp_save(save: &mut Value, app_version: &str, saved_at_ms: f64) -> Result<(), String> {
179 let carries_founder = detect_founder(save);
180 let Value::Object(m) = save else {
181 return Err("not a tower save (the save is not an object)".into());
182 };
183 if !m.get("minutes").is_some_and(Value::is_number)
184 || !m.get("units").is_some_and(Value::is_array)
185 {
186 return Err("not a tower save (no numeric minutes or no units list)".into());
187 }
188 check_stamp(app_version, saved_at_ms)?;
189 if carries_founder {
190 m.insert("founder".into(), json!(true));
191 }
192 m.insert("savedAt".into(), json!(saved_at_ms));
193 m.insert("appVersion".into(), json!(app_version));
194 Ok(())
195}
196
197pub(crate) fn check_stamp(app_version: &str, saved_at_ms: f64) -> Result<(), String> {
200 if !(saved_at_ms.is_finite() && saved_at_ms.abs() <= 8.64e15) {
201 return Err(format!("savedAt {saved_at_ms} is not a time"));
202 }
203 if parse_int(app_version).is_some_and(|v| v < 2.0) {
204 return Err(format!(
205 "appVersion {app_version:?} reads as a build before 2.0, which marks the tower as a founder's"
206 ));
207 }
208 Ok(())
209}
210
211pub fn encode_simulation_vctower(
217 sim: &Simulation,
218 app_version: &str,
219 saved_at_ms: f64,
220) -> Result<Vec<u8>, String> {
221 crate::export_stream::encode_vctower_string(sim.save_text(), app_version, saved_at_ms)
222}
223
224pub(super) fn lenient_base64(text: &str) -> Vec<u8> {
230 let body = text.split('=').next().unwrap_or("");
231 let mut symbols: String = body
232 .chars()
233 .filter_map(|c| match c {
234 'A'..='Z' | 'a'..='z' | '0'..='9' | '+' | '/' => Some(c),
235 '-' => Some('+'),
236 '_' => Some('/'),
237 _ => None,
238 })
239 .collect();
240 if symbols.len() % 4 == 1 {
241 symbols.pop();
242 }
243 base64::engine::GeneralPurpose::new(
247 &base64::alphabet::STANDARD,
248 base64::engine::GeneralPurposeConfig::new()
249 .with_decode_padding_mode(base64::engine::DecodePaddingMode::RequireNone)
250 .with_decode_allow_trailing_bits(true),
251 )
252 .decode(symbols.as_bytes())
253 .unwrap_or_default()
254}
255
256#[doc(hidden)]
257pub fn js_trim(s: &str) -> &str {
261 s.trim_matches(is_js_whitespace)
262}
263
264pub(crate) fn is_js_whitespace(c: char) -> bool {
266 (c.is_whitespace() && c != '\u{85}') || c == '\u{feff}'
267}