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}