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}