Skip to main content

boxddd/
recording.rs

1use crate::core::{box3d_lock, callback_state};
2use crate::debug_draw::{
3    CollectDebugDraw, DebugDraw, DebugDrawCommand, DebugDrawOptions, with_debug_draw,
4};
5use crate::error::{Error, Result};
6use crate::query::QueryFilter;
7use crate::types::{Aabb, BodyId, Pos, ShapeId, Vec3};
8use crate::world::World;
9use boxddd_sys::ffi;
10use std::ffi::{CStr, CString};
11use std::fs::File;
12use std::io::Write;
13use std::marker::PhantomData;
14use std::path::Path;
15use std::ptr::NonNull;
16use std::rc::Rc;
17use std::sync::{Mutex, OnceLock};
18
19#[derive(Copy, Clone, Debug)]
20struct ActiveRecording {
21    world: ffi::b3WorldId,
22    recording: usize,
23}
24
25static ACTIVE_RECORDINGS: OnceLock<Mutex<Vec<ActiveRecording>>> = OnceLock::new();
26
27fn active_recordings() -> &'static Mutex<Vec<ActiveRecording>> {
28    ACTIVE_RECORDINGS.get_or_init(|| Mutex::new(Vec::new()))
29}
30
31fn prune_inactive_recordings_locked(records: &mut Vec<ActiveRecording>) {
32    records.retain(|record| unsafe { ffi::b3World_IsValid(record.world) });
33}
34
35fn recording_key(recording: NonNull<ffi::b3Recording>) -> usize {
36    recording.as_ptr() as usize
37}
38
39fn active_recording_for_world_locked(world: ffi::b3WorldId) -> Option<usize> {
40    let mut records = active_recordings()
41        .lock()
42        .expect("Box3D recording registry lock poisoned");
43    prune_inactive_recordings_locked(&mut records);
44    records
45        .iter()
46        .find(|record| same_world_id(record.world, world))
47        .map(|record| record.recording)
48}
49
50fn insert_active_recording_locked(world: ffi::b3WorldId, recording: NonNull<ffi::b3Recording>) {
51    let mut records = active_recordings()
52        .lock()
53        .expect("Box3D recording registry lock poisoned");
54    prune_inactive_recordings_locked(&mut records);
55    records.push(ActiveRecording {
56        world,
57        recording: recording_key(recording),
58    });
59}
60
61fn remove_active_recording_locked(recording: NonNull<ffi::b3Recording>) {
62    let key = recording_key(recording);
63    let mut records = active_recordings()
64        .lock()
65        .expect("Box3D recording registry lock poisoned");
66    records.retain(|record| record.recording != key);
67}
68
69fn active_recording_matches_locked(
70    world: ffi::b3WorldId,
71    recording: NonNull<ffi::b3Recording>,
72) -> bool {
73    active_recording_for_world_locked(world) == Some(recording_key(recording))
74}
75
76pub(crate) fn detach_world_recording_locked(world: ffi::b3WorldId) {
77    let mut records = active_recordings()
78        .lock()
79        .expect("Box3D recording registry lock poisoned");
80    records.retain(|record| !same_world_id(record.world, world));
81}
82
83/// Owning handle for a Box3D recording stream.
84#[derive(Debug)]
85pub struct Recording {
86    raw: NonNull<ffi::b3Recording>,
87    active_world: Option<ffi::b3WorldId>,
88    _not_send_sync: PhantomData<Rc<()>>,
89}
90
91impl Recording {
92    /// Creates a recording buffer with Box3D's default initial capacity.
93    pub fn new() -> Result<Self> {
94        Self::with_capacity(0)
95    }
96
97    /// Creates a recording buffer with an optional initial byte capacity.
98    pub fn with_capacity(byte_capacity: usize) -> Result<Self> {
99        let byte_capacity = i32::try_from(byte_capacity).map_err(|_| Error::InvalidArgument)?;
100        callback_state::check_not_in_callback()?;
101        let _guard = box3d_lock::lock();
102        let raw = unsafe { ffi::b3CreateRecording(byte_capacity) };
103        Ok(Self {
104            raw: NonNull::new(raw).ok_or(Error::CreateRecordingFailed)?,
105            active_world: None,
106            _not_send_sync: PhantomData,
107        })
108    }
109
110    /// Loads a recording buffer from a file.
111    pub fn load_from_file(path: impl AsRef<Path>) -> Result<Self> {
112        let path = path_to_cstring(path)?;
113        callback_state::check_not_in_callback()?;
114        let _guard = box3d_lock::lock();
115        let raw = unsafe { ffi::b3LoadRecordingFromFile(path.as_ptr()) };
116        Ok(Self {
117            raw: NonNull::new(raw).ok_or(Error::RecordingIoFailed)?,
118            active_world: None,
119            _not_send_sync: PhantomData,
120        })
121    }
122
123    /// Saves the current recording bytes to a file.
124    ///
125    /// This fails while the recording is attached to a live world because Box3D
126    /// may still be mutating the backing buffer.
127    pub fn save_to_file(&self, path: impl AsRef<Path>) -> Result<()> {
128        let path = path.as_ref();
129        callback_state::check_not_in_callback()?;
130        let bytes = {
131            let _guard = box3d_lock::lock();
132            if self.active_world_is_valid_locked() {
133                return Err(Error::ResourceLifetimeViolation);
134            }
135            unsafe { self.bytes_locked() }.to_vec()
136        };
137
138        let mut file = File::create(path).map_err(|_| Error::RecordingIoFailed)?;
139        file.write_all(&bytes)
140            .and_then(|_| file.flush())
141            .map_err(|_| Error::RecordingIoFailed)
142    }
143
144    /// Returns the number of bytes currently stored in the recording buffer.
145    pub fn len(&self) -> usize {
146        let _guard = box3d_lock::lock();
147        unsafe { ffi::b3Recording_GetSize(self.raw.as_ptr()) }.max(0) as usize
148    }
149
150    /// Returns whether the recording buffer currently has no bytes.
151    pub fn is_empty(&self) -> bool {
152        self.len() == 0
153    }
154
155    /// Borrows the raw recording bytes.
156    ///
157    /// The returned slice is tied to `self` and is unavailable while recording is
158    /// active on a live world.
159    pub fn bytes(&self) -> Result<&[u8]> {
160        callback_state::check_not_in_callback()?;
161        let _guard = box3d_lock::lock();
162        if self.active_world_is_valid_locked() {
163            return Err(Error::ResourceLifetimeViolation);
164        }
165        Ok(unsafe { self.bytes_locked() })
166    }
167
168    /// Copies the raw recording bytes into an owned vector.
169    pub fn to_vec(&self) -> Result<Vec<u8>> {
170        Ok(self.bytes()?.to_vec())
171    }
172
173    /// Validates that this recording can replay with the requested worker count.
174    pub fn validate_replay(&self, worker_count: i32) -> Result<bool> {
175        validate_replay_bytes(self.bytes()?, worker_count)
176    }
177
178    /// Creates a replay player from this recording.
179    pub fn create_player(&self, worker_count: i32) -> Result<RecPlayer> {
180        RecPlayer::from_bytes(self.bytes()?, worker_count)
181    }
182
183    #[inline]
184    fn active_world_is_valid_locked(&self) -> bool {
185        self.active_world
186            .is_some_and(|world| unsafe { ffi::b3World_IsValid(world) })
187    }
188
189    #[inline]
190    fn clear_inactive_world_locked(&mut self) {
191        if self
192            .active_world
193            .is_some_and(|world| !unsafe { ffi::b3World_IsValid(world) })
194        {
195            self.active_world = None;
196        }
197    }
198
199    #[inline]
200    unsafe fn bytes_locked(&self) -> &[u8] {
201        let size = unsafe { ffi::b3Recording_GetSize(self.raw.as_ptr()) }.max(0) as usize;
202        let data = unsafe { ffi::b3Recording_GetData(self.raw.as_ptr()) };
203        if data.is_null() || size == 0 {
204            &[]
205        } else {
206            unsafe { std::slice::from_raw_parts(data, size) }
207        }
208    }
209}
210
211impl Drop for Recording {
212    fn drop(&mut self) {
213        let _guard = box3d_lock::lock();
214        if let Some(world) = self.active_world.take()
215            && unsafe { ffi::b3World_IsValid(world) }
216            && active_recording_matches_locked(world, self.raw)
217        {
218            unsafe { ffi::b3World_StopRecording(world) };
219        }
220        remove_active_recording_locked(self.raw);
221        unsafe { ffi::b3DestroyRecording(self.raw.as_ptr()) };
222    }
223}
224
225/// Kind of query captured in a recording.
226#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
227#[derive(Copy, Clone, Debug, PartialEq, Eq)]
228pub enum RecQueryType {
229    /// Axis-aligned bounding-box overlap query.
230    OverlapAabb,
231    /// Shape overlap query.
232    OverlapShape,
233    /// Ray-cast query.
234    CastRay,
235    /// Shape-cast query.
236    CastShape,
237    /// Closest-hit ray-cast query.
238    CastRayClosest,
239    /// Capsule mover cast query.
240    CastMover,
241    /// Capsule mover collision-plane query.
242    CollideMover,
243}
244
245impl RecQueryType {
246    /// Converts raw Box3D data into the safe value type.
247    pub const fn from_raw(raw: ffi::b3RecQueryType) -> Option<Self> {
248        match raw {
249            ffi::b3RecQueryType_b3_recQueryOverlapAABB => Some(Self::OverlapAabb),
250            ffi::b3RecQueryType_b3_recQueryOverlapShape => Some(Self::OverlapShape),
251            ffi::b3RecQueryType_b3_recQueryCastRay => Some(Self::CastRay),
252            ffi::b3RecQueryType_b3_recQueryCastShape => Some(Self::CastShape),
253            ffi::b3RecQueryType_b3_recQueryCastRayClosest => Some(Self::CastRayClosest),
254            ffi::b3RecQueryType_b3_recQueryCastMover => Some(Self::CastMover),
255            ffi::b3RecQueryType_b3_recQueryCollideMover => Some(Self::CollideMover),
256            _ => None,
257        }
258    }
259}
260
261/// Metadata stored by a recording replay player.
262#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
263#[derive(Clone, Debug, PartialEq)]
264pub struct RecPlayerInfo {
265    /// Number of recorded frames.
266    pub frame_count: i32,
267    /// Worker count used by the recording.
268    pub worker_count: i32,
269    /// Simulation time step for the frame.
270    pub time_step: f32,
271    /// Sub-step count used by the recording.
272    pub sub_step_count: i32,
273    /// Length scale used by the recording.
274    pub length_scale: f32,
275    /// Bounds stored in the recording metadata.
276    pub bounds: Aabb,
277}
278
279impl RecPlayerInfo {
280    /// Converts raw Box3D data into the safe value type.
281    #[inline]
282    pub fn from_raw(raw: ffi::b3RecPlayerInfo) -> Self {
283        Self {
284            frame_count: raw.frameCount,
285            worker_count: raw.workerCount,
286            time_step: raw.timeStep,
287            sub_step_count: raw.subStepCount,
288            length_scale: raw.lengthScale,
289            bounds: Aabb::from_raw(raw.bounds),
290        }
291    }
292}
293
294/// Metadata for a query captured in the current replay frame.
295#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
296#[derive(Clone, Debug, PartialEq)]
297pub struct RecQueryInfo {
298    /// Recorded query kind.
299    pub query_type: RecQueryType,
300    /// Collision filter used by the query.
301    pub filter: QueryFilter,
302    /// Axis-aligned bounding box.
303    pub aabb: Aabb,
304    /// Query origin.
305    pub origin: Pos,
306    /// Translation vector used by the query.
307    pub translation: Vec3,
308    /// Number of hits recorded for the query.
309    pub hit_count: i32,
310    /// Recording key.
311    pub key: u64,
312    /// User-defined query identifier.
313    pub id: u64,
314    /// Recorded name.
315    pub name: Option<String>,
316}
317
318impl RecQueryInfo {
319    fn from_raw(raw: ffi::b3RecQueryInfo) -> Result<Self> {
320        Ok(Self {
321            query_type: RecQueryType::from_raw(raw.type_).ok_or(Error::InvalidArgument)?,
322            filter: QueryFilter {
323                category_bits: raw.filter.categoryBits,
324                mask_bits: raw.filter.maskBits,
325                id: raw.filter.id,
326            },
327            aabb: Aabb::from_raw(raw.aabb),
328            origin: Pos::from_raw(raw.origin),
329            translation: Vec3::from_raw(raw.translation),
330            hit_count: raw.hitCount,
331            key: raw.key,
332            id: raw.id,
333            name: if raw.name.is_null() {
334                None
335            } else {
336                Some(
337                    unsafe { CStr::from_ptr(raw.name) }
338                        .to_string_lossy()
339                        .into_owned(),
340                )
341            },
342        })
343    }
344}
345
346/// Hit recorded for a replay-frame query.
347#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
348#[derive(Copy, Clone, Debug, PartialEq)]
349pub struct RecQueryHit {
350    /// Shape associated with the result.
351    pub shape_id: ShapeId,
352    /// World-space point for the result.
353    pub point: Pos,
354    /// World-space normal for the result.
355    pub normal: Vec3,
356    /// Fraction along the query translation.
357    pub fraction: f32,
358}
359
360impl RecQueryHit {
361    #[inline]
362    fn from_raw(raw: ffi::b3RecQueryHit) -> Self {
363        Self {
364            shape_id: ShapeId::from_raw(raw.shape),
365            point: Pos::from_raw(raw.point),
366            normal: Vec3::from_raw(raw.normal),
367            fraction: raw.fraction,
368        }
369    }
370}
371
372/// World id created by a replay player.
373#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
374#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
375pub struct ReplayWorldId {
376    /// Native Box3D handle index.
377    pub index1: u16,
378    /// Handle generation value.
379    pub generation: u16,
380}
381
382impl ReplayWorldId {
383    /// Converts raw Box3D data into the safe value type.
384    #[inline]
385    pub const fn from_raw(raw: ffi::b3WorldId) -> Self {
386        Self {
387            index1: raw.index1,
388            generation: raw.generation,
389        }
390    }
391
392    /// Converts this value into the raw Box3D representation.
393    #[inline]
394    pub const fn into_raw(self) -> ffi::b3WorldId {
395        ffi::b3WorldId {
396            index1: self.index1,
397            generation: self.generation,
398        }
399    }
400
401    /// Returns whether the Box3D handle is still valid.
402    pub fn is_valid(self) -> bool {
403        let _guard = box3d_lock::lock();
404        unsafe { ffi::b3World_IsValid(self.into_raw()) }
405    }
406}
407
408/// Player used to replay and inspect a Box3D recording.
409#[derive(Debug)]
410pub struct RecPlayer {
411    raw: NonNull<ffi::b3RecPlayer>,
412    _not_send_sync: PhantomData<Rc<()>>,
413}
414
415impl RecPlayer {
416    /// Creates a replay player from raw recording bytes.
417    pub fn from_bytes(bytes: &[u8], worker_count: i32) -> Result<Self> {
418        validate_replay_input(bytes, worker_count)?;
419        callback_state::check_not_in_callback()?;
420        let _guard = box3d_lock::lock();
421        let raw = unsafe {
422            ffi::b3RecPlayer_Create(bytes.as_ptr().cast(), bytes.len() as i32, worker_count)
423        };
424        Ok(Self {
425            raw: NonNull::new(raw).ok_or(Error::CreateRecPlayerFailed)?,
426            _not_send_sync: PhantomData,
427        })
428    }
429
430    /// Returns the world id owned by the replay player.
431    pub fn world_id(&self) -> ReplayWorldId {
432        let _guard = box3d_lock::lock();
433        ReplayWorldId::from_raw(unsafe { ffi::b3RecPlayer_GetWorldId(self.raw.as_ptr()) })
434    }
435
436    /// Steps the replay by one recorded frame.
437    pub fn step_frame(&mut self) -> Result<bool> {
438        callback_state::check_not_in_callback()?;
439        let _guard = box3d_lock::lock();
440        Ok(unsafe { ffi::b3RecPlayer_StepFrame(self.raw.as_ptr()) })
441    }
442
443    /// Restarts replay from the first recorded frame.
444    pub fn restart(&mut self) -> Result<()> {
445        callback_state::check_not_in_callback()?;
446        let _guard = box3d_lock::lock();
447        unsafe { ffi::b3RecPlayer_Restart(self.raw.as_ptr()) };
448        Ok(())
449    }
450
451    /// Seeks replay to a recorded frame.
452    pub fn seek_frame(&mut self, target_frame: i32) -> Result<()> {
453        if target_frame < 0 {
454            return Err(Error::InvalidArgument);
455        }
456        callback_state::check_not_in_callback()?;
457        let _guard = box3d_lock::lock();
458        unsafe { ffi::b3RecPlayer_SeekFrame(self.raw.as_ptr(), target_frame) };
459        Ok(())
460    }
461
462    /// Returns the current replay frame index.
463    pub fn frame(&self) -> i32 {
464        let _guard = box3d_lock::lock();
465        unsafe { ffi::b3RecPlayer_GetFrame(self.raw.as_ptr()) }
466    }
467
468    /// Returns the number of recorded frames.
469    pub fn frame_count(&self) -> i32 {
470        let _guard = box3d_lock::lock();
471        unsafe { ffi::b3RecPlayer_GetFrameCount(self.raw.as_ptr()) }
472    }
473
474    /// Returns whether replay has reached the end of the recording.
475    pub fn is_at_end(&self) -> bool {
476        let _guard = box3d_lock::lock();
477        unsafe { ffi::b3RecPlayer_IsAtEnd(self.raw.as_ptr()) }
478    }
479
480    /// Returns whether replay has diverged from the recorded state.
481    pub fn has_diverged(&self) -> bool {
482        let _guard = box3d_lock::lock();
483        unsafe { ffi::b3RecPlayer_HasDiverged(self.raw.as_ptr()) }
484    }
485
486    /// Returns metadata for the loaded recording.
487    pub fn info(&self) -> RecPlayerInfo {
488        let _guard = box3d_lock::lock();
489        RecPlayerInfo::from_raw(unsafe { ffi::b3RecPlayer_GetInfo(self.raw.as_ptr()) })
490    }
491
492    /// Returns the first frame where replay diverged, if any.
493    pub fn diverge_frame(&self) -> Option<i32> {
494        let _guard = box3d_lock::lock();
495        let frame = unsafe { ffi::b3RecPlayer_GetDivergeFrame(self.raw.as_ptr()) };
496        (frame >= 0).then_some(frame)
497    }
498
499    /// Sets the worker count.
500    pub fn set_worker_count(&mut self, count: i32) -> Result<()> {
501        validate_replay_worker_count(count)?;
502        callback_state::check_not_in_callback()?;
503        let _guard = box3d_lock::lock();
504        unsafe { ffi::b3RecPlayer_SetWorkerCount(self.raw.as_ptr(), count) };
505        Ok(())
506    }
507
508    /// Sets the keyframe policy.
509    pub fn set_keyframe_policy(
510        &mut self,
511        budget_bytes: usize,
512        min_interval_frames: i32,
513    ) -> Result<()> {
514        if min_interval_frames < 0 {
515            return Err(Error::InvalidArgument);
516        }
517        callback_state::check_not_in_callback()?;
518        let _guard = box3d_lock::lock();
519        unsafe {
520            ffi::b3RecPlayer_SetKeyframePolicy(self.raw.as_ptr(), budget_bytes, min_interval_frames)
521        };
522        Ok(())
523    }
524
525    /// Returns the configured replay keyframe memory budget.
526    pub fn keyframe_budget(&self) -> usize {
527        let _guard = box3d_lock::lock();
528        unsafe { ffi::b3RecPlayer_GetKeyframeBudget(self.raw.as_ptr()) }
529    }
530
531    /// Returns the minimum frame interval between replay keyframes.
532    pub fn keyframe_min_interval(&self) -> i32 {
533        let _guard = box3d_lock::lock();
534        unsafe { ffi::b3RecPlayer_GetKeyframeMinInterval(self.raw.as_ptr()) }
535    }
536
537    /// Returns the current frame interval between replay keyframes.
538    pub fn keyframe_interval(&self) -> i32 {
539        let _guard = box3d_lock::lock();
540        unsafe { ffi::b3RecPlayer_GetKeyframeInterval(self.raw.as_ptr()) }
541    }
542
543    /// Returns the bytes currently used by replay keyframes.
544    pub fn keyframe_bytes(&self) -> usize {
545        let _guard = box3d_lock::lock();
546        unsafe { ffi::b3RecPlayer_GetKeyframeBytes(self.raw.as_ptr()) }
547    }
548
549    /// Returns the number of bodies in the replay world.
550    pub fn body_count(&self) -> i32 {
551        let _guard = box3d_lock::lock();
552        unsafe { ffi::b3RecPlayer_GetBodyCount(self.raw.as_ptr()) }
553    }
554
555    /// Returns a body id from the replay world by index.
556    pub fn body_id(&self, index: i32) -> Result<Option<BodyId>> {
557        if index < 0 {
558            return Err(Error::IndexOutOfRange);
559        }
560        let _guard = box3d_lock::lock();
561        let raw = unsafe { ffi::b3RecPlayer_GetBodyId(self.raw.as_ptr(), index) };
562        if raw.index1 == 0 {
563            Ok(None)
564        } else {
565            Ok(Some(BodyId::from_raw(raw)))
566        }
567    }
568
569    /// Returns the number of queries captured for the current replay frame.
570    pub fn frame_query_count(&self) -> i32 {
571        let _guard = box3d_lock::lock();
572        unsafe { ffi::b3RecPlayer_GetFrameQueryCount(self.raw.as_ptr()) }
573    }
574
575    /// Returns metadata for a captured query in the current replay frame.
576    pub fn frame_query(&self, index: i32) -> Result<RecQueryInfo> {
577        if index < 0 || index >= self.frame_query_count() {
578            return Err(Error::IndexOutOfRange);
579        }
580        let _guard = box3d_lock::lock();
581        RecQueryInfo::from_raw(unsafe { ffi::b3RecPlayer_GetFrameQuery(self.raw.as_ptr(), index) })
582    }
583
584    /// Returns a hit recorded for a captured frame query.
585    pub fn frame_query_hit(&self, query_index: i32, hit_index: i32) -> Result<RecQueryHit> {
586        let query = self.frame_query(query_index)?;
587        if hit_index < 0 || hit_index >= query.hit_count {
588            return Err(Error::IndexOutOfRange);
589        }
590        let _guard = box3d_lock::lock();
591        Ok(RecQueryHit::from_raw(unsafe {
592            ffi::b3RecPlayer_GetFrameQueryHit(self.raw.as_ptr(), query_index, hit_index)
593        }))
594    }
595
596    /// Collects debug draw commands for recorded frame queries.
597    pub fn draw_frame_queries_collect(
598        &mut self,
599        options: DebugDrawOptions,
600        query_index: Option<i32>,
601        selected_index: Option<i32>,
602    ) -> Result<Vec<DebugDrawCommand>> {
603        let mut commands = Vec::new();
604        self.draw_frame_queries_collect_into(&mut commands, options, query_index, selected_index)?;
605        Ok(commands)
606    }
607
608    /// Collects debug draw commands for recorded frame queries into `out`.
609    pub fn draw_frame_queries_collect_into(
610        &mut self,
611        out: &mut Vec<DebugDrawCommand>,
612        options: DebugDrawOptions,
613        query_index: Option<i32>,
614        selected_index: Option<i32>,
615    ) -> Result<()> {
616        out.clear();
617        let mut collector = CollectDebugDraw::new(out);
618        self.draw_frame_queries(&mut collector, options, query_index, selected_index)?;
619        collector.finish();
620        Ok(())
621    }
622
623    /// Draws recorded frame queries with a custom debug draw sink.
624    pub fn draw_frame_queries(
625        &mut self,
626        drawer: &mut impl DebugDraw,
627        options: DebugDrawOptions,
628        query_index: Option<i32>,
629        selected_index: Option<i32>,
630    ) -> Result<()> {
631        callback_state::check_not_in_callback()?;
632        let query_index = checked_optional_index(query_index)?;
633        let selected_index = checked_optional_index(selected_index)?;
634        if query_index >= 0 && query_index >= self.frame_query_count() {
635            return Err(Error::IndexOutOfRange);
636        }
637        with_debug_draw(drawer, options, |draw| {
638            let _guard = box3d_lock::lock();
639            unsafe {
640                ffi::b3RecPlayer_DrawFrameQueries(
641                    self.raw.as_ptr(),
642                    draw,
643                    query_index,
644                    selected_index,
645                )
646            };
647            Ok(())
648        })
649    }
650}
651
652impl Drop for RecPlayer {
653    fn drop(&mut self) {
654        let _guard = box3d_lock::lock();
655        unsafe { ffi::b3RecPlayer_Destroy(self.raw.as_ptr()) };
656    }
657}
658
659impl World {
660    /// Tries to begin recording world mutations into the provided buffer.
661    pub fn try_start_recording(&mut self, recording: &mut Recording) -> Result<()> {
662        callback_state::check_not_in_callback()?;
663        let _guard = box3d_lock::lock();
664        self.check_world_valid_locked()?;
665        recording.clear_inactive_world_locked();
666        if recording.active_world.is_some() {
667            return Err(Error::ResourceLifetimeViolation);
668        }
669        if active_recording_for_world_locked(self.raw()).is_some() {
670            return Err(Error::ResourceLifetimeViolation);
671        }
672        unsafe { ffi::b3World_StartRecording(self.raw(), recording.raw.as_ptr()) };
673        insert_active_recording_locked(self.raw(), recording.raw);
674        recording.active_world = Some(self.raw());
675        Ok(())
676    }
677
678    /// Starts recording world mutations or panics if Box3D rejects the request.
679    pub fn start_recording(&mut self, recording: &mut Recording) {
680        self.try_start_recording(recording)
681            .expect("Box3D failed to start recording");
682    }
683
684    /// Tries to stop recording world mutations into the provided buffer.
685    pub fn try_stop_recording(&mut self, recording: &mut Recording) -> Result<()> {
686        callback_state::check_not_in_callback()?;
687        let _guard = box3d_lock::lock();
688        self.check_world_valid_locked()?;
689        recording.clear_inactive_world_locked();
690        let Some(active_world) = recording.active_world else {
691            return Err(Error::ResourceLifetimeViolation);
692        };
693        if !same_world_id(active_world, self.raw())
694            || !active_recording_matches_locked(self.raw(), recording.raw)
695        {
696            return Err(Error::ResourceLifetimeViolation);
697        }
698        unsafe { ffi::b3World_StopRecording(self.raw()) };
699        remove_active_recording_locked(recording.raw);
700        recording.active_world = None;
701        Ok(())
702    }
703
704    /// Stops recording world mutations or panics if Box3D rejects the request.
705    pub fn stop_recording(&mut self, recording: &mut Recording) {
706        self.try_stop_recording(recording)
707            .expect("Box3D failed to stop recording");
708    }
709}
710
711/// Validates that raw recording bytes can replay with the requested worker count.
712pub fn validate_replay_bytes(bytes: &[u8], worker_count: i32) -> Result<bool> {
713    validate_replay_input(bytes, worker_count)?;
714    callback_state::check_not_in_callback()?;
715    let _guard = box3d_lock::lock();
716    Ok(unsafe { ffi::b3ValidateReplay(bytes.as_ptr().cast(), bytes.len() as i32, worker_count) })
717}
718
719fn validate_replay_input(bytes: &[u8], worker_count: i32) -> Result<()> {
720    validate_replay_worker_count(worker_count)?;
721    if bytes.is_empty() || bytes.len() > i32::MAX as usize {
722        Err(Error::InvalidArgument)
723    } else {
724        Ok(())
725    }
726}
727
728fn validate_replay_worker_count(worker_count: i32) -> Result<()> {
729    if worker_count < 1 {
730        return Err(Error::InvalidArgument);
731    }
732    #[cfg(target_arch = "wasm32")]
733    if worker_count > 1 {
734        return Err(Error::UnsupportedOnWasm);
735    }
736    Ok(())
737}
738
739fn checked_optional_index(index: Option<i32>) -> Result<i32> {
740    match index {
741        Some(index) if index < 0 => Err(Error::IndexOutOfRange),
742        Some(index) => Ok(index),
743        None => Ok(-1),
744    }
745}
746
747fn same_world_id(a: ffi::b3WorldId, b: ffi::b3WorldId) -> bool {
748    a.index1 == b.index1 && a.generation == b.generation
749}
750
751fn path_to_cstring(path: impl AsRef<Path>) -> Result<CString> {
752    CString::new(path.as_ref().as_os_str().to_string_lossy().as_bytes())
753        .map_err(|_| Error::NulByteInString)
754}