khora_agents/serialization_agent/
mod.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//! The Intelligent Subsystem Agent responsible for managing scene serialization.
16//!
17//! This agent acts as the primary entry point for all serialization tasks. It holds a
18//! registry of available [`SerializationStrategy`] `Lanes` and contains the SAA logic
19//! to select the appropriate strategy based on a given [`SerializationGoal`].
20
21use khora_core::scene::{SceneFile, SceneHeader, SerializationGoal};
22use khora_data::ecs::World;
23use khora_lanes::scene_lane::{
24    ArchetypeSerializationLane, DefinitionSerializationLane, RecipeSerializationLane,
25    SerializationStrategy,
26};
27use std::collections::HashMap;
28
29/// An error that can occur within the `SerializationAgent`.
30#[derive(Debug)]
31pub enum AgentError {
32    /// No suitable serialization strategy was found for the requested operation.
33    StrategyNotFound,
34    /// The scene file header is invalid or corrupted.
35    InvalidHeader,
36    /// A general processing error occurred during serialization or deserialization.
37    ProcessingError(String),
38}
39
40/// The ISA responsible for the entire scene serialization process.
41pub struct SerializationAgent {
42    /// A registry of all available serialization strategies, keyed by their unique ID.
43    strategies: HashMap<String, Box<dyn SerializationStrategy>>,
44}
45
46impl SerializationAgent {
47    /// Creates a new `SerializationAgent` and registers all built-in strategies.
48    pub fn new() -> Self {
49        let mut strategies: HashMap<String, Box<dyn SerializationStrategy>> = HashMap::new();
50
51        // Register the built-in strategies.
52        let definition_lane = DefinitionSerializationLane::new();
53        strategies.insert(
54            definition_lane.get_strategy_id().to_string(),
55            Box::new(definition_lane),
56        );
57
58        let recipe_lane = RecipeSerializationLane::new();
59        strategies.insert(
60            recipe_lane.get_strategy_id().to_string(),
61            Box::new(recipe_lane),
62        );
63
64        let archetype_lane = ArchetypeSerializationLane::new();
65        strategies.insert(
66            archetype_lane.get_strategy_id().to_string(),
67            Box::new(archetype_lane),
68        );
69
70        Self { strategies }
71    }
72
73    /// Saves the current state of the `World` based on a high-level goal.
74    pub fn save_world(
75        &self,
76        world: &World,
77        goal: SerializationGoal,
78    ) -> Result<SceneFile, AgentError> {
79        let strategy_id = match goal {
80            SerializationGoal::HumanReadableDebug | SerializationGoal::LongTermStability => {
81                "KH_DEFINITION_RON_V1"
82            }
83            SerializationGoal::SmallestFileSize | SerializationGoal::EditorInterchange => {
84                "KH_RECIPE_V1"
85            }
86            SerializationGoal::FastestLoad => "KH_ARCHETYPE_V1",
87        };
88
89        let strategy = self
90            .strategies
91            .get(strategy_id)
92            .ok_or(AgentError::StrategyNotFound)?;
93
94        let payload = strategy
95            .serialize(world)
96            .map_err(|e| AgentError::ProcessingError(e.to_string()))?;
97
98        let strategy_id_str = strategy.get_strategy_id();
99        let mut strategy_id_bytes = [0u8; 32];
100        strategy_id_bytes[..strategy_id_str.len()].copy_from_slice(strategy_id_str.as_bytes());
101
102        let header = SceneHeader {
103            magic_bytes: khora_core::scene::HEADER_MAGIC_BYTES,
104            format_version: 1,
105            strategy_id: strategy_id_bytes,
106            payload_length: payload.len() as u64,
107        };
108
109        Ok(SceneFile { header, payload })
110    }
111
112    /// Populates a `World` from a `SceneFile`.
113    pub fn load_world(&self, file: &SceneFile, world: &mut World) -> Result<(), AgentError> {
114        // Convert the strategy_id bytes back to a string slice.
115        let strategy_id = str::from_utf8(&file.header.strategy_id)
116            .map_err(|_| AgentError::InvalidHeader)? // Nouvelle erreur
117            .trim_end_matches('\0');
118
119        let strategy = self
120            .strategies
121            .get(strategy_id)
122            .ok_or(AgentError::StrategyNotFound)?;
123
124        strategy
125            .deserialize(&file.payload, world)
126            .map_err(|e| AgentError::ProcessingError(e.to_string()))
127    }
128}
129
130impl Default for SerializationAgent {
131    fn default() -> Self {
132        Self::new()
133    }
134}
135
136#[cfg(test)]
137mod tests {
138    use super::*;
139    use khora_core::math::Vec3;
140    use khora_data::ecs::{GlobalTransform, Parent, Transform, Without, World};
141    use khora_lanes::scene_lane::transform_propagation_system;
142
143    #[test]
144    fn test_serialization_round_trip() {
145        // --- 1. ARRANGE ---
146        // Create the "source" world and populate it.
147        let mut source_world = World::new();
148
149        let root_transform = Transform {
150            translation: Vec3::new(10.0, 0.0, 0.0),
151            ..Default::default()
152        };
153        let root_id = source_world.spawn((root_transform, GlobalTransform::identity()));
154
155        let child_transform = Transform {
156            translation: Vec3::new(0.0, 5.0, 0.0),
157            ..Default::default()
158        };
159        let child_id = source_world.spawn((
160            child_transform,
161            GlobalTransform::identity(),
162            Parent(root_id),
163        ));
164
165        // Run transform propagation to have a complete, valid state.
166        transform_propagation_system(&mut source_world);
167        let expected_child_global = source_world.get::<GlobalTransform>(child_id).unwrap().0;
168
169        // --- 2. ACT ---
170        // Create an agent and perform the save/load cycle.
171        let agent = SerializationAgent::new();
172        let scene_file = agent
173            .save_world(&source_world, SerializationGoal::LongTermStability)
174            .unwrap();
175
176        // Create the "destination" world.
177        let mut dest_world = World::new();
178        agent.load_world(&scene_file, &mut dest_world).unwrap();
179
180        // Run propagation on the new world.
181        transform_propagation_system(&mut dest_world);
182
183        // --- 3. ASSERT ---
184        // We can't rely on EntityIds, so we verify the structure and data.
185
186        // Find the new root (entity with Transform but no Parent).
187        let mut root_query = dest_world.query::<(&Transform, Without<Parent>)>();
188        let (new_root_transform, _) = root_query.next().expect("Should be one root entity");
189        assert_eq!(*new_root_transform, root_transform);
190
191        // Find the new child entity and verify its data.
192        let mut child_query = dest_world.query::<(&Transform, &Parent, &GlobalTransform)>();
193        let (new_child_transform, _new_parent, new_child_global) =
194            child_query.next().expect("Should be one child entity");
195
196        // Verify the local transform is correct.
197        assert_eq!(*new_child_transform, child_transform);
198        // We can't check the parent ID directly, but the propagation result is the true test.
199        assert_eq!(new_child_global.0, expected_child_global);
200    }
201
202    #[test]
203    fn test_recipe_serialization_round_trip() {
204        // --- 1. ARRANGE ---
205        let mut source_world = World::new();
206
207        let root_transform = Transform {
208            translation: Vec3::new(25.0, 0.0, 0.0),
209            ..Default::default()
210        };
211        source_world.spawn((root_transform, GlobalTransform::identity()));
212
213        // --- 2. ACT ---
214        let agent = SerializationAgent::new();
215        // We ask for the recipe strategy explicitly via the goal.
216        let scene_file = agent
217            .save_world(&source_world, SerializationGoal::EditorInterchange)
218            .unwrap();
219
220        let mut dest_world = World::new();
221        agent.load_world(&scene_file, &mut dest_world).unwrap();
222
223        // --- 3. ASSERT ---
224        // We verify that the header has the correct strategy ID.
225        assert_eq!(
226            str::from_utf8(&scene_file.header.strategy_id)
227                .unwrap()
228                .trim_end_matches('\0'),
229            "KH_RECIPE_V1"
230        );
231
232        // We verify that the data is correct.
233        let mut root_query = dest_world.query::<(&Transform, Without<Parent>)>();
234        let (new_root_transform, _) = root_query.next().expect("Should be one root entity");
235        assert_eq!(*new_root_transform, root_transform);
236    }
237
238    #[test]
239    fn test_archetype_serialization_round_trip() {
240        // --- 1. ARRANGE ---
241        // Create the source world and populate it with a simple hierarchy.
242        let mut source_world = World::new();
243
244        let root_transform = Transform {
245            translation: Vec3::new(10.0, 0.0, 0.0),
246            ..Default::default()
247        };
248        let root_id = source_world.spawn((root_transform, GlobalTransform::identity()));
249
250        let child_transform = Transform {
251            translation: Vec3::new(0.0, 5.0, 0.0),
252            ..Default::default()
253        };
254        let child_id = source_world.spawn((
255            child_transform,
256            GlobalTransform::identity(),
257            Parent(root_id),
258        ));
259
260        // Run the transform system to ensure the source world is in a valid, fully computed state.
261        transform_propagation_system(&mut source_world);
262        let expected_child_global = source_world.get::<GlobalTransform>(child_id).unwrap().0;
263
264        // --- 2. ACT ---
265        // Create an agent and serialize the world using the `FastestLoad` goal,
266        // which should trigger the `ArchetypeSerializationLane`.
267        let agent = SerializationAgent::new();
268        let scene_file = agent
269            .save_world(&source_world, SerializationGoal::FastestLoad)
270            .unwrap();
271
272        // Archetype deserialization completely replaces the world's state.
273        // We create a new empty world to load the data into.
274        let mut dest_world = World::new();
275        agent.load_world(&scene_file, &mut dest_world).unwrap();
276
277        // --- 3. ASSERT ---
278        // Verify that the correct strategy was written to the file header.
279        assert_eq!(
280            str::from_utf8(&scene_file.header.strategy_id)
281                .unwrap()
282                .trim_end_matches('\0'),
283            "KH_ARCHETYPE_V1"
284        );
285
286        // A key guarantee of the archetype strategy is that it preserves EntityIds,
287        // as it's a direct memory snapshot. We can therefore use the original IDs to
288        // query the destination world.
289        let new_child_global = dest_world
290            .get::<GlobalTransform>(child_id)
291            .expect("Child entity should exist with the same ID")
292            .0;
293
294        // The simplest and most robust check is to see if the child's final GlobalTransform
295        // is correct. This implicitly validates the entire hierarchy and data.
296        // Note: No need to run transform_propagation_system on dest_world, as the
297        // raw memory of the `GlobalTransform` components was also copied.
298        assert_eq!(new_child_global, expected_child_global);
299
300        // As a sanity check, also verify the root entity was restored correctly.
301        assert!(
302            dest_world.get::<Transform>(root_id).is_some(),
303            "Root entity should exist with the same ID"
304        );
305    }
306}