Skip to main content

oxihuman_export/
asset_bundle.rs

1// Copyright (C) 2026 COOLJAPAN OU (Team KitaSan)
2// SPDX-License-Identifier: Apache-2.0
3
4#![allow(dead_code)]
5
6//! OXB — OxiHuman Bundle: a simple binary multi-file asset bundle format.
7//!
8//! Format specification:
9//! ```text
10//! Header (16 bytes):
11//!   [0..4]   magic: b"OXB1"
12//!   [4..8]   entry_count: u32 LE
13//!   [8..16]  reserved: [u8; 8] = 0
14//!
15//! Directory (entry_count entries, each 72 bytes):
16//!   [0..64]  name: null-padded UTF-8 string (max 63 chars + null)
17//!   [64..68] offset: u32 LE  (byte offset from start of bundle)
18//!   [68..72] length: u32 LE  (byte length of entry data)
19//!
20//! Data section:
21//!   Raw bytes of all entries, concatenated in directory order
22//! ```
23
24use std::fs;
25use std::io::Write;
26use std::path::Path;
27
28use anyhow::{anyhow, bail, Context, Result};
29
30/// Magic bytes that identify an OXB bundle file.
31pub const OXB_MAGIC: &[u8; 4] = b"OXB1";
32
33/// Maximum number of characters in an entry name (not counting the terminating null byte).
34pub const MAX_ENTRY_NAME: usize = 63;
35
36const HEADER_SIZE: usize = 16;
37const DIR_ENTRY_SIZE: usize = 72;
38const NAME_FIELD_SIZE: usize = 64;
39
40// ── BundleEntry ──────────────────────────────────────────────────────────────
41
42/// A single named data entry inside an [`AssetBundle`].
43pub struct BundleEntry {
44    pub name: String,
45    pub data: Vec<u8>,
46}
47
48impl BundleEntry {
49    /// Create a new entry from an in-memory byte vector.
50    ///
51    /// Returns an error if `name` is empty, longer than [`MAX_ENTRY_NAME`] bytes,
52    /// or contains a null byte.
53    pub fn new(name: impl Into<String>, data: Vec<u8>) -> Result<Self> {
54        let name = name.into();
55        validate_name(&name)?;
56        Ok(Self { name, data })
57    }
58
59    /// Create a new entry by reading `path` from disk.
60    pub fn from_file(name: impl Into<String>, path: &Path) -> Result<Self> {
61        let name = name.into();
62        validate_name(&name)?;
63        let data =
64            fs::read(path).with_context(|| format!("failed to read file: {}", path.display()))?;
65        Ok(Self { name, data })
66    }
67
68    /// Returns the byte length of the entry data.
69    pub fn size(&self) -> usize {
70        self.data.len()
71    }
72}
73
74// ── AssetBundle ───────────────────────────────────────────────────────────────
75
76/// An in-memory collection of named binary entries that can be exported to / loaded
77/// from a `.oxb` bundle file.
78pub struct AssetBundle {
79    entries: Vec<BundleEntry>,
80}
81
82impl AssetBundle {
83    /// Create an empty bundle.
84    pub fn new() -> Self {
85        Self {
86            entries: Vec::new(),
87        }
88    }
89
90    /// Add a pre-built [`BundleEntry`] to the bundle.
91    ///
92    /// Returns an error if an entry with the same name already exists.
93    pub fn add(&mut self, entry: BundleEntry) -> Result<()> {
94        if self.contains(&entry.name) {
95            bail!("duplicate entry name: {}", entry.name);
96        }
97        self.entries.push(entry);
98        Ok(())
99    }
100
101    /// Add a named byte slice to the bundle.
102    pub fn add_bytes(&mut self, name: impl Into<String>, data: Vec<u8>) -> Result<()> {
103        let entry = BundleEntry::new(name, data)?;
104        self.add(entry)
105    }
106
107    /// Add a named file from disk to the bundle.
108    pub fn add_file(&mut self, name: impl Into<String>, path: &Path) -> Result<()> {
109        let entry = BundleEntry::from_file(name, path)?;
110        self.add(entry)
111    }
112
113    /// Add a named UTF-8 string to the bundle (stored as raw UTF-8 bytes).
114    pub fn add_str(&mut self, name: impl Into<String>, text: &str) -> Result<()> {
115        self.add_bytes(name, text.as_bytes().to_vec())
116    }
117
118    /// Returns the number of entries in the bundle.
119    pub fn entry_count(&self) -> usize {
120        self.entries.len()
121    }
122
123    /// Returns the total number of data bytes across all entries.
124    pub fn total_size(&self) -> usize {
125        self.entries.iter().map(|e| e.size()).sum()
126    }
127
128    /// Look up an entry by name.
129    pub fn get(&self, name: &str) -> Option<&BundleEntry> {
130        self.entries.iter().find(|e| e.name == name)
131    }
132
133    /// Returns a list of all entry names in insertion order.
134    pub fn entry_names(&self) -> Vec<&str> {
135        self.entries.iter().map(|e| e.name.as_str()).collect()
136    }
137
138    /// Returns `true` if the bundle contains an entry with the given name.
139    pub fn contains(&self, name: &str) -> bool {
140        self.entries.iter().any(|e| e.name == name)
141    }
142
143    /// Remove an entry by name.  Returns `true` if an entry was found and removed.
144    pub fn remove(&mut self, name: &str) -> bool {
145        if let Some(pos) = self.entries.iter().position(|e| e.name == name) {
146            self.entries.remove(pos);
147            true
148        } else {
149            false
150        }
151    }
152}
153
154impl Default for AssetBundle {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160// ── I/O ──────────────────────────────────────────────────────────────────────
161
162/// Serialise `bundle` to a `.oxb` file at `path`.
163pub fn export_bundle(bundle: &AssetBundle, path: &Path) -> Result<()> {
164    let entry_count = bundle.entries.len();
165    let data_offset = HEADER_SIZE + entry_count * DIR_ENTRY_SIZE;
166
167    let mut file = fs::File::create(path)
168        .with_context(|| format!("cannot create bundle file: {}", path.display()))?;
169
170    // ── Header ──────────────────────────────────────────────────────────────
171    file.write_all(OXB_MAGIC)?;
172    file.write_all(&(entry_count as u32).to_le_bytes())?;
173    file.write_all(&[0u8; 8])?; // reserved
174
175    // ── Directory ───────────────────────────────────────────────────────────
176    let mut current_offset = data_offset as u32;
177    for entry in &bundle.entries {
178        let mut name_buf = [0u8; NAME_FIELD_SIZE];
179        let name_bytes = entry.name.as_bytes();
180        name_buf[..name_bytes.len()].copy_from_slice(name_bytes);
181        file.write_all(&name_buf)?;
182        file.write_all(&current_offset.to_le_bytes())?;
183        file.write_all(&(entry.data.len() as u32).to_le_bytes())?;
184        current_offset += entry.data.len() as u32;
185    }
186
187    // ── Data section ────────────────────────────────────────────────────────
188    for entry in &bundle.entries {
189        file.write_all(&entry.data)?;
190    }
191
192    file.flush()?;
193    Ok(())
194}
195
196/// Deserialise a `.oxb` file from `path` into an [`AssetBundle`].
197pub fn load_bundle(path: &Path) -> Result<AssetBundle> {
198    let raw =
199        fs::read(path).with_context(|| format!("cannot read bundle file: {}", path.display()))?;
200
201    if raw.len() < HEADER_SIZE {
202        bail!("bundle file too small to contain a valid header");
203    }
204
205    // ── Header ──────────────────────────────────────────────────────────────
206    if &raw[0..4] != OXB_MAGIC.as_ref() {
207        bail!("invalid OXB magic bytes");
208    }
209    let entry_count = u32::from_le_bytes(
210        raw[4..8]
211            .try_into()
212            .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
213    ) as usize;
214
215    let dir_end = HEADER_SIZE + entry_count * DIR_ENTRY_SIZE;
216    if raw.len() < dir_end {
217        bail!("bundle file truncated: directory extends past end of file");
218    }
219
220    // ── Directory + data ────────────────────────────────────────────────────
221    let mut bundle = AssetBundle::new();
222    for i in 0..entry_count {
223        let base = HEADER_SIZE + i * DIR_ENTRY_SIZE;
224        let name_field = &raw[base..base + NAME_FIELD_SIZE];
225        let null_pos = name_field
226            .iter()
227            .position(|&b| b == 0)
228            .unwrap_or(NAME_FIELD_SIZE);
229        let name = std::str::from_utf8(&name_field[..null_pos])
230            .with_context(|| format!("entry {} has invalid UTF-8 name", i))?
231            .to_owned();
232
233        let offset = u32::from_le_bytes(
234            raw[base + 64..base + 68]
235                .try_into()
236                .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
237        ) as usize;
238        let length = u32::from_le_bytes(
239            raw[base + 68..base + 72]
240                .try_into()
241                .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
242        ) as usize;
243
244        if offset + length > raw.len() {
245            bail!(
246                "entry '{}' data range [{}, {}) exceeds file size {}",
247                name,
248                offset,
249                offset + length,
250                raw.len()
251            );
252        }
253
254        let data = raw[offset..offset + length].to_vec();
255        bundle
256            .add(BundleEntry { name, data })
257            .with_context(|| format!("failed to add entry {}", i))?;
258    }
259
260    Ok(bundle)
261}
262
263/// Validate a `.oxb` file without fully loading its data.
264///
265/// Checks the magic bytes, the directory structure, and that every entry's
266/// data range lies within the file.  Returns the number of entries on success.
267pub fn validate_bundle(path: &Path) -> Result<usize> {
268    let raw =
269        fs::read(path).with_context(|| format!("cannot read bundle file: {}", path.display()))?;
270
271    if raw.len() < HEADER_SIZE {
272        bail!("bundle file too small");
273    }
274    if &raw[0..4] != OXB_MAGIC.as_ref() {
275        bail!("invalid OXB magic bytes");
276    }
277
278    let entry_count = u32::from_le_bytes(
279        raw[4..8]
280            .try_into()
281            .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
282    ) as usize;
283    let dir_end = HEADER_SIZE + entry_count * DIR_ENTRY_SIZE;
284
285    if raw.len() < dir_end {
286        bail!("directory extends past end of file");
287    }
288
289    for i in 0..entry_count {
290        let base = HEADER_SIZE + i * DIR_ENTRY_SIZE;
291        let offset = u32::from_le_bytes(
292            raw[base + 64..base + 68]
293                .try_into()
294                .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
295        ) as usize;
296        let length = u32::from_le_bytes(
297            raw[base + 68..base + 72]
298                .try_into()
299                .map_err(|_| anyhow::anyhow!("byte conversion failed"))?,
300        ) as usize;
301
302        if offset < dir_end {
303            bail!(
304                "entry {} offset {} is inside the header/directory region",
305                i,
306                offset
307            );
308        }
309        if offset.checked_add(length).is_none_or(|end| end > raw.len()) {
310            bail!(
311                "entry {} data range [{}, {}) exceeds file size",
312                i,
313                offset,
314                offset + length
315            );
316        }
317    }
318
319    Ok(entry_count)
320}
321
322/// Extract all entries from the bundle at `path` to `output_dir`.
323///
324/// `output_dir` is created if it does not exist.  Returns the list of extracted
325/// entry names.
326pub fn extract_bundle(path: &Path, output_dir: &Path) -> Result<Vec<String>> {
327    let bundle = load_bundle(path)?;
328    fs::create_dir_all(output_dir)
329        .with_context(|| format!("cannot create output directory: {}", output_dir.display()))?;
330
331    let mut names = Vec::new();
332    for entry in &bundle.entries {
333        let out_path = output_dir.join(&entry.name);
334        fs::write(&out_path, &entry.data)
335            .with_context(|| format!("cannot write extracted file: {}", out_path.display()))?;
336        names.push(entry.name.clone());
337    }
338    Ok(names)
339}
340
341/// Build a bundle from all *files* (non-recursive) in `dir`.
342///
343/// Each file's name (not full path) is used as the entry name.  Entries are
344/// added in directory-read order (which is filesystem-dependent).
345pub fn bundle_from_dir(dir: &Path) -> Result<AssetBundle> {
346    let mut bundle = AssetBundle::new();
347    let read_dir =
348        fs::read_dir(dir).with_context(|| format!("cannot read directory: {}", dir.display()))?;
349
350    for result in read_dir {
351        let entry = result.with_context(|| "failed to read directory entry")?;
352        let meta = entry.metadata()?;
353        if !meta.is_file() {
354            continue;
355        }
356        let file_name = entry
357            .file_name()
358            .into_string()
359            .map_err(|_| anyhow!("non-UTF-8 file name in directory"))?;
360        let data = fs::read(entry.path())?;
361        bundle.add_bytes(file_name, data)?;
362    }
363
364    Ok(bundle)
365}
366
367// ── helpers ───────────────────────────────────────────────────────────────────
368
369fn validate_name(name: &str) -> Result<()> {
370    if name.is_empty() {
371        bail!("entry name must not be empty");
372    }
373    if name.len() > MAX_ENTRY_NAME {
374        bail!(
375            "entry name '{}' is {} bytes, exceeds MAX_ENTRY_NAME ({})",
376            name,
377            name.len(),
378            MAX_ENTRY_NAME
379        );
380    }
381    if name.contains('\0') {
382        bail!("entry name must not contain null bytes");
383    }
384    Ok(())
385}
386
387// ── tests ─────────────────────────────────────────────────────────────────────
388
389#[cfg(test)]
390mod tests {
391    use super::*;
392    use std::path::PathBuf;
393
394    fn tmp_path(filename: &str) -> PathBuf {
395        PathBuf::from(format!("/tmp/{}", filename))
396    }
397
398    // ── BundleEntry ──────────────────────────────────────────────────────────
399
400    #[test]
401    fn test_bundle_entry_new() {
402        let entry = BundleEntry::new("mesh.bin", vec![1, 2, 3]).expect("should succeed");
403        assert_eq!(entry.name, "mesh.bin");
404        assert_eq!(entry.data, vec![1, 2, 3]);
405        assert_eq!(entry.size(), 3);
406    }
407
408    #[test]
409    fn test_bundle_entry_name_too_long() {
410        let long_name = "a".repeat(MAX_ENTRY_NAME + 1);
411        let result = BundleEntry::new(long_name, vec![]);
412        assert!(result.is_err());
413    }
414
415    // ── AssetBundle ──────────────────────────────────────────────────────────
416
417    #[test]
418    fn test_asset_bundle_new() {
419        let bundle = AssetBundle::new();
420        assert_eq!(bundle.entry_count(), 0);
421        assert_eq!(bundle.total_size(), 0);
422    }
423
424    #[test]
425    fn test_add_bytes() {
426        let mut bundle = AssetBundle::new();
427        bundle.add_bytes("alpha", vec![0xFF, 0x00]).expect("should succeed");
428        bundle.add_bytes("beta", vec![1, 2, 3, 4]).expect("should succeed");
429        assert_eq!(bundle.entry_count(), 2);
430        assert_eq!(bundle.total_size(), 6);
431    }
432
433    #[test]
434    fn test_add_str() {
435        let mut bundle = AssetBundle::new();
436        bundle.add_str("readme.txt", "Hello, world!").expect("should succeed");
437        let entry = bundle.get("readme.txt").expect("should succeed");
438        assert_eq!(entry.data, b"Hello, world!");
439    }
440
441    #[test]
442    fn test_contains_and_get() {
443        let mut bundle = AssetBundle::new();
444        bundle.add_bytes("x", vec![42]).expect("should succeed");
445        assert!(bundle.contains("x"));
446        assert!(!bundle.contains("y"));
447        assert_eq!(bundle.get("x").expect("should succeed").data, vec![42]);
448        assert!(bundle.get("y").is_none());
449    }
450
451    #[test]
452    fn test_remove_entry() {
453        let mut bundle = AssetBundle::new();
454        bundle.add_bytes("keep", vec![1]).expect("should succeed");
455        bundle.add_bytes("drop", vec![2]).expect("should succeed");
456        assert!(bundle.remove("drop"));
457        assert!(!bundle.contains("drop"));
458        assert_eq!(bundle.entry_count(), 1);
459        assert!(!bundle.remove("drop")); // second remove returns false
460    }
461
462    #[test]
463    fn test_total_size() {
464        let mut bundle = AssetBundle::new();
465        bundle.add_bytes("a", vec![0u8; 100]).expect("should succeed");
466        bundle.add_bytes("b", vec![0u8; 200]).expect("should succeed");
467        assert_eq!(bundle.total_size(), 300);
468    }
469
470    // ── I/O roundtrip ────────────────────────────────────────────────────────
471
472    #[test]
473    fn test_export_and_load_roundtrip() {
474        let path = tmp_path("oxihuman_test_roundtrip.oxb");
475
476        let mut bundle = AssetBundle::new();
477        bundle.add_str("hello.txt", "Hello OXB").expect("should succeed");
478        bundle
479            .add_bytes("data.bin", vec![0xDE, 0xAD, 0xBE, 0xEF])
480            .expect("should succeed");
481        bundle.add_bytes("empty.bin", vec![]).expect("should succeed");
482        export_bundle(&bundle, &path).expect("should succeed");
483
484        let loaded = load_bundle(&path).expect("should succeed");
485        assert_eq!(loaded.entry_count(), 3);
486        assert_eq!(loaded.get("hello.txt").expect("should succeed").data, b"Hello OXB");
487        assert_eq!(
488            loaded.get("data.bin").expect("should succeed").data,
489            vec![0xDE, 0xAD, 0xBE, 0xEF]
490        );
491        assert_eq!(loaded.get("empty.bin").expect("should succeed").data, Vec::<u8>::new());
492    }
493
494    #[test]
495    fn test_validate_bundle() {
496        let path = tmp_path("oxihuman_test_validate.oxb");
497
498        let mut bundle = AssetBundle::new();
499        bundle.add_bytes("a", vec![1, 2]).expect("should succeed");
500        bundle.add_bytes("b", vec![3, 4, 5]).expect("should succeed");
501        export_bundle(&bundle, &path).expect("should succeed");
502
503        let count = validate_bundle(&path).expect("should succeed");
504        assert_eq!(count, 2);
505    }
506
507    #[test]
508    fn test_validate_bad_magic() {
509        let path = tmp_path("oxihuman_test_bad_magic.oxb");
510        fs::write(&path, b"NOTOXB1\x00\x00\x00\x00\x00\x00\x00\x00\x00").expect("should succeed");
511        let result = validate_bundle(&path);
512        assert!(result.is_err());
513        let msg = result.unwrap_err().to_string();
514        assert!(msg.contains("magic"));
515    }
516
517    #[test]
518    fn test_extract_bundle() {
519        let bundle_path = tmp_path("oxihuman_test_extract.oxb");
520        let out_dir = PathBuf::from("/tmp/oxihuman_test_extract_out");
521
522        let mut bundle = AssetBundle::new();
523        bundle.add_str("file1.txt", "content one").expect("should succeed");
524        bundle.add_str("file2.txt", "content two").expect("should succeed");
525        export_bundle(&bundle, &bundle_path).expect("should succeed");
526
527        let names = extract_bundle(&bundle_path, &out_dir).expect("should succeed");
528        assert_eq!(names.len(), 2);
529
530        let f1 = fs::read_to_string(out_dir.join("file1.txt")).expect("should succeed");
531        let f2 = fs::read_to_string(out_dir.join("file2.txt")).expect("should succeed");
532        assert_eq!(f1, "content one");
533        assert_eq!(f2, "content two");
534    }
535
536    #[test]
537    fn test_bundle_from_dir() {
538        let dir = PathBuf::from("/tmp/oxihuman_test_bundle_from_dir");
539        fs::create_dir_all(&dir).expect("should succeed");
540        fs::write(dir.join("asset_a.bin"), b"aaa").expect("should succeed");
541        fs::write(dir.join("asset_b.bin"), b"bbbb").expect("should succeed");
542
543        let bundle = bundle_from_dir(&dir).expect("should succeed");
544        assert_eq!(bundle.entry_count(), 2);
545        assert!(bundle.contains("asset_a.bin"));
546        assert!(bundle.contains("asset_b.bin"));
547        assert_eq!(bundle.get("asset_a.bin").expect("should succeed").data, b"aaa");
548    }
549}