Skip to main content

alice_physics/
scene_io.rs

1//! Scene Serialization and Deserialization
2//!
3//! Save and load physics scenes in binary or JSON format.
4//! All data is stored as raw fixed-point values (hi/lo pairs) to preserve
5//! bit-exact determinism across platforms.
6//!
7//! # Binary Format
8//!
9//! ```text
10//! Magic:  "APHYS\0" (6 bytes)
11//! Version: u32 LE
12//! Body count: u32 LE
13//! Joint count: u32 LE
14//! Config: substeps(u32), iterations(u32), gravity(6xi64), damping(i64+u64)
15//! Bodies: [SerializedBody; body_count]
16//! Joints: [SerializedJoint; joint_count]
17//! ```
18//!
19//! # JSON Format
20//!
21//! Manual JSON formatting without serde dependency. Each Fix128 value is
22//! stored as a `[hi, lo]` array for deterministic round-tripping.
23//!
24//! # Feature Gate
25//!
26//! This module requires the `std` feature (file I/O).
27
28use crate::math::{Fix128, Vec3Fix};
29
30use std::fmt::Write as FmtWrite;
31use std::io::{Read, Write};
32
33// ============================================================================
34// Scene Types
35// ============================================================================
36
37/// A complete physics scene for serialization.
38///
39/// Marked `#[non_exhaustive]` so additional scene sections (particle
40/// systems, CFD grids, structural analysis) can be added post-v1.0
41/// without a breaking API change.
42#[derive(Clone, Debug, PartialEq, Eq)]
43#[non_exhaustive]
44pub struct PhysicsScene {
45    /// Serialized rigid bodies
46    pub bodies: Vec<SerializedBody>,
47    /// Serialized joints
48    pub joints: Vec<SerializedJoint>,
49    /// Solver configuration
50    pub config: PhysicsConfig,
51    /// Format version
52    pub version: u32,
53}
54
55impl PhysicsScene {
56    /// Assemble a scene from its parts.
57    ///
58    /// This is the only way to construct a `PhysicsScene` from outside the
59    /// crate: the struct is `#[non_exhaustive]`, so a struct literal is
60    /// rejected downstream (E0639). Pass [`CURRENT_SCENE_VERSION`] as
61    /// `version` unless you are deliberately writing an older format.
62    ///
63    /// ```
64    /// use alice_physics::scene_io::{PhysicsConfig, PhysicsScene, CURRENT_SCENE_VERSION};
65    ///
66    /// let scene = PhysicsScene::new(Vec::new(), Vec::new(), PhysicsConfig::default(), CURRENT_SCENE_VERSION);
67    /// assert_eq!(scene.version, CURRENT_SCENE_VERSION);
68    /// assert!(scene.bodies.is_empty());
69    /// ```
70    #[must_use]
71    pub fn new(
72        bodies: Vec<SerializedBody>,
73        joints: Vec<SerializedJoint>,
74        config: PhysicsConfig,
75        version: u32,
76    ) -> Self {
77        Self {
78            bodies,
79            joints,
80            config,
81            version,
82        }
83    }
84}
85
86/// Serialized rigid body (raw fixed-point data).
87///
88/// Position and velocity are stored as 6 i64 values:
89/// `[x.hi, x.lo_as_i64, y.hi, y.lo_as_i64, z.hi, z.lo_as_i64]`
90#[derive(Clone, Debug, PartialEq, Eq)]
91pub struct SerializedBody {
92    /// Position: x.hi, x.lo, y.hi, y.lo, z.hi, z.lo
93    pub position: [i64; 6],
94    /// Velocity: x.hi, x.lo, y.hi, y.lo, z.hi, z.lo
95    pub velocity: [i64; 6],
96    /// Rotation quaternion: x.hi, x.lo, y.hi, y.lo, z.hi, z.lo, w.hi, w.lo
97    pub rotation: [i64; 8],
98    /// Mass: hi, lo
99    pub mass: [i64; 2],
100    /// Body type: 0=Dynamic, 1=Static, 2=Kinematic
101    pub body_type: u8,
102}
103
104/// Serialized joint (raw fixed-point data).
105#[derive(Clone, Debug, PartialEq, Eq)]
106pub struct SerializedJoint {
107    /// Index of body A
108    pub body_a: u32,
109    /// Index of body B
110    pub body_b: u32,
111    // LIMITATION(COV-ENGINE-150): five of the seven joint kinds (no D6, no ConeTwist), and only the kind and two anchors are stored: axes, limits, motors and spring constants are not.
112    /// Joint type: 0=Ball, 1=Hinge, 2=Fixed, 3=Slider, 4=Spring
113    pub joint_type: u8,
114    /// Anchor on body A: x.hi, x.lo, y.hi, y.lo, z.hi, z.lo
115    pub anchor_a: [i64; 6],
116    /// Anchor on body B: x.hi, x.lo, y.hi, y.lo, z.hi, z.lo
117    pub anchor_b: [i64; 6],
118}
119
120/// Serialized physics configuration.
121///
122/// Marked `#[non_exhaustive]` so new solver parameters can be added
123/// post-v1.0 without a breaking API change.
124#[derive(Clone, Debug, PartialEq, Eq)]
125#[non_exhaustive]
126pub struct PhysicsConfig {
127    /// Number of substeps
128    pub substeps: u32,
129    /// Number of iterations per substep
130    pub iterations: u32,
131    /// Gravity vector (6 i64 values)
132    pub gravity: [i64; 6],
133    /// Damping (hi, lo)
134    pub damping: [i64; 2],
135}
136
137impl PhysicsConfig {
138    /// Build a configuration from raw serialized values.
139    ///
140    /// `gravity` and `damping` use the same raw `Fix128` limb layout as
141    /// [`SerializedBody::position`] (`[hi, lo_as_i64, ...]`). Downstream
142    /// crates need this because the struct is `#[non_exhaustive]`; for the
143    /// engine defaults use [`PhysicsConfig::default`].
144    #[must_use]
145    pub const fn new(substeps: u32, iterations: u32, gravity: [i64; 6], damping: [i64; 2]) -> Self {
146        Self {
147            substeps,
148            iterations,
149            gravity,
150            damping,
151        }
152    }
153}
154
155impl Default for PhysicsConfig {
156    fn default() -> Self {
157        let grav = vec3fix_to_raw(Vec3Fix::new(
158            Fix128::ZERO,
159            Fix128::from_int(-10),
160            Fix128::ZERO,
161        ));
162        let damp = fix128_to_raw(Fix128::from_ratio(99, 100));
163        Self {
164            substeps: 8,
165            iterations: 4,
166            gravity: grav,
167            damping: damp,
168        }
169    }
170}
171
172/// Magic bytes for the binary format header.
173const MAGIC: &[u8; 6] = b"APHYS\0";
174
175/// Current format version.
176const CURRENT_VERSION: u32 = 1;
177
178/// Upper bound on the entries reserved up front when reading a binary scene.
179const MAX_PREALLOC: usize = 4096;
180
181/// Current `.aphys` / JSON scene format version, for [`PhysicsScene::new`].
182pub const CURRENT_SCENE_VERSION: u32 = CURRENT_VERSION;
183
184// ============================================================================
185// Conversion Helpers
186// ============================================================================
187
188const fn fix128_to_raw(v: Fix128) -> [i64; 2] {
189    [v.hi, v.lo as i64]
190}
191
192#[cfg(test)]
193const fn raw_to_fix128(r: &[i64; 2]) -> Fix128 {
194    Fix128 {
195        hi: r[0],
196        lo: r[1] as u64,
197    }
198}
199
200const fn vec3fix_to_raw(v: Vec3Fix) -> [i64; 6] {
201    [
202        v.x.hi,
203        v.x.lo as i64,
204        v.y.hi,
205        v.y.lo as i64,
206        v.z.hi,
207        v.z.lo as i64,
208    ]
209}
210
211#[cfg(test)]
212const fn raw_to_vec3fix(r: &[i64; 6]) -> Vec3Fix {
213    Vec3Fix::new(
214        Fix128 {
215            hi: r[0],
216            lo: r[1] as u64,
217        },
218        Fix128 {
219            hi: r[2],
220            lo: r[3] as u64,
221        },
222        Fix128 {
223            hi: r[4],
224            lo: r[5] as u64,
225        },
226    )
227}
228
229// ============================================================================
230// Binary I/O Helpers
231// ============================================================================
232
233fn write_u32(w: &mut dyn Write, v: u32) -> std::io::Result<()> {
234    w.write_all(&v.to_le_bytes())
235}
236
237fn write_u8(w: &mut dyn Write, v: u8) -> std::io::Result<()> {
238    w.write_all(&[v])
239}
240
241fn write_i64(w: &mut dyn Write, v: i64) -> std::io::Result<()> {
242    w.write_all(&v.to_le_bytes())
243}
244
245fn read_u32(r: &mut dyn Read) -> std::io::Result<u32> {
246    let mut buf = [0u8; 4];
247    r.read_exact(&mut buf)?;
248    Ok(u32::from_le_bytes(buf))
249}
250
251fn read_u8(r: &mut dyn Read) -> std::io::Result<u8> {
252    let mut buf = [0u8; 1];
253    r.read_exact(&mut buf)?;
254    Ok(buf[0])
255}
256
257fn read_i64(r: &mut dyn Read) -> std::io::Result<i64> {
258    let mut buf = [0u8; 8];
259    r.read_exact(&mut buf)?;
260    Ok(i64::from_le_bytes(buf))
261}
262
263fn write_i64_array(w: &mut dyn Write, arr: &[i64]) -> std::io::Result<()> {
264    for &v in arr {
265        write_i64(w, v)?;
266    }
267    Ok(())
268}
269
270fn read_i64_array<const N: usize>(r: &mut dyn Read) -> std::io::Result<[i64; N]> {
271    let mut arr = [0i64; N];
272    for item in &mut arr {
273        *item = read_i64(r)?;
274    }
275    Ok(arr)
276}
277
278// ============================================================================
279// Binary Format
280// ============================================================================
281
282/// Save a physics scene to a binary file.
283///
284/// Format: magic, version, counts, config, bodies, joints.
285///
286/// # Errors
287///
288/// Returns an error if the file cannot be created or written.
289pub fn save_scene(scene: &PhysicsScene, path: &std::path::Path) -> std::io::Result<()> {
290    let mut file = std::fs::File::create(path)?;
291    write_scene_binary(&mut file, scene)
292}
293
294/// Load a physics scene from a binary file.
295///
296/// # Errors
297///
298/// Returns an error if the file cannot be opened, read, or contains invalid data.
299pub fn load_scene(path: &std::path::Path) -> std::io::Result<PhysicsScene> {
300    let mut file = std::fs::File::open(path)?;
301    read_scene_binary(&mut file)
302}
303
304fn write_scene_binary(w: &mut dyn Write, scene: &PhysicsScene) -> std::io::Result<()> {
305    // Magic
306    w.write_all(MAGIC)?;
307
308    // Version
309    write_u32(w, scene.version)?;
310
311    // Counts
312    write_u32(w, scene.bodies.len() as u32)?;
313    write_u32(w, scene.joints.len() as u32)?;
314
315    // Config
316    write_u32(w, scene.config.substeps)?;
317    write_u32(w, scene.config.iterations)?;
318    write_i64_array(w, &scene.config.gravity)?;
319    write_i64_array(w, &scene.config.damping)?;
320
321    // Bodies
322    for body in &scene.bodies {
323        write_i64_array(w, &body.position)?;
324        write_i64_array(w, &body.velocity)?;
325        write_i64_array(w, &body.rotation)?;
326        write_i64_array(w, &body.mass)?;
327        write_u8(w, body.body_type)?;
328    }
329
330    // Joints
331    for joint in &scene.joints {
332        write_u32(w, joint.body_a)?;
333        write_u32(w, joint.body_b)?;
334        write_u8(w, joint.joint_type)?;
335        write_i64_array(w, &joint.anchor_a)?;
336        write_i64_array(w, &joint.anchor_b)?;
337    }
338
339    Ok(())
340}
341
342fn read_scene_binary(r: &mut dyn Read) -> std::io::Result<PhysicsScene> {
343    // Magic
344    let mut magic = [0u8; 6];
345    r.read_exact(&mut magic)?;
346    if &magic != MAGIC {
347        return Err(std::io::Error::new(
348            std::io::ErrorKind::InvalidData,
349            "Invalid magic bytes: expected APHYS\\0",
350        ));
351    }
352
353    // Version
354    let version = read_u32(r)?;
355
356    // Counts
357    let body_count = read_u32(r)? as usize;
358    let joint_count = read_u32(r)? as usize;
359
360    // Config
361    let substeps = read_u32(r)?;
362    let iterations = read_u32(r)?;
363    let gravity = read_i64_array::<6>(r)?;
364    let damping = read_i64_array::<2>(r)?;
365
366    let config = PhysicsConfig {
367        substeps,
368        iterations,
369        gravity,
370        damping,
371    };
372
373    // Bodies
374    // The counts come from the file: do not trust them for a reservation (a corrupt
375    // header must end in `UnexpectedEof`, not in a multi-gigabyte allocation).
376    let mut bodies = Vec::with_capacity(body_count.min(MAX_PREALLOC));
377    for _ in 0..body_count {
378        let position = read_i64_array::<6>(r)?;
379        let velocity = read_i64_array::<6>(r)?;
380        let rotation = read_i64_array::<8>(r)?;
381        let mass = read_i64_array::<2>(r)?;
382        let body_type = read_u8(r)?;
383        bodies.push(SerializedBody {
384            position,
385            velocity,
386            rotation,
387            mass,
388            body_type,
389        });
390    }
391
392    // Joints
393    let mut joints = Vec::with_capacity(joint_count.min(MAX_PREALLOC));
394    for _ in 0..joint_count {
395        let body_a = read_u32(r)?;
396        let body_b = read_u32(r)?;
397        let joint_type = read_u8(r)?;
398        let anchor_a = read_i64_array::<6>(r)?;
399        let anchor_b = read_i64_array::<6>(r)?;
400        joints.push(SerializedJoint {
401            body_a,
402            body_b,
403            joint_type,
404            anchor_a,
405            anchor_b,
406        });
407    }
408
409    Ok(PhysicsScene {
410        bodies,
411        joints,
412        config,
413        version,
414    })
415}
416
417// ============================================================================
418// JSON Format (manual, no serde)
419// ============================================================================
420
421/// Save a physics scene to a JSON file (human-readable).
422///
423/// # Errors
424///
425/// Returns an error if the file cannot be written.
426pub fn save_scene_json(scene: &PhysicsScene, path: &std::path::Path) -> std::io::Result<()> {
427    let json = scene_to_json(scene);
428    std::fs::write(path, json)
429}
430
431/// Load a physics scene from a JSON file.
432///
433/// # Errors
434///
435/// Returns an error if the file cannot be read or contains invalid JSON data.
436pub fn load_scene_json(path: &std::path::Path) -> std::io::Result<PhysicsScene> {
437    let json = std::fs::read_to_string(path)?;
438    parse_scene_json(&json).map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
439}
440
441fn i64_array_to_json(arr: &[i64]) -> String {
442    let items: Vec<String> = arr.iter().map(|v| format!("{v}")).collect();
443    format!("[{}]", items.join(", "))
444}
445
446fn scene_to_json(scene: &PhysicsScene) -> String {
447    fn write_json(s: &mut String, scene: &PhysicsScene) -> core::fmt::Result {
448        s.push_str("{\n");
449        writeln!(s, "  \"version\": {},", scene.version)?;
450
451        // Config
452        s.push_str("  \"config\": {\n");
453        writeln!(s, "    \"substeps\": {},", scene.config.substeps)?;
454        writeln!(s, "    \"iterations\": {},", scene.config.iterations)?;
455        writeln!(
456            s,
457            "    \"gravity\": {},",
458            i64_array_to_json(&scene.config.gravity)
459        )?;
460        writeln!(
461            s,
462            "    \"damping\": {}",
463            i64_array_to_json(&scene.config.damping)
464        )?;
465        s.push_str("  },\n");
466
467        // Bodies
468        s.push_str("  \"bodies\": [\n");
469        for (i, body) in scene.bodies.iter().enumerate() {
470            s.push_str("    {\n");
471            writeln!(
472                s,
473                "      \"position\": {},",
474                i64_array_to_json(&body.position)
475            )?;
476            writeln!(
477                s,
478                "      \"velocity\": {},",
479                i64_array_to_json(&body.velocity)
480            )?;
481            writeln!(
482                s,
483                "      \"rotation\": {},",
484                i64_array_to_json(&body.rotation)
485            )?;
486            writeln!(s, "      \"mass\": {},", i64_array_to_json(&body.mass))?;
487            writeln!(s, "      \"body_type\": {}", body.body_type)?;
488            if i < scene.bodies.len() - 1 {
489                s.push_str("    },\n");
490            } else {
491                s.push_str("    }\n");
492            }
493        }
494        s.push_str("  ],\n");
495
496        // Joints
497        s.push_str("  \"joints\": [\n");
498        for (i, joint) in scene.joints.iter().enumerate() {
499            s.push_str("    {\n");
500            writeln!(s, "      \"body_a\": {},", joint.body_a)?;
501            writeln!(s, "      \"body_b\": {},", joint.body_b)?;
502            writeln!(s, "      \"joint_type\": {},", joint.joint_type)?;
503            writeln!(
504                s,
505                "      \"anchor_a\": {},",
506                i64_array_to_json(&joint.anchor_a)
507            )?;
508            writeln!(
509                s,
510                "      \"anchor_b\": {}",
511                i64_array_to_json(&joint.anchor_b)
512            )?;
513            if i < scene.joints.len() - 1 {
514                s.push_str("    },\n");
515            } else {
516                s.push_str("    }\n");
517            }
518        }
519        s.push_str("  ]\n");
520
521        s.push_str("}\n");
522        Ok(())
523    }
524
525    let mut s = String::new();
526    // fmt::Write for String is infallible, but we use ? for clean control flow
527    let _ = write_json(&mut s, scene);
528    s
529}
530
531// ============================================================================
532// JSON Parser (minimal, no external dependencies)
533// ============================================================================
534
535fn parse_scene_json(json: &str) -> Result<PhysicsScene, String> {
536    let json = json.trim();
537    if !json.starts_with('{') || !json.ends_with('}') {
538        return Err("Expected JSON object".into());
539    }
540
541    let version = extract_u32(json, "version")?.unwrap_or(CURRENT_VERSION);
542
543    // Config
544    let config_str = extract_object(json, "config").unwrap_or_default();
545    let substeps = extract_u32(&config_str, "substeps")?.unwrap_or(8);
546    let iterations = extract_u32(&config_str, "iterations")?.unwrap_or(4);
547    let gravity = extract_i64_array(&config_str, "gravity", 6)?;
548    let damping = extract_i64_array(&config_str, "damping", 2)?;
549
550    let config = PhysicsConfig {
551        substeps,
552        iterations,
553        gravity: [
554            gravity[0], gravity[1], gravity[2], gravity[3], gravity[4], gravity[5],
555        ],
556        damping: [damping[0], damping[1]],
557    };
558
559    // Bodies
560    let bodies_str = extract_array(json, "bodies").unwrap_or_default();
561    let body_objects = split_array_objects(&bodies_str);
562    let mut bodies = Vec::new();
563    for obj in &body_objects {
564        let position_v = extract_i64_array(obj, "position", 6)?;
565        let velocity_v = extract_i64_array(obj, "velocity", 6)?;
566        let rotation_v = extract_i64_array(obj, "rotation", 8)?;
567        let mass_v = extract_i64_array(obj, "mass", 2)?;
568        let body_type = extract_u8(obj, "body_type")?.unwrap_or(0);
569
570        let mut position = [0i64; 6];
571        let mut velocity = [0i64; 6];
572        let mut rotation = [0i64; 8];
573        let mut mass = [0i64; 2];
574        position.copy_from_slice(&position_v);
575        velocity.copy_from_slice(&velocity_v);
576        rotation.copy_from_slice(&rotation_v);
577        mass.copy_from_slice(&mass_v);
578
579        bodies.push(SerializedBody {
580            position,
581            velocity,
582            rotation,
583            mass,
584            body_type,
585        });
586    }
587
588    // Joints
589    let joints_str = extract_array(json, "joints").unwrap_or_default();
590    let joint_objects = split_array_objects(&joints_str);
591    let mut joints = Vec::new();
592    for obj in &joint_objects {
593        let body_a = extract_u32(obj, "body_a")?.unwrap_or(0);
594        let body_b = extract_u32(obj, "body_b")?.unwrap_or(0);
595        let joint_type = extract_u8(obj, "joint_type")?.unwrap_or(0);
596        let anchor_a_v = extract_i64_array(obj, "anchor_a", 6)?;
597        let anchor_b_v = extract_i64_array(obj, "anchor_b", 6)?;
598
599        let mut anchor_a = [0i64; 6];
600        let mut anchor_b = [0i64; 6];
601        anchor_a.copy_from_slice(&anchor_a_v);
602        anchor_b.copy_from_slice(&anchor_b_v);
603
604        joints.push(SerializedJoint {
605            body_a,
606            body_b,
607            joint_type,
608            anchor_a,
609            anchor_b,
610        });
611    }
612
613    Ok(PhysicsScene {
614        bodies,
615        joints,
616        config,
617        version,
618    })
619}
620
621// ============================================================================
622// Minimal JSON extraction helpers
623// ============================================================================
624
625/// Read an unsigned integer value for `key`.
626///
627/// `Ok(None)` when the key (or its colon) is absent, so callers can apply their documented
628/// default. A value that is present but is not a `u32` (negative, too large, not a number)
629/// is an error: silently replacing it with the default would load a different scene than
630/// the file describes.
631fn extract_u32(json: &str, key: &str) -> Result<Option<u32>, String> {
632    let pattern = format!("\"{key}\"");
633    let Some(idx) = json.find(&pattern) else {
634        return Ok(None);
635    };
636    let rest = &json[idx + pattern.len()..];
637    let Some(colon) = rest.find(':') else {
638        return Ok(None);
639    };
640    let after_colon = rest[colon + 1..].trim_start();
641    // Token: everything up to the next JSON delimiter
642    let end = after_colon
643        .find([',', '}', ']', '\n', '\r'])
644        .unwrap_or(after_colon.len());
645    let token = after_colon[..end].trim();
646    token
647        .parse::<u32>()
648        .map(Some)
649        .map_err(|e| format!("Invalid value for {key}: {token:?} ({e})"))
650}
651
652/// Like [`extract_u32`] for fields stored as `u8`; a value above 255 is an error, not a truncation.
653fn extract_u8(json: &str, key: &str) -> Result<Option<u8>, String> {
654    match extract_u32(json, key)? {
655        None => Ok(None),
656        Some(v) => u8::try_from(v)
657            .map(Some)
658            .map_err(|_| format!("Value for {key} does not fit in u8: {v}")),
659    }
660}
661
662fn extract_object(json: &str, key: &str) -> Option<String> {
663    let pattern = format!("\"{key}\"");
664    let idx = json.find(&pattern)?;
665    let rest = &json[idx + pattern.len()..];
666    let brace = rest.find('{')?;
667    let start = brace;
668    let mut depth = 0i32;
669    let bytes = rest.as_bytes();
670    for (i, &b) in bytes[start..].iter().enumerate() {
671        if b == b'{' {
672            depth += 1;
673        }
674        if b == b'}' {
675            depth -= 1;
676        }
677        if depth == 0 {
678            return Some(rest[start..=(start + i)].to_string());
679        }
680    }
681    None
682}
683
684fn extract_array(json: &str, key: &str) -> Option<String> {
685    let pattern = format!("\"{key}\"");
686    let idx = json.find(&pattern)?;
687    let rest = &json[idx + pattern.len()..];
688    // Find the opening [ that follows the colon
689    let colon = rest.find(':')?;
690    let after_colon = &rest[colon + 1..];
691    let bracket = after_colon.find('[')?;
692    let start = bracket;
693    let mut depth = 0i32;
694    let bytes = after_colon.as_bytes();
695    for (i, &b) in bytes[start..].iter().enumerate() {
696        if b == b'[' {
697            depth += 1;
698        }
699        if b == b']' {
700            depth -= 1;
701        }
702        if depth == 0 {
703            return Some(after_colon[start..=(start + i)].to_string());
704        }
705    }
706    None
707}
708
709fn extract_i64_array(json: &str, key: &str, expected_len: usize) -> Result<Vec<i64>, String> {
710    let arr_str = extract_array(json, key).ok_or_else(|| format!("Missing key: {key}"))?;
711    // Parse [n1, n2, ...]
712    let inner = arr_str.trim_start_matches('[').trim_end_matches(']');
713    let values: Result<Vec<i64>, _> = inner.split(',').map(|s| s.trim().parse::<i64>()).collect();
714    let values = values.map_err(|e| format!("Parse error for {key}: {e}"))?;
715    if values.len() != expected_len {
716        return Err(format!(
717            "Expected {} values for {}, got {}",
718            expected_len,
719            key,
720            values.len()
721        ));
722    }
723    Ok(values)
724}
725
726fn split_array_objects(arr_str: &str) -> Vec<String> {
727    let inner = arr_str.trim_start_matches('[').trim_end_matches(']').trim();
728    if inner.is_empty() {
729        return Vec::new();
730    }
731
732    let mut objects = Vec::new();
733    let mut depth = 0i32;
734    let mut start = 0;
735    let bytes = inner.as_bytes();
736
737    for (i, &b) in bytes.iter().enumerate() {
738        if b == b'{' {
739            if depth == 0 {
740                start = i;
741            }
742            depth += 1;
743        }
744        if b == b'}' {
745            depth -= 1;
746            if depth == 0 {
747                objects.push(inner[start..=i].to_string());
748            }
749        }
750    }
751
752    objects
753}
754
755// ============================================================================
756// Tests
757// ============================================================================
758
759#[cfg(test)]
760mod tests {
761    use super::*;
762
763    fn make_test_scene() -> PhysicsScene {
764        PhysicsScene {
765            version: CURRENT_VERSION,
766            config: PhysicsConfig::default(),
767            bodies: vec![
768                SerializedBody {
769                    position: [0, 0, 10, 0, 0, 0],
770                    velocity: [0, 0, 0, 0, 0, 0],
771                    rotation: [0, 0, 0, 0, 0, 0, 1, 0],
772                    mass: [1, 0],
773                    body_type: 0,
774                },
775                SerializedBody {
776                    position: [5, 0, 0, 0, -3, 0],
777                    velocity: [1, 0, -1, 0, 0, 0],
778                    rotation: [0, 0, 0, 0, 0, 0, 1, 0],
779                    mass: [2, 0],
780                    body_type: 1,
781                },
782            ],
783            joints: vec![SerializedJoint {
784                body_a: 0,
785                body_b: 1,
786                joint_type: 0,
787                anchor_a: [0, 0, 0, 0, 0, 0],
788                anchor_b: [1, 0, 0, 0, 0, 0],
789            }],
790        }
791    }
792
793    #[test]
794    fn test_binary_roundtrip() {
795        let scene = make_test_scene();
796        let dir = tempfile::tempdir().unwrap();
797        let path = dir.path().join("test.aphys");
798
799        save_scene(&scene, &path).unwrap();
800        let loaded = load_scene(&path).unwrap();
801
802        assert_eq!(loaded.version, scene.version);
803        assert_eq!(loaded.bodies.len(), scene.bodies.len());
804        assert_eq!(loaded.joints.len(), scene.joints.len());
805        assert_eq!(loaded.config.substeps, scene.config.substeps);
806    }
807
808    #[test]
809    fn test_binary_body_data_preserved() {
810        let scene = make_test_scene();
811        let dir = tempfile::tempdir().unwrap();
812        let path = dir.path().join("test2.aphys");
813
814        save_scene(&scene, &path).unwrap();
815        let loaded = load_scene(&path).unwrap();
816
817        assert_eq!(loaded.bodies[0].position, scene.bodies[0].position);
818        assert_eq!(loaded.bodies[0].mass, scene.bodies[0].mass);
819        assert_eq!(loaded.bodies[0].body_type, scene.bodies[0].body_type);
820        assert_eq!(loaded.bodies[1].velocity, scene.bodies[1].velocity);
821    }
822
823    #[test]
824    fn test_binary_joint_data_preserved() {
825        let scene = make_test_scene();
826        let dir = tempfile::tempdir().unwrap();
827        let path = dir.path().join("test3.aphys");
828
829        save_scene(&scene, &path).unwrap();
830        let loaded = load_scene(&path).unwrap();
831
832        assert_eq!(loaded.joints[0].body_a, 0);
833        assert_eq!(loaded.joints[0].body_b, 1);
834        assert_eq!(loaded.joints[0].joint_type, 0);
835        assert_eq!(loaded.joints[0].anchor_b, scene.joints[0].anchor_b);
836    }
837
838    #[test]
839    fn test_binary_invalid_magic() {
840        let dir = tempfile::tempdir().unwrap();
841        let path = dir.path().join("bad.aphys");
842        std::fs::write(&path, b"BADMG\0").unwrap();
843
844        let result = load_scene(&path);
845        assert!(result.is_err());
846    }
847
848    #[test]
849    fn test_json_roundtrip() {
850        let scene = make_test_scene();
851        let dir = tempfile::tempdir().unwrap();
852        let path = dir.path().join("test.json");
853
854        save_scene_json(&scene, &path).unwrap();
855        let loaded = load_scene_json(&path).unwrap();
856
857        assert_eq!(loaded.version, scene.version);
858        assert_eq!(loaded.bodies.len(), scene.bodies.len());
859        assert_eq!(loaded.joints.len(), scene.joints.len());
860    }
861
862    #[test]
863    fn test_json_body_data_preserved() {
864        let scene = make_test_scene();
865        let dir = tempfile::tempdir().unwrap();
866        let path = dir.path().join("test2.json");
867
868        save_scene_json(&scene, &path).unwrap();
869        let loaded = load_scene_json(&path).unwrap();
870
871        assert_eq!(loaded.bodies[0].position, scene.bodies[0].position);
872        assert_eq!(loaded.bodies[1].velocity, scene.bodies[1].velocity);
873        assert_eq!(loaded.bodies[0].rotation, scene.bodies[0].rotation);
874    }
875
876    #[test]
877    fn test_json_config_preserved() {
878        let scene = make_test_scene();
879        let dir = tempfile::tempdir().unwrap();
880        let path = dir.path().join("test3.json");
881
882        save_scene_json(&scene, &path).unwrap();
883        let loaded = load_scene_json(&path).unwrap();
884
885        assert_eq!(loaded.config.substeps, scene.config.substeps);
886        assert_eq!(loaded.config.iterations, scene.config.iterations);
887        assert_eq!(loaded.config.gravity, scene.config.gravity);
888        assert_eq!(loaded.config.damping, scene.config.damping);
889    }
890
891    #[test]
892    fn test_json_empty_scene() {
893        let scene = PhysicsScene {
894            version: CURRENT_VERSION,
895            config: PhysicsConfig::default(),
896            bodies: vec![],
897            joints: vec![],
898        };
899        let dir = tempfile::tempdir().unwrap();
900        let path = dir.path().join("empty.json");
901
902        save_scene_json(&scene, &path).unwrap();
903        let loaded = load_scene_json(&path).unwrap();
904
905        assert!(loaded.bodies.is_empty());
906        assert!(loaded.joints.is_empty());
907    }
908
909    #[test]
910    fn test_binary_empty_scene() {
911        let scene = PhysicsScene {
912            version: CURRENT_VERSION,
913            config: PhysicsConfig::default(),
914            bodies: vec![],
915            joints: vec![],
916        };
917        let dir = tempfile::tempdir().unwrap();
918        let path = dir.path().join("empty.aphys");
919
920        save_scene(&scene, &path).unwrap();
921        let loaded = load_scene(&path).unwrap();
922
923        assert!(loaded.bodies.is_empty());
924        assert!(loaded.joints.is_empty());
925    }
926
927    #[test]
928    fn test_fix128_raw_roundtrip() {
929        let val = Fix128::from_ratio(355, 113); // approx pi
930        let raw = fix128_to_raw(val);
931        let restored = raw_to_fix128(&raw);
932        assert_eq!(val.hi, restored.hi);
933        assert_eq!(val.lo, restored.lo);
934    }
935
936    #[test]
937    fn test_vec3fix_raw_roundtrip() {
938        let val = Vec3Fix::new(Fix128::from_int(42), Fix128::from_ratio(-7, 3), Fix128::PI);
939        let raw = vec3fix_to_raw(val);
940        let restored = raw_to_vec3fix(&raw);
941        assert_eq!(val.x.hi, restored.x.hi);
942        assert_eq!(val.x.lo, restored.x.lo);
943        assert_eq!(val.y.hi, restored.y.hi);
944        assert_eq!(val.z.hi, restored.z.hi);
945    }
946
947    #[test]
948    fn test_binary_negative_values() {
949        let scene = PhysicsScene {
950            version: CURRENT_VERSION,
951            config: PhysicsConfig::default(),
952            bodies: vec![SerializedBody {
953                position: [-100, 0, -200, 0, -300, 0],
954                velocity: [-1, 0, -2, 0, -3, 0],
955                rotation: [0, 0, 0, 0, 0, 0, 1, 0],
956                mass: [5, 0],
957                body_type: 2,
958            }],
959            joints: vec![],
960        };
961        let dir = tempfile::tempdir().unwrap();
962        let path = dir.path().join("neg.aphys");
963
964        save_scene(&scene, &path).unwrap();
965        let loaded = load_scene(&path).unwrap();
966
967        assert_eq!(loaded.bodies[0].position[0], -100);
968        assert_eq!(loaded.bodies[0].position[2], -200);
969        assert_eq!(loaded.bodies[0].body_type, 2);
970    }
971
972    #[test]
973    fn test_json_negative_values() {
974        let scene = PhysicsScene {
975            version: CURRENT_VERSION,
976            config: PhysicsConfig::default(),
977            bodies: vec![SerializedBody {
978                position: [-10, 0, -20, 0, -30, 0],
979                velocity: [0, 0, 0, 0, 0, 0],
980                rotation: [0, 0, 0, 0, 0, 0, 1, 0],
981                mass: [1, 0],
982                body_type: 0,
983            }],
984            joints: vec![],
985        };
986        let dir = tempfile::tempdir().unwrap();
987        let path = dir.path().join("neg.json");
988
989        save_scene_json(&scene, &path).unwrap();
990        let loaded = load_scene_json(&path).unwrap();
991
992        assert_eq!(loaded.bodies[0].position[0], -10);
993        assert_eq!(loaded.bodies[0].position[2], -20);
994    }
995
996    #[test]
997    fn test_multiple_joints_roundtrip() {
998        let scene = PhysicsScene {
999            version: CURRENT_VERSION,
1000            config: PhysicsConfig::default(),
1001            bodies: vec![SerializedBody {
1002                position: [0; 6],
1003                velocity: [0; 6],
1004                rotation: [0, 0, 0, 0, 0, 0, 1, 0],
1005                mass: [1, 0],
1006                body_type: 0,
1007            }],
1008            joints: vec![
1009                SerializedJoint {
1010                    body_a: 0,
1011                    body_b: 0,
1012                    joint_type: 1,
1013                    anchor_a: [1, 0, 2, 0, 3, 0],
1014                    anchor_b: [4, 0, 5, 0, 6, 0],
1015                },
1016                SerializedJoint {
1017                    body_a: 0,
1018                    body_b: 0,
1019                    joint_type: 4,
1020                    anchor_a: [7, 0, 8, 0, 9, 0],
1021                    anchor_b: [10, 0, 11, 0, 12, 0],
1022                },
1023            ],
1024        };
1025        let dir = tempfile::tempdir().unwrap();
1026        let path = dir.path().join("multi_joint.aphys");
1027
1028        save_scene(&scene, &path).unwrap();
1029        let loaded = load_scene(&path).unwrap();
1030
1031        assert_eq!(loaded.joints.len(), 2);
1032        assert_eq!(loaded.joints[0].joint_type, 1);
1033        assert_eq!(loaded.joints[1].joint_type, 4);
1034        assert_eq!(loaded.joints[1].anchor_a[0], 7);
1035    }
1036
1037    #[test]
1038    fn test_scene_to_json_format() {
1039        let scene = make_test_scene();
1040        let json = scene_to_json(&scene);
1041        assert!(json.contains("\"version\""));
1042        assert!(json.contains("\"bodies\""));
1043        assert!(json.contains("\"joints\""));
1044        assert!(json.contains("\"config\""));
1045    }
1046}