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}