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}