Skip to main content

box2d_rust/recording/
player.rs

1// Incremental recording player (b2RecPlayer in recording_replay.c): owns a
2// private copy of the recording bytes and drives replay one step at a time,
3// with an outliner body list, a keyframe ring for fast backward seeks, and a
4// per-frame query stash for the viewer.
5//
6// The C player creates its replay world in the global registry and exposes a
7// b2WorldId; the registry-less Rust port owns the World directly and exposes
8// world()/world_mut() instead.
9//
10// SPDX-FileCopyrightText: 2026 Erin Catto
11// SPDX-License-Identifier: MIT
12
13use super::ops::{
14    dispatch_world_op, OP_DESTROY_WORLD, OP_RECORDING_BOUNDS, OP_STATE_HASH, OP_STEP,
15};
16use super::player_queries::{
17    draw_stashed_queries, stash_query_hit, stash_query_info, QueryStash, RecQueryHit, RecQueryInfo,
18};
19use super::snapshot::SnapReader;
20use super::RecHeader;
21use crate::body::make_body_id;
22use crate::core::{get_length_units_per_meter, set_length_units_per_meter};
23use crate::debug_draw::DebugDraw;
24use crate::id::BodyId;
25use crate::math_functions::Aabb;
26use crate::world::World;
27
28// Keyframe ring tuning. A memory budget caps the snapshots kept; the spacing
29// starts at the min and doubles when adding the next keyframe would exceed
30// the budget, so memory stays bounded and seek cost grows only once a
31// recording outgrows the budget.
32const KEYFRAME_INTERVAL_DEFAULT: i32 = 16;
33const KEYFRAME_BUDGET_DEFAULT: usize = 512 * 1024 * 1024;
34
35/// Static metadata describing a recording, resolved once when the player
36/// opens the file. (b2RecPlayerInfo)
37#[derive(Debug, Clone, Copy, Default)]
38pub struct RecPlayerInfo {
39    /// Total recorded steps.
40    pub frame_count: i32,
41    /// dt of the recorded steps.
42    pub time_step: f32,
43    /// Recorded sub-steps.
44    pub sub_step_count: i32,
45    /// Length units per meter in effect when recorded.
46    pub length_scale: f32,
47    /// Accumulated world bounds over the recording, zero-extent if
48    /// unavailable.
49    pub bounds: Aabb,
50}
51
52/// A restore point captured during forward replay, so a backward seek can
53/// re-simulate only the gap from the nearest keyframe instead of from frame
54/// 0. The image is a full serialize_world blob. (b2RecKeyframe)
55struct Keyframe {
56    /// Serialized world at the end of this frame.
57    image: Vec<u8>,
58    /// Frame this restores to, a post-step boundary.
59    frame: i32,
60    /// Op-stream offset where the next frame resumes.
61    cursor: usize,
62    /// Outliner list as of this frame.
63    body_ids: Vec<BodyId>,
64    /// Divergence latches as of this frame, so a seek reports the linear
65    /// path's state.
66    diverge_frame: i32,
67    diverged: bool,
68}
69
70impl Keyframe {
71    fn bytes(&self) -> usize {
72        self.image.len() + self.body_ids.len() * std::mem::size_of::<BodyId>()
73    }
74}
75
76/// Incremental player. Owns a private copy of the recording bytes and drives
77/// replay one step at a time. (b2RecPlayer)
78pub struct RecPlayer {
79    /// Recording bytes, a private copy owned here.
80    data: Vec<u8>,
81    /// First payload offset (past header and the seed snapshot blob, which
82    /// doubles as the frame-0 restore image).
83    header_end: usize,
84    /// Length scale used in the recording.
85    length_scale: f32,
86    /// Global length scale before this player overrode it, restored on drop.
87    previous_length_scale: f32,
88    /// Steps dispatched so far.
89    frame: i32,
90    /// Total recorded steps, counted once at open.
91    frame_count: i32,
92    /// dt of the first recorded step.
93    recorded_dt: f32,
94    /// Sub-steps of the first recorded step.
95    recorded_sub_step_count: i32,
96    /// Accumulated world bounds, resolved by the open-time scan.
97    bounds: Aabb,
98    /// First step that diverged, -1 until then.
99    diverge_frame: i32,
100    /// A step_frame ran out of records without reaching a step.
101    at_end: bool,
102
103    // Reader state (b2RecReader, minus the borrow: transient SnapReader
104    // windows are built per record instead)
105    cursor: usize,
106    ok: bool,
107    diverged: bool,
108
109    /// The replay world, restored from the seed snapshot.
110    world: World,
111
112    /// Per-frame query store, reset at the top of each step_frame.
113    stash: QueryStash,
114
115    /// Live bodies in creation order, tracked from create/destroy ops to
116    /// drive the viewer outliner. Destroyed slots hold a null id so ordinals
117    /// stay stable. Rebuilt deterministically on replay.
118    body_ids: Vec<BodyId>,
119    /// Frame-0 list so a restart or backward scrub rolls the outliner back.
120    frame0_body_ids: Vec<BodyId>,
121
122    // Keyframe ring for fast backward seeks. Captured in increasing-frame
123    // order as the replay plays forward. The spacing doubles and the
124    // off-grid keyframes are evicted once the memory budget is hit.
125    keyframes: Vec<Keyframe>,
126    /// Memory cap in bytes for the kept snapshots.
127    keyframe_budget: usize,
128    /// Running total of kept snapshot + body-list bytes.
129    keyframe_bytes: usize,
130    /// Finest spacing in frames.
131    keyframe_min_interval: i32,
132    /// Current spacing, a power-of-two multiple of the min, doubles on
133    /// eviction.
134    keyframe_interval: i32,
135    /// Highest frame captured, guards against re-capture while back-stepping.
136    last_keyframe_frame: i32,
137}
138
139impl RecPlayer {
140    /// Open a recording for incremental playback. The player copies the
141    /// bytes, so the source buffer can be dropped immediately after this
142    /// call. Returns None if the recording is malformed.
143    /// (b2RecPlayer_Create; workerCount is not a parameter of the serial
144    /// port.)
145    pub fn create(data: &[u8]) -> Option<RecPlayer> {
146        if data.len() < RecHeader::SIZE {
147            return None; // recording too small
148        }
149
150        // Validate the header before copying anything
151        let header = RecHeader::read(data)?;
152        if header.magic != super::REC_MAGIC
153            || header.version_major != super::REC_VERSION_MAJOR
154            || header.version_minor != super::REC_VERSION_MINOR
155            || header.pointer_width != std::mem::size_of::<usize>() as u8
156            || header.big_endian != 0
157        {
158            return None;
159        }
160
161        // Every recording is snapshot-seeded: the blob sits between the
162        // header and the op stream
163        if header.snapshot_size == 0 || header.snapshot_size > (data.len() - RecHeader::SIZE) as u64
164        {
165            return None;
166        }
167        let header_end = RecHeader::SIZE + header.snapshot_size as usize;
168
169        // Override the global length scale with the recording's so replay
170        // reproduces the same constants. This is global engine state, so the
171        // previous value is captured and restored on drop.
172        let previous_length_scale = get_length_units_per_meter();
173        if header.length_scale > 0.0 {
174            set_length_units_per_meter(header.length_scale);
175        }
176
177        // Deserialize the seed snapshot to stand up the replay world. The op
178        // stream that follows is the hook log. The blob doubles as the
179        // frame-0 restore image, owned by the copy held here.
180        let Some(world) = super::create_world_from_snapshot(&data[RecHeader::SIZE..header_end])
181        else {
182            set_length_units_per_meter(previous_length_scale);
183            return None; // snapshot deserialize failed
184        };
185
186        let mut player = RecPlayer {
187            data: data.to_vec(),
188            header_end,
189            length_scale: header.length_scale,
190            previous_length_scale,
191            frame: 0,
192            frame_count: 0,
193            recorded_dt: 0.0,
194            recorded_sub_step_count: 0,
195            bounds: Aabb::default(),
196            diverge_frame: -1,
197            at_end: false,
198            cursor: header_end,
199            ok: true,
200            diverged: false,
201            world,
202            stash: QueryStash::default(),
203            body_ids: Vec::new(),
204            frame0_body_ids: Vec::new(),
205            keyframes: Vec::new(),
206            keyframe_budget: KEYFRAME_BUDGET_DEFAULT,
207            keyframe_bytes: 0,
208            keyframe_min_interval: KEYFRAME_INTERVAL_DEFAULT,
209            keyframe_interval: KEYFRAME_INTERVAL_DEFAULT,
210            last_keyframe_frame: 0,
211        };
212
213        // Count steps and read the first step's tuning so a viewer can show
214        // length and hz up front
215        player.scan_file();
216
217        // The seed snapshot holds the bodies present when recording began;
218        // only post-snapshot creates reach the tracker, so seed the outliner
219        // list directly from the restored world. (b2RecSeedBodyIds)
220        player.seed_body_ids();
221        player.frame0_body_ids = player.body_ids.clone();
222
223        Some(player)
224    }
225
226    /// Count steps, read the first step's tuning, and pick up the bounds
227    /// record, without touching the world. (b2RecScanFile)
228    fn scan_file(&mut self) {
229        let data = &self.data;
230        let size = data.len();
231        let mut cursor = self.header_end;
232        let mut frame_count = 0;
233        let mut got_step = false;
234
235        while cursor + 4 <= size {
236            let opcode = data[cursor];
237            let payload_size = data[cursor + 1] as usize
238                | (data[cursor + 2] as usize) << 8
239                | (data[cursor + 3] as usize) << 16;
240            let payload_start = cursor + 4;
241            if payload_start + payload_size > size {
242                break;
243            }
244
245            if opcode == OP_STEP {
246                // Step: [u32 world][f32 dt][i32 subStepCount]
247                frame_count += 1;
248                if !got_step && payload_size >= 12 {
249                    let mut r = SnapReader::new(&data[payload_start + 4..payload_start + 12]);
250                    self.recorded_dt = r.r_f32();
251                    self.recorded_sub_step_count = r.r_i32();
252                    got_step = true;
253                }
254            } else if opcode == OP_RECORDING_BOUNDS && payload_size >= 16 {
255                // RecordingBounds: [f32 lo.x][lo.y][hi.x][hi.y]
256                let mut r = SnapReader::new(&data[payload_start..payload_start + 16]);
257                self.bounds.lower_bound.x = r.r_f32();
258                self.bounds.lower_bound.y = r.r_f32();
259                self.bounds.upper_bound.x = r.r_f32();
260                self.bounds.upper_bound.y = r.r_f32();
261            }
262
263            cursor = payload_start + payload_size;
264        }
265
266        self.frame_count = frame_count;
267    }
268
269    /// Populate the outliner list from the restored world; slot order is
270    /// stable. (b2RecSeedBodyIds)
271    fn seed_body_ids(&mut self) {
272        self.body_ids.clear();
273        for i in 0..self.world.bodies.len() {
274            if self.world.bodies[i].id != i as i32 {
275                continue; // free slot
276            }
277            self.body_ids.push(make_body_id(&self.world, i as i32));
278        }
279    }
280
281    /// Dispatch the record at the cursor and advance past it. Returns the
282    /// opcode, or None on framing failure. (b2RecDispatchOne)
283    fn dispatch_one(&mut self) -> Option<u8> {
284        if self.cursor + 4 > self.data.len() {
285            return None;
286        }
287        let opcode = self.data[self.cursor];
288        let payload_size = self.data[self.cursor + 1] as usize
289            | (self.data[self.cursor + 2] as usize) << 8
290            | (self.data[self.cursor + 3] as usize) << 16;
291        let payload_start = self.cursor + 4;
292        let payload_end = payload_start + payload_size;
293        if payload_end > self.data.len() {
294            self.ok = false;
295            return None;
296        }
297
298        match opcode {
299            OP_STEP => {
300                let mut r = SnapReader::new(&self.data[payload_start..payload_end]);
301                let _world_id = r.r_u32();
302                let dt = r.r_f32();
303                let sub_step_count = r.r_i32();
304                if r.ok {
305                    crate::world::world_step(&mut self.world, dt, sub_step_count);
306                } else {
307                    self.ok = false;
308                }
309            }
310            OP_STATE_HASH => {
311                let mut r = SnapReader::new(&self.data[payload_start..payload_end]);
312                let _world_id = r.r_u32();
313                let recorded = r.r_u64();
314                if r.ok && recorded != super::hash_world_state(&self.world) {
315                    // Non-fatal so a viewer can keep playing past the first
316                    // divergent frame
317                    self.diverged = true;
318                }
319            }
320            OP_RECORDING_BOUNDS => {
321                // Resolved by the open-time scan
322            }
323            OP_DESTROY_WORLD => {
324                // The recorded session ended here. The player owns the
325                // replay world's lifetime, so a viewer can keep drawing the
326                // final step. This is always the last record.
327            }
328            _ => {
329                // Split borrows: the reader window borrows `data`, the
330                // dispatchers mutate `world` and the player hooks.
331                let RecPlayer {
332                    data,
333                    world,
334                    body_ids,
335                    stash,
336                    ..
337                } = self;
338                let mut r = SnapReader::new(&data[payload_start..payload_end]);
339                let handled = dispatch_world_op(opcode, &mut r, world)
340                    .or_else(|| {
341                        super::ops_body::dispatch_body_op(opcode, &mut r, world, Some(body_ids))
342                    })
343                    .or_else(|| super::ops_shape::dispatch_shape_op(opcode, &mut r, world))
344                    .or_else(|| super::ops_joint::dispatch_joint_op(opcode, &mut r, world))
345                    .or_else(|| {
346                        super::ops_query::dispatch_query_op(opcode, &mut r, world, Some(stash))
347                    });
348                let reader_ok = r.ok;
349                match handled {
350                    Some(true) | None => {}
351                    Some(false) => {
352                        if (0xE0..=0xE8).contains(&opcode) {
353                            // A query hit failed to reproduce: divergence,
354                            // non-fatal (b2RecReplayQueryCtx semantics)
355                            self.diverged = true;
356                        } else {
357                            // A create returned a different id than
358                            // recorded: structural drift, fatal since later
359                            // ops would target the wrong objects
360                            // (b2RecCheckId semantics)
361                            self.ok = false;
362                        }
363                    }
364                }
365                if !reader_ok {
366                    self.ok = false;
367                }
368            }
369        }
370
371        self.cursor = payload_end;
372        Some(opcode)
373    }
374
375    /// Advance the replay by one recorded step. Returns true if a step
376    /// executed, false once the end of the recording is reached.
377    /// (b2RecPlayer_StepFrame)
378    pub fn step_frame(&mut self) -> bool {
379        if self.at_end {
380            return false;
381        }
382
383        // Reset the per-frame query store before dispatching new records
384        self.stash.clear();
385
386        // Run this frame's Step, then consume the records that trail it
387        // (StateHash, queries, any between-frame mutators) up to the next
388        // Step. The queries and hash for a frame are recorded after its
389        // Step, so grouping them with that Step keeps them paired with the
390        // world state they were computed against. Stopping before the next
391        // Step is what advances exactly one frame.
392        let mut stepped = false;
393        loop {
394            // Peek the next opcode without consuming it. The next frame's
395            // Step ends this frame.
396            if self.cursor >= self.data.len() || !self.ok {
397                self.at_end = true;
398                return stepped;
399            }
400            if stepped && self.data[self.cursor] == OP_STEP {
401                // Capture a keyframe at the interval. The guard skips frames
402                // already covered, so re-stepping a gap during a backward
403                // seek never re-captures.
404                if self.frame > self.last_keyframe_frame && self.frame % self.keyframe_interval == 0
405                {
406                    self.capture_keyframe();
407                }
408                return true;
409            }
410
411            let Some(opcode) = self.dispatch_one() else {
412                self.at_end = true;
413                return stepped;
414            };
415            if opcode == OP_STEP {
416                self.frame += 1;
417                stepped = true;
418            }
419            // Latch the first frame that diverged for the timeline marker
420            if self.diverge_frame < 0 && self.diverged {
421                self.diverge_frame = self.frame;
422            }
423        }
424    }
425
426    /// Capture a restore point for the just-completed frame. The cursor
427    /// already sits at the next frame's Step, so this records the exact
428    /// resume position next to a full world image plus the outliner and
429    /// divergence state forward stepping would otherwise have to rebuild.
430    /// (b2RecCaptureKeyframe)
431    fn capture_keyframe(&mut self) {
432        let image = super::world_snapshot(&self.world);
433        let body_bytes = self.body_ids.len() * std::mem::size_of::<BodyId>();
434        let new_bytes = image.len() + body_bytes;
435
436        // Make room under the budget: doubling the spacing drops the
437        // off-grid keyframes, roughly halving the bytes, until the new
438        // keyframe fits or only it remains. The budget is soft in the corner
439        // where a single snapshot already exceeds it.
440        while !self.keyframes.is_empty() && self.keyframe_bytes + new_bytes > self.keyframe_budget {
441            self.keyframe_interval *= 2;
442            let before = self.keyframes.len();
443            let interval = self.keyframe_interval;
444            self.keyframes.retain(|kf| kf.frame % interval == 0);
445            self.keyframe_bytes = self.keyframes.iter().map(Keyframe::bytes).sum();
446            if self.keyframes.len() == before {
447                break;
448            }
449        }
450
451        self.keyframes.push(Keyframe {
452            image,
453            frame: self.frame,
454            cursor: self.cursor,
455            body_ids: self.body_ids.clone(),
456            diverge_frame: self.diverge_frame,
457            diverged: self.diverged,
458        });
459        self.keyframe_bytes += new_bytes;
460        self.last_keyframe_frame = self.frame;
461    }
462
463    /// Restore the world and player state from a keyframe, so a backward
464    /// seek resumes from it instead of frame 0. Mirrors restart but targets
465    /// a mid-stream image; world_restore is in place, so the replay world
466    /// stays the same object. (b2RecPlayerRestoreKeyframe)
467    fn restore_keyframe(&mut self, index: usize) {
468        let kf = &self.keyframes[index];
469        if !super::world_restore(&mut self.world, &kf.image) {
470            self.ok = false;
471            return;
472        }
473        let kf = &self.keyframes[index];
474        self.cursor = kf.cursor;
475        self.ok = true;
476        self.diverged = kf.diverged;
477        self.frame = kf.frame;
478        self.diverge_frame = kf.diverge_frame;
479        self.at_end = false;
480        self.body_ids = kf.body_ids.clone();
481    }
482
483    /// Rewind the player to the first step, recreating the replay world from
484    /// the frame-0 image in place. (b2RecPlayer_Restart)
485    pub fn restart(&mut self) {
486        if !super::world_restore(
487            &mut self.world,
488            &self.data[RecHeader::SIZE..self.header_end],
489        ) {
490            self.ok = false;
491            return;
492        }
493        // Stepping resumes at the first Step, which sits right after the
494        // header and snapshot blob
495        self.cursor = self.header_end;
496        self.ok = true;
497        self.diverged = false;
498        self.frame = 0;
499        self.diverge_frame = -1;
500        self.at_end = false;
501
502        // Frame 0 is the pre-step snapshot, so it has no recorded queries.
503        // Clear the per-frame store so the last stepped frame's queries do
504        // not linger on a load or a backward scrub to the start.
505        self.stash.clear();
506
507        // Roll the outliner body list back to its frame-0 contents
508        self.body_ids.clone_from(&self.frame0_body_ids);
509    }
510
511    /// Seek to a recorded step. Seeking backward restores the nearest
512    /// keyframe and re-runs the gap. Clamps to the recording bounds.
513    /// (b2RecPlayer_SeekFrame)
514    pub fn seek_frame(&mut self, target_frame: i32) {
515        let target_frame = target_frame.max(0);
516
517        // Resume from the nearest keyframe strictly below the target when it
518        // beats the current cursor. A backward seek must restore since the
519        // cursor cannot rewind. A forward seek restores only when a keyframe
520        // sits ahead of the cursor, capping a long forward fling at one
521        // keyframe interval of replay instead of every intervening frame.
522        // Strictly below so the step loop still runs the target frame and
523        // regenerates its per-frame query store, body list, and divergence
524        // latch exactly as a plain forward replay would.
525        let mut best: Option<usize> = None;
526        for (i, kf) in self.keyframes.iter().enumerate() {
527            if kf.frame < target_frame && best.map_or(true, |b| kf.frame > self.keyframes[b].frame)
528            {
529                best = Some(i);
530            }
531        }
532
533        if target_frame < self.frame {
534            match best {
535                Some(i) => self.restore_keyframe(i),
536                None => self.restart(),
537            }
538        } else if let Some(i) = best {
539            if self.keyframes[i].frame > self.frame {
540                self.restore_keyframe(i);
541            }
542        }
543
544        while self.frame < target_frame && self.step_frame() {}
545    }
546
547    /// The replay world. (b2RecPlayer_GetWorldId)
548    pub fn world(&self) -> &World {
549        &self.world
550    }
551
552    /// Mutable access to the replay world, e.g. for a viewer's world_draw.
553    pub fn world_mut(&mut self) -> &mut World {
554        &mut self.world
555    }
556
557    /// The number of steps replayed so far. (b2RecPlayer_GetFrame)
558    pub fn frame(&self) -> i32 {
559        self.frame
560    }
561
562    /// Static metadata for the recording. (b2RecPlayer_GetInfo)
563    pub fn info(&self) -> RecPlayerInfo {
564        RecPlayerInfo {
565            frame_count: self.frame_count,
566            time_step: self.recorded_dt,
567            sub_step_count: self.recorded_sub_step_count,
568            length_scale: self.length_scale,
569            bounds: self.bounds,
570        }
571    }
572
573    /// True once the end of the recording has been reached.
574    /// (b2RecPlayer_IsAtEnd)
575    pub fn is_at_end(&self) -> bool {
576        self.at_end
577    }
578
579    /// True if a recorded state hash or query failed to reproduce.
580    /// (b2RecPlayer_HasDiverged)
581    pub fn has_diverged(&self) -> bool {
582        self.diverged
583    }
584
585    /// The first step at which replay diverged, or -1.
586    /// (b2RecPlayer_GetDivergeFrame)
587    pub fn diverge_frame(&self) -> i32 {
588        self.diverge_frame
589    }
590
591    /// Tune the keyframe ring. A zero budget or a non-positive interval
592    /// keeps that value. Clears the existing ring, so call restart afterward
593    /// to repopulate it under the new policy. (b2RecPlayer_SetKeyframePolicy)
594    pub fn set_keyframe_policy(&mut self, budget_bytes: usize, min_interval_frames: i32) {
595        if budget_bytes > 0 {
596            self.keyframe_budget = budget_bytes;
597        }
598        if min_interval_frames > 0 {
599            self.keyframe_min_interval = min_interval_frames;
600        }
601
602        // Drop the ring so it repopulates under the new policy on the next
603        // replay
604        self.keyframes.clear();
605        self.keyframe_bytes = 0;
606        self.keyframe_interval = self.keyframe_min_interval;
607        self.last_keyframe_frame = 0;
608    }
609
610    /// (b2RecPlayer_GetKeyframeBudget)
611    pub fn keyframe_budget(&self) -> usize {
612        self.keyframe_budget
613    }
614
615    /// (b2RecPlayer_GetKeyframeMinInterval)
616    pub fn keyframe_min_interval(&self) -> i32 {
617        self.keyframe_min_interval
618    }
619
620    /// The current keyframe spacing in frames; reflects the effective
621    /// backward-seek granularity right now. (b2RecPlayer_GetKeyframeInterval)
622    pub fn keyframe_interval(&self) -> i32 {
623        self.keyframe_interval
624    }
625
626    /// The memory currently held by keyframe snapshots, in bytes.
627    /// (b2RecPlayer_GetKeyframeBytes)
628    pub fn keyframe_bytes(&self) -> usize {
629        self.keyframe_bytes
630    }
631
632    /// Draw spatial queries recorded during the most recently replayed
633    /// frame. Call after world_draw so queries are layered on top of the
634    /// world. `query_index` < 0 draws all of them.
635    /// (b2RecPlayer_DrawFrameQueries)
636    pub fn draw_frame_queries(&self, draw: &mut dyn DebugDraw, query_index: i32) {
637        draw_stashed_queries(&self.world, &self.stash, draw, query_index);
638    }
639
640    /// The number of spatial queries recorded for the most recently replayed
641    /// frame. (b2RecPlayer_GetFrameQueryCount)
642    pub fn frame_query_count(&self) -> i32 {
643        self.stash.queries.len() as i32
644    }
645
646    /// A recorded query from the most recently replayed frame by index.
647    /// (b2RecPlayer_GetFrameQuery)
648    pub fn frame_query(&self, index: i32) -> RecQueryInfo {
649        stash_query_info(&self.stash, index)
650    }
651
652    /// One result of a recorded query from the most recently replayed frame.
653    /// (b2RecPlayer_GetFrameQueryHit)
654    pub fn frame_query_hit(&self, query_index: i32, hit_index: i32) -> RecQueryHit {
655        stash_query_hit(&self.stash, query_index, hit_index)
656    }
657
658    /// The number of body slots tracked for the outliner. This is the
659    /// creation-order span and includes holes for destroyed bodies, so it
660    /// only grows as the replay advances. (b2RecPlayer_GetBodyCount)
661    pub fn body_count(&self) -> i32 {
662        self.body_ids.len() as i32
663    }
664
665    /// A tracked body by creation ordinal. Returns the null id for a
666    /// destroyed slot or an out-of-range index; validate with body_is_valid.
667    /// (b2RecPlayer_GetBodyId)
668    pub fn body_id(&self, index: i32) -> BodyId {
669        if index < 0 || index as usize >= self.body_ids.len() {
670            return BodyId::default();
671        }
672        self.body_ids[index as usize]
673    }
674
675    /// Internal-parity check used by validate tests: framing and ids read
676    /// cleanly so far.
677    pub fn is_ok(&self) -> bool {
678        self.ok
679    }
680}
681
682impl Drop for RecPlayer {
683    fn drop(&mut self) {
684        // Restore the global length scale. (b2RecPlayer_Destroy; the world
685        // and buffers are owned values, dropped normally.)
686        set_length_units_per_meter(self.previous_length_scale);
687    }
688}