Skip to main content

sora_export/
binary.rs

1use sora_diagnostics::{Result, SoraError};
2
3mod encoder;
4
5use crate::{
6    exporter::{DataExporter, ExportOutput, ExportRequest, OutputKind},
7    fs_util::{create_dir_all, write_file},
8};
9
10use self::encoder::BinaryEncoder;
11
12pub struct BinaryBundleExporter;
13
14impl DataExporter for BinaryBundleExporter {
15    fn format_name(&self) -> &'static str {
16        "binary"
17    }
18
19    fn output_kind(&self) -> OutputKind {
20        OutputKind::File
21    }
22
23    fn export(&self, request: ExportRequest<'_>) -> Result<()> {
24        let ExportOutput::File(path) = request.output else {
25            return Err(SoraError::InvalidExportOutput {
26                format: self.format_name().to_owned(),
27                expected: "file",
28            });
29        };
30
31        if let Some(parent) = path.parent() {
32            create_dir_all(parent)?;
33        }
34
35        let bundle = BinaryEncoder::new(request.ir, request.data, request.options.compression)
36            .encode(request.execution)?;
37
38        write_file(path, bundle)
39    }
40}
41
42#[cfg(test)]
43mod tests {
44    use super::*;
45    use crate::exporter::ExportOutput;
46    use sora_data::model::{ConfigData, RowData, TableData, Value};
47    use sora_ir::{model::ConfigIr, normalize::normalize_schema};
48    use sora_schema::model::SchemaFile;
49    use std::{
50        collections::BTreeMap,
51        fs,
52        path::PathBuf,
53        sync::atomic::{AtomicUsize, Ordering},
54        time::{SystemTime, UNIX_EPOCH},
55    };
56
57    #[test]
58    fn binary_bundle_has_expected_header() {
59        let ir = example_ir();
60        let data = example_data();
61        let path = temp_dir().join("config.sora");
62
63        BinaryBundleExporter
64            .export(ExportRequest {
65                ir: &ir,
66                data: &data,
67                locale_catalog: None,
68                execution: &sora_execution::ExecutionContext::default(),
69                options: Default::default(),
70                output: ExportOutput::File(path.clone()),
71            })
72            .unwrap();
73
74        let bytes = fs::read(&path).unwrap();
75        assert_eq!(&bytes[0..4], b"SORA");
76        assert_eq!(u32::from_le_bytes(bytes[4..8].try_into().unwrap()), 1);
77        assert_eq!(u32::from_le_bytes(bytes[8..12].try_into().unwrap()), 24);
78        assert!(u32::from_le_bytes(bytes[12..16].try_into().unwrap()) > 0);
79        assert_eq!(u32::from_le_bytes(bytes[16..20].try_into().unwrap()), 4);
80
81        let sections = read_sections(&bytes);
82        assert_eq!(sections[0].kind, 0);
83        assert_eq!(sections[0].compression, 0);
84        assert_eq!(sections[0].name, "$manifest");
85        assert_eq!(sections[0].len, sections[0].uncompressed_len);
86        let manifest: serde_json::Value = serde_json::from_slice(
87            &bytes[sections[0].offset..sections[0].offset + sections[0].len],
88        )
89        .unwrap();
90        assert_eq!(manifest["format_version"], 1);
91        assert_eq!(manifest["package"], "game_config");
92        assert_eq!(manifest["tables"][0]["name"], "Item");
93        assert_eq!(manifest["tables"][0]["rows"], 1);
94        assert!(manifest["schema_fingerprint"].as_str().unwrap().len() > 8);
95        assert!(manifest["data_fingerprint"].as_str().unwrap().len() > 8);
96
97        assert_eq!(sections[1].kind, 1);
98        assert_eq!(sections[1].compression, 0);
99        assert_eq!(sections[1].name, "$schema");
100        assert_eq!(sections[1].len, sections[1].uncompressed_len);
101        assert_eq!(sections[2].kind, 3);
102        assert_eq!(sections[2].compression, 0);
103        assert_eq!(sections[2].name, "$strings");
104        assert_eq!(sections[2].len, sections[2].uncompressed_len);
105        let strings_payload = &bytes[sections[2].offset..sections[2].offset + sections[2].len];
106        let (string_count, cursor) = read_var_u32(strings_payload, 0);
107        let (string_len, cursor) = read_var_u32(strings_payload, cursor);
108        assert_eq!(string_count, 1);
109        assert_eq!(string_len, 10);
110        assert_eq!(
111            &strings_payload[cursor..cursor + string_len as usize],
112            b"Iron Sword"
113        );
114        assert_eq!(sections[3].kind, 2);
115        assert_eq!(sections[3].compression, 0);
116        assert_eq!(sections[3].name, "Item");
117        assert_eq!(sections[3].len, sections[3].uncompressed_len);
118
119        let table_payload = &bytes[sections[3].offset..sections[3].offset + sections[3].len];
120        assert_eq!(read_u32(table_payload, 0), 1);
121        assert_eq!(read_u32(table_payload, 4), 0);
122        assert_eq!(read_u32(table_payload, 8), 3);
123        let (id, cursor) = read_var_i32(table_payload, 12);
124        let (name_id, cursor) = read_var_u32(table_payload, cursor);
125        assert_eq!(id, 1001);
126        assert_eq!(name_id, 0);
127        assert_eq!(cursor, table_payload.len());
128
129        let _ = fs::remove_dir_all(path.parent().unwrap());
130    }
131
132    #[test]
133    fn binary_bundle_can_compress_sections_with_zstd() {
134        let ir = example_ir();
135        let data = example_data();
136        let path = temp_dir().join("config.sora");
137
138        BinaryBundleExporter
139            .export(ExportRequest {
140                ir: &ir,
141                data: &data,
142                locale_catalog: None,
143                execution: &sora_execution::ExecutionContext::default(),
144                options: crate::exporter::ExportOptions {
145                    compression: crate::exporter::ExportCompression::Zstd { level: 3 },
146                    locale: None,
147                },
148                output: ExportOutput::File(path.clone()),
149            })
150            .unwrap();
151
152        let bytes = fs::read(&path).unwrap();
153        let sections = read_sections(&bytes);
154        assert_eq!(sections[0].compression, 0);
155        assert_eq!(sections[1].compression, 0);
156        assert_eq!(sections[2].compression, 1);
157        assert_eq!(sections[3].compression, 1);
158        assert!(sections[2].len > 0);
159        assert!(sections[3].len > 0);
160        assert_ne!(sections[2].len, sections[2].uncompressed_len);
161        assert_ne!(sections[3].len, sections[3].uncompressed_len);
162
163        let _ = fs::remove_dir_all(path.parent().unwrap());
164    }
165
166    fn example_ir() -> ConfigIr {
167        let schema: SchemaFile = toml::from_str(
168            r#"
169package = "game_config"
170
171[[tables]]
172name = "Item"
173mode = "map"
174key = "id"
175
176[[tables.fields]]
177name = "id"
178type = "i32"
179
180[[tables.fields]]
181name = "name"
182type = "string"
183"#,
184        )
185        .unwrap();
186        normalize_schema(schema).unwrap()
187    }
188
189    fn example_data() -> ConfigData {
190        ConfigData {
191            tables: vec![TableData {
192                name: "Item".to_owned(),
193                rows: vec![RowData {
194                    values: BTreeMap::from([
195                        ("id".to_owned(), Value::Integer(1001)),
196                        ("name".to_owned(), Value::String("Iron Sword".to_owned())),
197                    ]),
198                }],
199            }],
200        }
201    }
202
203    fn temp_dir() -> PathBuf {
204        static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
205        let unique = SystemTime::now()
206            .duration_since(UNIX_EPOCH)
207            .unwrap()
208            .as_nanos();
209        let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
210        std::env::temp_dir().join(format!("sora-export-binary-test-{unique}-{id}"))
211    }
212
213    #[derive(Debug)]
214    struct TestSection {
215        kind: u32,
216        compression: u32,
217        name: String,
218        offset: usize,
219        len: usize,
220        uncompressed_len: usize,
221    }
222
223    fn read_sections(bytes: &[u8]) -> Vec<TestSection> {
224        let directory_len = read_u32(bytes, 12) as usize;
225        let section_count = read_u32(bytes, 16) as usize;
226        let mut cursor = 24;
227        let directory_end = cursor + directory_len;
228        let mut sections = Vec::new();
229        while cursor < directory_end {
230            let kind = read_u32(bytes, cursor);
231            let compression = read_u32(bytes, cursor + 4);
232            let name_len = read_u32(bytes, cursor + 8) as usize;
233            let offset = read_u32(bytes, cursor + 16) as usize;
234            let len = read_u32(bytes, cursor + 20) as usize;
235            let uncompressed_len = read_u32(bytes, cursor + 24) as usize;
236            let name_start = cursor + 28;
237            let name = std::str::from_utf8(&bytes[name_start..name_start + name_len])
238                .unwrap()
239                .to_owned();
240            sections.push(TestSection {
241                kind,
242                compression,
243                name,
244                offset,
245                len,
246                uncompressed_len,
247            });
248            cursor = name_start + name_len;
249        }
250        assert_eq!(sections.len(), section_count);
251        sections
252    }
253
254    fn read_u32(bytes: &[u8], offset: usize) -> u32 {
255        u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())
256    }
257
258    fn read_var_u32(bytes: &[u8], mut offset: usize) -> (u32, usize) {
259        let mut value = 0u32;
260        let mut shift = 0;
261        loop {
262            let byte = bytes[offset];
263            offset += 1;
264            value |= u32::from(byte & 0x7f) << shift;
265            if byte & 0x80 == 0 {
266                return (value, offset);
267            }
268            shift += 7;
269        }
270    }
271
272    fn read_var_i32(bytes: &[u8], offset: usize) -> (i32, usize) {
273        let (value, offset) = read_var_u32(bytes, offset);
274        (((value >> 1) as i32) ^ (-((value & 1) as i32)), offset)
275    }
276}