Skip to main content
Verticopolis engineGitHub

verticopolis_engine/load/
container.rs

1//! The `.vctower` container: base64 and compression framing, the stamp, encode and decode.
2
3use super::*;
4
5/// The reason a `.vctower` is refused for inflating past the size cap.
6pub(crate) const EXPANDS_TOO_FAR: &str =
7    "this .vctower expands to more data than we will hold; it looks crafted rather than saved";
8
9/// Reads a `.vctower` file's text into its save JSON, ready for
10/// [`deserialize`]. Refuses text that is not a `.vctower` file, and one that
11/// would inflate past 32 MiB.
12pub fn decode_vctower(text: &str) -> Result<Value, String> {
13    parse_save_json(&inflate_vctower(text)?).map_err(|e| format!("json: {e}"))
14}
15
16/// Why the save text could not be taken out of a `.vctower` container.
17pub(crate) enum InflateError {
18    /// The text does not start with the `VCTOWER1` stamp.
19    NotAContainer,
20    /// The text inflates past the size cap.
21    ExpandsTooFar,
22    /// The packed data is not valid deflate.
23    Damaged(String),
24}
25
26/// The save text inside a VCTOWER1 container, as `decode_vctower` parses it.
27pub(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
35/// `inflate_vctower` with the reason as a variant, for a caller that words
36/// each refusal itself.
37pub(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    // `TextDecoder` drops a leading byte order mark before `JSON.parse` sees it.
53    if out.starts_with(b"\xEF\xBB\xBF") {
54        out.drain(..3);
55    }
56    Ok(out)
57}
58
59#[doc(hidden)]
60/// `JSON.parse` for save text that a JavaScript host wrote. A JavaScript
61/// string can hold half of a surrogate pair (a name cut at a UTF-16 length,
62/// for one), and `JSON.stringify` writes it as a `\udxxx` escape that
63/// `serde_json` refuses, since a Rust string cannot hold it. Each such escape
64/// becomes U+FFFD, the character `TextEncoder` substitutes for an unpaired
65/// surrogate, so the save
66/// loads with a replacement character where the half character was. Paired
67/// escapes, every other escape and every byte outside a string pass through
68/// unchanged; text with no lone surrogate escape is parsed as given.
69pub 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
76/// The value of the four hex digits at `at`, if they are four hex digits.
77pub(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
86/// The text with every lone surrogate escape inside a string rewritten to
87/// `\ufffd`, or `None` when there is none (the common case, which then
88/// costs one scan and no copy).
89pub(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                    // A malformed escape: leave it for the parser to refuse.
109                    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            // Any other escape is two bytes; skipping both keeps an escaped
130            // quote or backslash from being read as the end of the string.
131            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
141/// The first line of every `.vctower` file, naming the format and its
142/// version.
143pub const TOWER_FILE_MAGIC: &str = "VCTOWER1";
144
145/// Writes save JSON as `.vctower` file bytes, as given. To write a player's
146/// tower, stamp it first with [`stamp_save`], or use
147/// [`encode_simulation_vctower`], which does both: a save with no stamp loads
148/// as a founder's tower. Refuses a save larger than a loader accepts.
149pub fn encode_vctower(save: &Value) -> Result<Vec<u8>, String> {
150    pack_json(&crate::canonical::js_json(save))
151}
152
153/// The container around a save's JSON text: the size check, the deflate and
154/// the base64 framing of [`encode_vctower`], shared with the streamed writer
155/// (`export_stream`) so the two cannot frame differently.
156pub(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
173/// Stamps save JSON with the app that wrote it and when, as the web game does
174/// before writing a file. `app_version` names your host and its version (it
175/// must not read as a build older than 2.0, which the web game takes for a
176/// founder's tower); `saved_at_ms` is the time in milliseconds since the Unix
177/// epoch. Refuses JSON that is not a tower.
178pub 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
197/// The two checks on a stamp's own values, which [`stamp_save`] runs after
198/// the tower's: the time and the build name.
199pub(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
211/// Writes a game as `.vctower` file bytes: [`Simulation::serialize`],
212/// [`stamp_save`] and [`encode_vctower`] in one call, without holding the
213/// whole save in memory at once. The engine has no clock or version of its
214/// own, so pass your host's name and version and the time in milliseconds
215/// since the Unix epoch.
216pub 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
224/// `Buffer.from(b64, "base64")`: decoding stops at the first `=`, every
225/// character outside the alphabet (whitespace included) is skipped, the
226/// URL-safe alphabet is taken alongside the standard one, padding is
227/// optional, a dangling single symbol is dropped, and the last symbol's
228/// unused bits are ignored.
229pub(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    // With the alphabet filtered, the padding cut, the dangling symbol
244    // dropped and trailing bits allowed, no input is left that the decoder
245    // refuses; like `Buffer.from`, garbage simply inflates to garbage.
246    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)]
257/// `String.prototype.trim`: strips WhiteSpace and LineTerminator. Compared
258/// with Rust's `is_whitespace`, JavaScript also counts U+FEFF and does not
259/// count U+0085 (NEL).
260pub fn js_trim(s: &str) -> &str {
261    s.trim_matches(is_js_whitespace)
262}
263
264/// Whether JavaScript's `trim` strips the character (see `js_trim`).
265pub(crate) fn is_js_whitespace(c: char) -> bool {
266    (c.is_whitespace() && c != '\u{85}') || c == '\u{feff}'
267}