khora_core/scene/format.rs
1// Copyright 2025 eraflo
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15//! Defines the unified file format for Khora scenes.
16//!
17//! Every scene persisted by the SAA-Serialize system uses this container format.
18//! It consists of a fixed-size [`SceneHeader`] followed by a variable-length payload.
19//! The header acts as a manifest, describing what serialization strategy was used
20//! to encode the payload, allowing the engine to correctly dispatch the data to the
21//! appropriate deserialization `Lane`.
22
23use std::convert::TryInto;
24
25/// A unique byte sequence to identify Khora Scene Files. ("KHORASCN").
26pub const HEADER_MAGIC_BYTES: [u8; 8] = *b"KHORASCN";
27const STRATEGY_ID_LEN: usize = 32;
28
29/// An error that can occur when parsing a `SceneFile` from bytes.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum SceneFileError {
32 /// The byte slice is too short to contain a valid header.
33 TooShort,
34 /// The file's magic bytes do not match `HEADER_MAGIC_BYTES`.
35 InvalidMagicBytes,
36}
37
38/// The fixed-size header at the beginning of every Khora scene file.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct SceneHeader {
41 /// Magic bytes to identify the file type, must be `HEADER_MAGIC_BYTES`.
42 pub magic_bytes: [u8; 8],
43 /// The version of the header format itself.
44 pub format_version: u8,
45 /// A null-padded UTF-8 string identifying the serialization strategy used.
46 /// e.g., "KH_RECIPE_V1", "KH_ARCHETYPE_V1".
47 pub strategy_id: [u8; STRATEGY_ID_LEN],
48 /// The length of the payload data that follows this header, in bytes.
49 pub payload_length: u64,
50}
51
52/// A logical representation of a full scene file in memory.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct SceneFile {
55 /// The parsed header data.
56 pub header: SceneHeader,
57 /// The raw, variable-length payload data.
58 pub payload: Vec<u8>,
59}
60
61// NOTE: We are intentionally not using `serde` for the header.
62// It's a fixed-layout, performance-critical part of the file format,
63// so direct byte manipulation is more robust and efficient.
64impl SceneHeader {
65 /// The total size of the header in bytes.
66 pub const SIZE: usize = 8 + 1 + STRATEGY_ID_LEN + 8;
67
68 /// Attempts to parse a `SceneHeader` from the beginning of a byte slice.
69 pub fn from_bytes(bytes: &[u8]) -> Result<Self, &'static str> {
70 if bytes.len() < Self::SIZE {
71 return Err("Not enough bytes to form a valid header");
72 }
73
74 let magic_bytes: [u8; 8] = bytes[0..8].try_into().unwrap();
75 if magic_bytes != HEADER_MAGIC_BYTES {
76 return Err("Invalid magic bytes; not a Khora scene file");
77 }
78
79 let format_version = bytes[8];
80
81 let strategy_id: [u8; STRATEGY_ID_LEN] = bytes[9..9 + STRATEGY_ID_LEN].try_into().unwrap();
82
83 let payload_length =
84 u64::from_le_bytes(bytes[9 + STRATEGY_ID_LEN..Self::SIZE].try_into().unwrap());
85
86 Ok(Self {
87 magic_bytes,
88 format_version,
89 strategy_id,
90 payload_length,
91 })
92 }
93
94 /// Serializes the header into a fixed-size byte array.
95 pub fn to_bytes(&self) -> [u8; Self::SIZE] {
96 let mut bytes = [0u8; Self::SIZE];
97 bytes[0..8].copy_from_slice(&self.magic_bytes);
98 bytes[8] = self.format_version;
99 bytes[9..9 + STRATEGY_ID_LEN].copy_from_slice(&self.strategy_id);
100 let payload_bytes = self.payload_length.to_le_bytes();
101 bytes[9 + STRATEGY_ID_LEN..Self::SIZE].copy_from_slice(&payload_bytes);
102 bytes
103 }
104}
105
106impl SceneFile {
107 /// Parses a `SceneFile` from a byte slice.
108 pub fn from_bytes(bytes: &[u8]) -> Result<Self, SceneFileError> {
109 let header =
110 SceneHeader::from_bytes(bytes).map_err(|_| SceneFileError::InvalidMagicBytes)?;
111 let header_size = SceneHeader::SIZE;
112 let payload_end = header_size + header.payload_length as usize;
113
114 if bytes.len() < payload_end {
115 return Err(SceneFileError::TooShort);
116 }
117
118 let payload = bytes[header_size..payload_end].to_vec();
119 Ok(Self { header, payload })
120 }
121
122 /// Serializes the entire `SceneFile` (header + payload) into a single byte vector.
123 pub fn to_bytes(&self) -> Vec<u8> {
124 let header_bytes = self.header.to_bytes();
125 let mut file_bytes = Vec::with_capacity(header_bytes.len() + self.payload.len());
126 file_bytes.extend_from_slice(&header_bytes);
127 file_bytes.extend_from_slice(&self.payload);
128 file_bytes
129 }
130}