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                execution: &sora_execution::ExecutionContext::default(),
68                options: Default::default(),
69                output: ExportOutput::File(path.clone()),
70            })
71            .unwrap();
72
73        let bytes = fs::read(&path).unwrap();
74        assert_eq!(&bytes[0..4], b"SORA");
75        assert_eq!(u32::from_le_bytes(bytes[4..8].try_into().unwrap()), 1);
76        assert_eq!(u32::from_le_bytes(bytes[8..12].try_into().unwrap()), 24);
77        assert!(u32::from_le_bytes(bytes[12..16].try_into().unwrap()) > 0);
78        assert_eq!(u32::from_le_bytes(bytes[16..20].try_into().unwrap()), 4);
79
80        let sections = read_sections(&bytes);
81        assert_eq!(sections[0].kind, 0);
82        assert_eq!(sections[0].compression, 0);
83        assert_eq!(sections[0].name, "$manifest");
84        assert_eq!(sections[0].len, sections[0].uncompressed_len);
85        let manifest: serde_json::Value = serde_json::from_slice(
86            &bytes[sections[0].offset..sections[0].offset + sections[0].len],
87        )
88        .unwrap();
89        assert_eq!(manifest["format_version"], 1);
90        assert_eq!(manifest["package"], "game_config");
91        assert_eq!(manifest["tables"][0]["name"], "Item");
92        assert_eq!(manifest["tables"][0]["rows"], 1);
93        assert!(manifest["schema_fingerprint"].as_str().unwrap().len() > 8);
94        assert!(manifest["data_fingerprint"].as_str().unwrap().len() > 8);
95
96        assert_eq!(sections[1].kind, 1);
97        assert_eq!(sections[1].compression, 0);
98        assert_eq!(sections[1].name, "$schema");
99        assert_eq!(sections[1].len, sections[1].uncompressed_len);
100        assert_eq!(sections[2].kind, 3);
101        assert_eq!(sections[2].compression, 0);
102        assert_eq!(sections[2].name, "$strings");
103        assert_eq!(sections[2].len, sections[2].uncompressed_len);
104        let strings_payload = &bytes[sections[2].offset..sections[2].offset + sections[2].len];
105        let (string_count, cursor) = read_var_u32(strings_payload, 0);
106        let (string_len, cursor) = read_var_u32(strings_payload, cursor);
107        assert_eq!(string_count, 1);
108        assert_eq!(string_len, 10);
109        assert_eq!(
110            &strings_payload[cursor..cursor + string_len as usize],
111            b"Iron Sword"
112        );
113        assert_eq!(sections[3].kind, 2);
114        assert_eq!(sections[3].compression, 0);
115        assert_eq!(sections[3].name, "Item");
116        assert_eq!(sections[3].len, sections[3].uncompressed_len);
117
118        let table_payload = &bytes[sections[3].offset..sections[3].offset + sections[3].len];
119        assert_eq!(read_u32(table_payload, 0), 1);
120        assert_eq!(read_u32(table_payload, 4), 0);
121        assert_eq!(read_u32(table_payload, 8), 3);
122        let (id, cursor) = read_var_i32(table_payload, 12);
123        let (name_id, cursor) = read_var_u32(table_payload, cursor);
124        assert_eq!(id, 1001);
125        assert_eq!(name_id, 0);
126        assert_eq!(cursor, table_payload.len());
127
128        let _ = fs::remove_dir_all(path.parent().unwrap());
129    }
130
131    #[test]
132    fn binary_bundle_can_compress_sections_with_zstd() {
133        let ir = example_ir();
134        let data = example_data();
135        let path = temp_dir().join("config.sora");
136
137        BinaryBundleExporter
138            .export(ExportRequest {
139                ir: &ir,
140                data: &data,
141                execution: &sora_execution::ExecutionContext::default(),
142                options: crate::exporter::ExportOptions {
143                    compression: crate::exporter::ExportCompression::Zstd { level: 3 },
144                },
145                output: ExportOutput::File(path.clone()),
146            })
147            .unwrap();
148
149        let bytes = fs::read(&path).unwrap();
150        let sections = read_sections(&bytes);
151        assert_eq!(sections[0].compression, 0);
152        assert_eq!(sections[1].compression, 0);
153        assert_eq!(sections[2].compression, 1);
154        assert_eq!(sections[3].compression, 1);
155        assert!(sections[2].len > 0);
156        assert!(sections[3].len > 0);
157        assert_ne!(sections[2].len, sections[2].uncompressed_len);
158        assert_ne!(sections[3].len, sections[3].uncompressed_len);
159
160        let _ = fs::remove_dir_all(path.parent().unwrap());
161    }
162
163    fn example_ir() -> ConfigIr {
164        let schema: SchemaFile = toml::from_str(
165            r#"
166package = "game_config"
167
168[[tables]]
169name = "Item"
170mode = "map"
171key = "id"
172
173[[tables.fields]]
174name = "id"
175type = "i32"
176
177[[tables.fields]]
178name = "name"
179type = "string"
180"#,
181        )
182        .unwrap();
183        normalize_schema(schema).unwrap()
184    }
185
186    fn example_data() -> ConfigData {
187        ConfigData {
188            tables: vec![TableData {
189                name: "Item".to_owned(),
190                rows: vec![RowData {
191                    values: BTreeMap::from([
192                        ("id".to_owned(), Value::Integer(1001)),
193                        ("name".to_owned(), Value::String("Iron Sword".to_owned())),
194                    ]),
195                }],
196            }],
197        }
198    }
199
200    fn temp_dir() -> PathBuf {
201        static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
202        let unique = SystemTime::now()
203            .duration_since(UNIX_EPOCH)
204            .unwrap()
205            .as_nanos();
206        let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
207        std::env::temp_dir().join(format!("sora-export-binary-test-{unique}-{id}"))
208    }
209
210    #[derive(Debug)]
211    struct TestSection {
212        kind: u32,
213        compression: u32,
214        name: String,
215        offset: usize,
216        len: usize,
217        uncompressed_len: usize,
218    }
219
220    fn read_sections(bytes: &[u8]) -> Vec<TestSection> {
221        let directory_len = read_u32(bytes, 12) as usize;
222        let section_count = read_u32(bytes, 16) as usize;
223        let mut cursor = 24;
224        let directory_end = cursor + directory_len;
225        let mut sections = Vec::new();
226        while cursor < directory_end {
227            let kind = read_u32(bytes, cursor);
228            let compression = read_u32(bytes, cursor + 4);
229            let name_len = read_u32(bytes, cursor + 8) as usize;
230            let offset = read_u32(bytes, cursor + 16) as usize;
231            let len = read_u32(bytes, cursor + 20) as usize;
232            let uncompressed_len = read_u32(bytes, cursor + 24) as usize;
233            let name_start = cursor + 28;
234            let name = std::str::from_utf8(&bytes[name_start..name_start + name_len])
235                .unwrap()
236                .to_owned();
237            sections.push(TestSection {
238                kind,
239                compression,
240                name,
241                offset,
242                len,
243                uncompressed_len,
244            });
245            cursor = name_start + name_len;
246        }
247        assert_eq!(sections.len(), section_count);
248        sections
249    }
250
251    fn read_u32(bytes: &[u8], offset: usize) -> u32 {
252        u32::from_le_bytes(bytes[offset..offset + 4].try_into().unwrap())
253    }
254
255    fn read_var_u32(bytes: &[u8], mut offset: usize) -> (u32, usize) {
256        let mut value = 0u32;
257        let mut shift = 0;
258        loop {
259            let byte = bytes[offset];
260            offset += 1;
261            value |= u32::from(byte & 0x7f) << shift;
262            if byte & 0x80 == 0 {
263                return (value, offset);
264            }
265            shift += 7;
266        }
267    }
268
269    fn read_var_i32(bytes: &[u8], offset: usize) -> (i32, usize) {
270        let (value, offset) = read_var_u32(bytes, offset);
271        (((value >> 1) as i32) ^ (-((value & 1) as i32)), offset)
272    }
273}