Skip to main content

khora_io/serialization/
service.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//! Scene serialization service — on-demand, not an Agent.
16//!
17//! This service provides `save_world()` and `load_world()` APIs backed by a
18//! strategy registry. No GORNA negotiation — the serialization strategy is
19//! chosen by the caller.
20
21use super::*;
22use khora_core::scene::{SceneFile, SceneHeader, SerializationGoal};
23use khora_data::ecs::World;
24use std::collections::HashMap;
25
26/// Scene format version produced by today's writers. Bumped whenever
27/// the on-disk layout changes; older payloads run through registered
28/// [`SceneMigration`]s on the way in.
29pub const CURRENT_SCENE_VERSION: u32 = 1;
30
31/// An error that can occur within the `SerializationService`.
32#[derive(Debug)]
33pub enum SerializationServiceError {
34    /// No suitable serialization strategy was found.
35    StrategyNotFound,
36    /// The scene file header is invalid or corrupted.
37    InvalidHeader,
38    /// A general processing error occurred.
39    ProcessingError(String),
40}
41
42/// The serialization service.
43///
44/// Provides on-demand scene save/load through a strategy pattern.
45/// Registered in `ServiceRegistry` and accessed by game code via `AppContext`.
46pub struct SerializationService {
47    strategies: HashMap<String, Box<dyn SerializationStrategy>>,
48}
49
50impl SerializationService {
51    /// Creates a new service and registers all built-in strategies.
52    pub fn new() -> Self {
53        let mut strategies: HashMap<String, Box<dyn SerializationStrategy>> = HashMap::new();
54
55        let definition_strategy = DefinitionSerializationStrategy::new();
56        strategies.insert(
57            definition_strategy.get_strategy_id().to_string(),
58            Box::new(definition_strategy),
59        );
60
61        let recipe_strategy = RecipeSerializationStrategy::new();
62        strategies.insert(
63            recipe_strategy.get_strategy_id().to_string(),
64            Box::new(recipe_strategy),
65        );
66
67        let archetype_strategy = ArchetypeSerializationStrategy::new();
68        strategies.insert(
69            archetype_strategy.get_strategy_id().to_string(),
70            Box::new(archetype_strategy),
71        );
72
73        let messagepack_strategy = MessagePackSerializationStrategy::new();
74        strategies.insert(
75            messagepack_strategy.get_strategy_id().to_string(),
76            Box::new(messagepack_strategy),
77        );
78
79        Self { strategies }
80    }
81
82    /// Saves the current state of the `World` based on a high-level goal.
83    pub fn save_world(
84        &self,
85        world: &World,
86        goal: SerializationGoal,
87    ) -> Result<SceneFile, SerializationServiceError> {
88        let strategy_id = match goal {
89            SerializationGoal::HumanReadableDebug | SerializationGoal::LongTermStability => {
90                "KH_DEFINITION_RON_V1"
91            }
92            SerializationGoal::SmallestFileSize | SerializationGoal::EditorInterchange => {
93                "KH_RECIPE_V1"
94            }
95            SerializationGoal::FastestLoad => "KH_ARCHETYPE_V1",
96            SerializationGoal::PortableBinary => "KH_MESSAGEPACK_V1",
97        };
98
99        let strategy = self
100            .strategies
101            .get(strategy_id)
102            .ok_or(SerializationServiceError::StrategyNotFound)?;
103
104        let payload = strategy
105            .serialize(world)
106            .map_err(|e| SerializationServiceError::ProcessingError(e.to_string()))?;
107
108        let strategy_id_str = strategy.get_strategy_id();
109        let mut strategy_id_bytes = [0u8; 32];
110        strategy_id_bytes[..strategy_id_str.len()].copy_from_slice(strategy_id_str.as_bytes());
111
112        let header = SceneHeader {
113            magic_bytes: khora_core::scene::HEADER_MAGIC_BYTES,
114            format_version: CURRENT_SCENE_VERSION as u8,
115            strategy_id: strategy_id_bytes,
116            payload_length: payload.len() as u64,
117        };
118
119        Ok(SceneFile { header, payload })
120    }
121
122    /// Populates a `World` from a `SceneFile`.
123    pub fn load_world(
124        &self,
125        file: &SceneFile,
126        world: &mut World,
127    ) -> Result<(), SerializationServiceError> {
128        let strategy_id = str::from_utf8(&file.header.strategy_id)
129            .map_err(|_| SerializationServiceError::InvalidHeader)?
130            .trim_end_matches('\0');
131
132        // Apply registered migrations to bring the payload up to the
133        // current scene format version. No migrations are registered
134        // today (format_version = 1 is the only supported scene), but
135        // the seam is here for the next bump.
136        let payload = migrate_payload(
137            file.payload.clone(),
138            file.header.format_version as u32,
139            CURRENT_SCENE_VERSION,
140        )
141        .map_err(|e| SerializationServiceError::ProcessingError(e.to_string()))?;
142
143        let strategy = self
144            .strategies
145            .get(strategy_id)
146            .ok_or(SerializationServiceError::StrategyNotFound)?;
147
148        strategy
149            .deserialize(&payload, world)
150            .map_err(|e| SerializationServiceError::ProcessingError(e.to_string()))
151    }
152}
153
154impl Default for SerializationService {
155    fn default() -> Self {
156        Self::new()
157    }
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163    use khora_core::math::Vec3;
164    use khora_core::scene::SerializationGoal;
165    use khora_data::ecs::{GlobalTransform, Parent, Transform, Without, World};
166
167    #[test]
168    fn test_definition_serialization_round_trip() {
169        let mut source_world = World::new();
170
171        let root_transform = Transform {
172            translation: Vec3::new(10.0, 0.0, 0.0),
173            ..Default::default()
174        };
175        let _root_id = source_world.spawn((root_transform, GlobalTransform::identity()));
176
177        let service = SerializationService::new();
178        let scene_file = service
179            .save_world(&source_world, SerializationGoal::LongTermStability)
180            .unwrap();
181
182        let mut dest_world = World::new();
183        service.load_world(&scene_file, &mut dest_world).unwrap();
184
185        let mut root_query = dest_world.query::<(&Transform, Without<Parent>)>();
186        let (new_root_transform, _) = root_query.next().expect("Should be one root entity");
187        assert_eq!(*new_root_transform, root_transform);
188    }
189
190    #[test]
191    fn test_recipe_serialization_round_trip() {
192        let mut source_world = World::new();
193
194        let root_transform = Transform {
195            translation: Vec3::new(25.0, 0.0, 0.0),
196            ..Default::default()
197        };
198        source_world.spawn((root_transform, GlobalTransform::identity()));
199
200        let service = SerializationService::new();
201        let scene_file = service
202            .save_world(&source_world, SerializationGoal::EditorInterchange)
203            .unwrap();
204
205        let mut dest_world = World::new();
206        service.load_world(&scene_file, &mut dest_world).unwrap();
207
208        assert_eq!(
209            str::from_utf8(&scene_file.header.strategy_id)
210                .unwrap()
211                .trim_end_matches('\0'),
212            "KH_RECIPE_V1"
213        );
214
215        let mut root_query = dest_world.query::<(&Transform, Without<Parent>)>();
216        let (new_root_transform, _) = root_query.next().expect("Should be one root entity");
217        assert_eq!(*new_root_transform, root_transform);
218    }
219
220    #[test]
221    fn test_archetype_serialization_round_trip() {
222        let mut source_world = World::new();
223
224        let root_transform = Transform {
225            translation: Vec3::new(10.0, 0.0, 0.0),
226            ..Default::default()
227        };
228        let root_id = source_world.spawn((root_transform, GlobalTransform::identity()));
229
230        let service = SerializationService::new();
231        let scene_file = service
232            .save_world(&source_world, SerializationGoal::FastestLoad)
233            .unwrap();
234
235        let mut dest_world = World::new();
236        service.load_world(&scene_file, &mut dest_world).unwrap();
237
238        assert_eq!(
239            str::from_utf8(&scene_file.header.strategy_id)
240                .unwrap()
241                .trim_end_matches('\0'),
242            "KH_ARCHETYPE_V1"
243        );
244
245        assert!(
246            dest_world.get::<Transform>(root_id).is_some(),
247            "Root entity should exist with the same ID"
248        );
249    }
250}