Skip to main content

box2d_rust/recording/
ops.rs

1// Recording op stream: opcode manifest from recording_ops.inl, the engine-
2// emitted op writers, start/stop, and replay validation.
3//
4// Ownership differs from C by design: the C world holds a pointer to a
5// user-owned b2Recording; the Rust world takes ownership for the duration of
6// the session (world_start_recording moves the Recording in,
7// world_stop_recording moves it back out).
8//
9// The API-mutation op writers (create body/shape/joint, setters, queries)
10// land as their call-site hooks are added; the replay dispatcher skips
11// unknown opcodes by their framed size, exactly like C's b2RecDispatchOne.
12//
13// SPDX-FileCopyrightText: 2026 Erin Catto
14// SPDX-License-Identifier: MIT
15
16use super::snapshot::SnapReader;
17use super::write::*;
18use super::{RecHeader, Recording};
19use crate::id::WorldId;
20use crate::math_functions::Aabb;
21use crate::world::World;
22
23// Opcode manifest. (recording_ops.inl — ranges: 0x0x world config,
24// 0x1x-0x3x body, 0x4x-0x6x shape, 0x7x chain, 0x80 step, 0x9x-0xD1 joints,
25// 0xEx queries, 0xFx markers)
26pub const OP_DESTROY_WORLD: u8 = 0x01;
27pub const OP_WORLD_ENABLE_SLEEPING: u8 = 0x02;
28pub const OP_WORLD_ENABLE_CONTINUOUS: u8 = 0x03;
29pub const OP_WORLD_SET_RESTITUTION_THRESHOLD: u8 = 0x04;
30pub const OP_WORLD_SET_HIT_EVENT_THRESHOLD: u8 = 0x05;
31pub const OP_WORLD_SET_GRAVITY: u8 = 0x06;
32pub const OP_WORLD_EXPLODE: u8 = 0x07;
33pub const OP_WORLD_SET_CONTACT_TUNING: u8 = 0x08;
34pub const OP_WORLD_SET_CONTACT_RECYCLE_DISTANCE: u8 = 0x09;
35pub const OP_WORLD_SET_MAXIMUM_LINEAR_SPEED: u8 = 0x0A;
36pub const OP_WORLD_ENABLE_WARM_STARTING: u8 = 0x0B;
37pub const OP_WORLD_REBUILD_STATIC_TREE: u8 = 0x0C;
38pub const OP_WORLD_ENABLE_SPECULATIVE: u8 = 0x0D;
39pub const OP_STEP: u8 = 0x80;
40pub const OP_STATE_HASH: u8 = 0xF1;
41pub const OP_RECORDING_BOUNDS: u8 = 0xF2;
42
43/// Run an op writer against the active recording session, if any. This is
44/// the B2_REC macro: one branch when recording is off, the args built inside
45/// the branch. (B2_REC)
46pub(crate) fn record_op(world: &mut World, f: impl FnOnce(&mut Recording, WorldId)) {
47    if let Some(mut rec) = world.recording.take() {
48        let world_id = world_id_of(world);
49        f(&mut rec, world_id);
50        world.recording = Some(rec);
51    }
52}
53
54/// A world-config op carrying a single bool. (WorldEnableSleeping and kin)
55pub(crate) fn write_world_bool(rec: &mut Recording, opcode: u8, world_id: WorldId, flag: bool) {
56    rec.begin_record(opcode);
57    rec_w_worldid(&mut rec.buffer, world_id);
58    rec_w_bool(&mut rec.buffer, flag);
59    rec.end_record();
60}
61
62/// A world-config op carrying a single f32. (WorldSetRestitutionThreshold
63/// and kin)
64pub(crate) fn write_world_f32(rec: &mut Recording, opcode: u8, world_id: WorldId, value: f32) {
65    rec.begin_record(opcode);
66    rec_w_worldid(&mut rec.buffer, world_id);
67    rec_w_f32(&mut rec.buffer, value);
68    rec.end_record();
69}
70
71/// A world-config op with no payload beyond the world id.
72/// (WorldRebuildStaticTree)
73pub(crate) fn write_world_marker(rec: &mut Recording, opcode: u8, world_id: WorldId) {
74    rec.begin_record(opcode);
75    rec_w_worldid(&mut rec.buffer, world_id);
76    rec.end_record();
77}
78
79pub(crate) fn write_world_set_gravity(
80    rec: &mut Recording,
81    world_id: WorldId,
82    gravity: crate::math_functions::Vec2,
83) {
84    rec.begin_record(OP_WORLD_SET_GRAVITY);
85    rec_w_worldid(&mut rec.buffer, world_id);
86    rec_w_vec2(&mut rec.buffer, gravity);
87    rec.end_record();
88}
89
90pub(crate) fn write_world_set_contact_tuning(
91    rec: &mut Recording,
92    world_id: WorldId,
93    hertz: f32,
94    damping_ratio: f32,
95    push_speed: f32,
96) {
97    rec.begin_record(OP_WORLD_SET_CONTACT_TUNING);
98    rec_w_worldid(&mut rec.buffer, world_id);
99    rec_w_f32(&mut rec.buffer, hertz);
100    rec_w_f32(&mut rec.buffer, damping_ratio);
101    rec_w_f32(&mut rec.buffer, push_speed);
102    rec.end_record();
103}
104
105pub(crate) fn write_world_explode(
106    rec: &mut Recording,
107    world_id: WorldId,
108    def: &crate::types::ExplosionDef,
109) {
110    rec.begin_record(OP_WORLD_EXPLODE);
111    rec_w_worldid(&mut rec.buffer, world_id);
112    rec_w_explosiondef(&mut rec.buffer, def);
113    rec.end_record();
114}
115
116#[cfg(feature = "double-precision")]
117pub(crate) fn read_position(r: &mut SnapReader) -> crate::math_functions::Pos {
118    crate::math_functions::Pos {
119        x: r.r_f64(),
120        y: r.r_f64(),
121    }
122}
123
124#[cfg(not(feature = "double-precision"))]
125pub(crate) fn read_position(r: &mut SnapReader) -> crate::math_functions::Pos {
126    crate::math_functions::Pos {
127        x: r.r_f32(),
128        y: r.r_f32(),
129    }
130}
131
132fn world_id_of(world: &World) -> WorldId {
133    WorldId {
134        index1: world.world_id + 1,
135        generation: world.generation,
136    }
137}
138
139// Engine-emitted op writers. (codegen b2RecWrite_<Name>: begin, args, end)
140
141pub(crate) fn write_step(rec: &mut Recording, world_id: WorldId, dt: f32, sub_step_count: i32) {
142    rec.begin_record(OP_STEP);
143    rec_w_worldid(&mut rec.buffer, world_id);
144    rec_w_f32(&mut rec.buffer, dt);
145    rec_w_i32(&mut rec.buffer, sub_step_count);
146    rec.end_record();
147}
148
149pub(crate) fn write_state_hash(rec: &mut Recording, world_id: WorldId, hash: u64) {
150    rec.begin_record(OP_STATE_HASH);
151    rec_w_worldid(&mut rec.buffer, world_id);
152    rec_w_u64(&mut rec.buffer, hash);
153    rec.end_record();
154}
155
156pub(crate) fn write_recording_bounds(rec: &mut Recording, bounds: Aabb) {
157    rec.begin_record(OP_RECORDING_BOUNDS);
158    rec_w_aabb(&mut rec.buffer, bounds);
159    rec.end_record();
160}
161
162pub(crate) fn write_destroy_world(rec: &mut Recording, world_id: WorldId) {
163    rec.begin_record(OP_DESTROY_WORLD);
164    rec_w_worldid(&mut rec.buffer, world_id);
165    rec.end_record();
166}
167
168/// Begin recording into the buffer: header, seed snapshot, seed bounds, and
169/// the anchoring state hash. (b2StartRecordingIntoBuffer)
170pub(crate) fn start_recording_into_buffer(world: &mut World, mut recording: Recording) {
171    // Reset so a recording handle can be reused for a fresh session
172    recording.buffer.clear();
173    recording.have_bounds = false;
174
175    // Serialize the live world into a blob that follows the header and seeds
176    // replay.
177    let mut blob = Vec::new();
178    super::serialize_world(world, &mut blob);
179
180    let header = RecHeader {
181        magic: super::REC_MAGIC,
182        version_major: super::REC_VERSION_MAJOR,
183        version_minor: super::REC_VERSION_MINOR,
184        length_scale: crate::core::get_length_units_per_meter(),
185        pointer_width: std::mem::size_of::<usize>() as u8,
186        big_endian: 0,
187        validation_enabled: if cfg!(debug_assertions) { 1 } else { 0 },
188        snapshot_size: blob.len() as u64,
189    };
190    header.write(&mut recording.buffer);
191    recording.buffer.extend_from_slice(&blob);
192
193    // Seed the bounds with the snapshot state so frame 0 is framed even if
194    // nothing moves
195    let (seed, have_bounds) = crate::world::compute_world_bounds(world);
196    if have_bounds {
197        recording.accumulate_bounds(seed);
198    }
199
200    // Anchor the recorded state hash so replay verifies the blob
201    // deserialized to the same world.
202    let world_id = world_id_of(world);
203    let hash = super::hash_world_state(world);
204    write_state_hash(&mut recording, world_id, hash);
205
206    world.recording = Some(recording);
207}
208
209/// Stop recording: append the accumulated bounds and the DestroyWorld end
210/// marker, and hand the buffer back. (b2StopRecordingInternal)
211pub(crate) fn stop_recording_internal(world: &mut World) -> Option<Recording> {
212    let mut rec = world.recording.take()?;
213
214    // Stash the accumulated bounds so a viewer can frame the whole motion at
215    // open time. Sits in the op stream ahead of the end marker.
216    let bounds = if rec.have_bounds {
217        rec.accumulated_bounds
218    } else {
219        Aabb::default()
220    };
221    write_recording_bounds(&mut rec, bounds);
222
223    // Write DestroyWorld so the buffer is self-contained, an end marker the
224    // viewer reads.
225    let world_id = world_id_of(world);
226    write_destroy_world(&mut rec, world_id);
227
228    Some(rec)
229}
230
231/// Start recording this world's session into the given recording buffer.
232/// No-op if a session is already active (the recording is returned unused).
233/// (b2World_StartRecording)
234pub fn world_start_recording(world: &mut World, recording: Recording) -> Option<Recording> {
235    // Must be a step boundary, so refuse a locked world
236    debug_assert!(!world.locked);
237    if world.locked || world.recording.is_some() {
238        return Some(recording);
239    }
240
241    start_recording_into_buffer(world, recording);
242    None
243}
244
245/// Stop the active recording session and return the finished buffer.
246/// (b2World_StopRecording)
247pub fn world_stop_recording(world: &mut World) -> Option<Recording> {
248    debug_assert!(!world.locked);
249    if world.locked {
250        return None;
251    }
252
253    stop_recording_internal(world)
254}
255
256/// Per-step recording emission, called by world_step while the world is
257/// still locked so the buffer stays single-writer. (the recording block at
258/// the end of b2World_Step)
259pub(crate) fn record_step_end(world: &mut World) {
260    let Some(mut rec) = world.recording.take() else {
261        return;
262    };
263
264    // StateHash proves the simulation reproduced exactly on replay.
265    let world_id = world_id_of(world);
266    let hash = super::hash_world_state(world);
267    write_state_hash(&mut rec, world_id, hash);
268
269    // Grow the recorded bounds so a replay can frame the whole motion, not
270    // just frame 0
271    let (bounds, have_bounds) = crate::world::compute_world_bounds(world);
272    if have_bounds {
273        rec.accumulate_bounds(bounds);
274    }
275
276    world.recording = Some(rec);
277}
278
279/// Result of a replay pass. (b2RecPlayer diagnostics, condensed)
280#[derive(Debug, Clone, Copy, PartialEq, Default)]
281pub struct ReplayResult {
282    pub steps: i32,
283    pub hash_checks: i32,
284    pub diverged: bool,
285    pub ok: bool,
286    /// Accumulated session bounds from the RecordingBounds record, for
287    /// framing a viewer. (b2RecPlayer_GetInfo().bounds)
288    pub bounds: Aabb,
289    pub have_bounds: bool,
290}
291
292/// Persist a recording buffer. The library never opens files while
293/// recording; this lets a host save a finished session.
294/// (b2SaveRecordingToFile)
295pub fn save_recording_to_file(recording: &Recording, path: &std::path::Path) -> bool {
296    std::fs::write(path, &recording.buffer).is_ok()
297}
298
299/// Load a recording buffer saved by [`save_recording_to_file`].
300/// (b2LoadRecordingFromFile)
301pub fn load_recording_from_file(path: &std::path::Path) -> Option<Recording> {
302    let buffer = std::fs::read(path).ok()?;
303    let mut recording = Recording::new(0);
304    recording.buffer = buffer;
305    Some(recording)
306}
307
308/// Replay a recording buffer against a world restored from its seed
309/// snapshot, verifying every recorded StateHash. Unknown opcodes are skipped
310/// by their framed size, like C's b2RecDispatchOne, so a stream containing
311/// not-yet-dispatched mutation ops still advances. Returns true only when
312/// the whole stream reads cleanly and no hash diverges. (b2ValidateReplay)
313pub fn validate_replay(data: &[u8]) -> bool {
314    let result = replay_buffer(data);
315    result.ok && !result.diverged
316}
317
318/// Dispatch a world-config opcode (0x01-0x0D). Shared by the linear
319/// replay loop and the incremental player. Returns None when the opcode
320/// is not in this family.
321pub(crate) fn dispatch_world_op(opcode: u8, r: &mut SnapReader, world: &mut World) -> Option<bool> {
322    match opcode {
323        OP_WORLD_ENABLE_SLEEPING => {
324            let _ = r.r_u32();
325            let flag = r.r_bool();
326            crate::world::world_enable_sleeping(world, flag);
327        }
328        OP_WORLD_ENABLE_CONTINUOUS => {
329            let _ = r.r_u32();
330            let flag = r.r_bool();
331            crate::world::world_enable_continuous(world, flag);
332        }
333        OP_WORLD_SET_RESTITUTION_THRESHOLD => {
334            let _ = r.r_u32();
335            let value = r.r_f32();
336            crate::world::world_set_restitution_threshold(world, value);
337        }
338        OP_WORLD_SET_HIT_EVENT_THRESHOLD => {
339            let _ = r.r_u32();
340            let value = r.r_f32();
341            crate::world::world_set_hit_event_threshold(world, value);
342        }
343        OP_WORLD_SET_GRAVITY => {
344            let _ = r.r_u32();
345            let gravity = crate::math_functions::Vec2 {
346                x: r.r_f32(),
347                y: r.r_f32(),
348            };
349            crate::world::world_set_gravity(world, gravity);
350        }
351        OP_WORLD_EXPLODE => {
352            let _ = r.r_u32();
353            let mut def = crate::types::default_explosion_def();
354            def.mask_bits = r.r_u64();
355            def.position = read_position(r);
356            def.radius = r.r_f32();
357            def.falloff = r.r_f32();
358            def.impulse_per_length = r.r_f32();
359            crate::world::world_explode(world, &def);
360        }
361        OP_WORLD_SET_CONTACT_TUNING => {
362            let _ = r.r_u32();
363            let hertz = r.r_f32();
364            let damping_ratio = r.r_f32();
365            let push_speed = r.r_f32();
366            crate::world::world_set_contact_tuning(world, hertz, damping_ratio, push_speed);
367        }
368        OP_WORLD_SET_CONTACT_RECYCLE_DISTANCE => {
369            let _ = r.r_u32();
370            let value = r.r_f32();
371            crate::world::world_set_contact_recycle_distance(world, value);
372        }
373        OP_WORLD_SET_MAXIMUM_LINEAR_SPEED => {
374            let _ = r.r_u32();
375            let value = r.r_f32();
376            crate::world::world_set_maximum_linear_speed(world, value);
377        }
378        OP_WORLD_ENABLE_WARM_STARTING => {
379            let _ = r.r_u32();
380            let flag = r.r_bool();
381            crate::world::world_enable_warm_starting(world, flag);
382        }
383        OP_WORLD_REBUILD_STATIC_TREE => {
384            let _ = r.r_u32();
385            crate::world::world_rebuild_static_tree(world);
386        }
387        OP_WORLD_ENABLE_SPECULATIVE => {
388            let _ = r.r_u32();
389            let flag = r.r_bool();
390            crate::world::world_enable_speculative(world, flag);
391        }
392        _ => return None,
393    }
394    Some(true)
395}
396
397/// (b2ReplayFile core loop, serial)
398pub fn replay_buffer(data: &[u8]) -> ReplayResult {
399    let mut result = ReplayResult::default();
400
401    let Some(header) = RecHeader::read(data) else {
402        return result;
403    };
404    if header.magic != super::REC_MAGIC
405        || header.version_major != super::REC_VERSION_MAJOR
406        || header.version_minor != super::REC_VERSION_MINOR
407    {
408        return result;
409    }
410
411    let snapshot_start = RecHeader::SIZE;
412    let snapshot_end = snapshot_start + header.snapshot_size as usize;
413    if snapshot_end > data.len() {
414        return result;
415    }
416
417    let Some(mut world) = super::create_world_from_snapshot(&data[snapshot_start..snapshot_end])
418    else {
419        return result;
420    };
421
422    let mut r = SnapReader::new(&data[snapshot_end..]);
423    while r.ok && r.cursor < r.data.len() {
424        let opcode = r.r_u8();
425        // u24 payload size
426        let payload_size = r.r_u8() as usize | (r.r_u8() as usize) << 8 | (r.r_u8() as usize) << 16;
427        let payload_start = r.cursor;
428        if !r.ok || payload_start + payload_size > r.data.len() {
429            return result;
430        }
431
432        match opcode {
433            OP_STEP => {
434                let _world_id = r.r_u32();
435                let dt = r.r_f32();
436                let sub_step_count = r.r_i32();
437                crate::world::world_step(&mut world, dt, sub_step_count);
438                result.steps += 1;
439            }
440            OP_STATE_HASH => {
441                let _world_id = r.r_u32();
442                let recorded = r.r_u64();
443                let computed = super::hash_world_state(&world);
444                result.hash_checks += 1;
445                if recorded != computed {
446                    // Non-fatal: reading continues so a viewer can show where
447                    // divergence begins
448                    result.diverged = true;
449                }
450            }
451            OP_RECORDING_BOUNDS => {
452                result.bounds = Aabb {
453                    lower_bound: crate::math_functions::Vec2 {
454                        x: r.r_f32(),
455                        y: r.r_f32(),
456                    },
457                    upper_bound: crate::math_functions::Vec2 {
458                        x: r.r_f32(),
459                        y: r.r_f32(),
460                    },
461                };
462                result.have_bounds = true;
463            }
464            OP_DESTROY_WORLD => {
465                // End-of-session marker
466                result.ok = true;
467                return result;
468            }
469            _ => {
470                let handled = dispatch_world_op(opcode, &mut r, &mut world)
471                    .or_else(|| super::ops_body::dispatch_body_op(opcode, &mut r, &mut world, None))
472                    .or_else(|| super::ops_shape::dispatch_shape_op(opcode, &mut r, &mut world))
473                    .or_else(|| super::ops_joint::dispatch_joint_op(opcode, &mut r, &mut world))
474                    .or_else(|| {
475                        super::ops_query::dispatch_query_op(opcode, &mut r, &mut world, None)
476                    });
477                if let Some(ids_match) = handled {
478                    if !ids_match {
479                        // A create op returned a different id than recorded:
480                        // the replay is not deterministic.
481                        result.diverged = true;
482                    }
483                } else {
484                    // Mutation ops gain dispatchers as their hooks land; skip
485                    // by framed size
486                }
487            }
488        }
489
490        r.cursor = payload_start + payload_size;
491    }
492
493    result.ok = r.ok;
494    result
495}
496
497#[cfg(test)]
498mod tests {
499    use super::*;
500    use crate::body::create_body;
501    use crate::geometry::{make_box, make_square};
502    use crate::math_functions::to_pos;
503    use crate::math_functions::Vec2;
504    use crate::shape::create_polygon_shape;
505    use crate::types::{default_body_def, default_shape_def, default_world_def, BodyType};
506    use crate::world::world_step;
507
508    // Record a settling pile, then replay from the seed snapshot: every
509    // recorded per-step StateHash must match the recomputed hash.
510    #[test]
511    fn record_and_validate_replay() {
512        let world_def = default_world_def();
513        let mut world = World::new(&world_def);
514
515        let bd = default_body_def();
516        let ground = create_body(&mut world, &bd);
517        let sd = default_shape_def();
518        create_polygon_shape(&mut world, ground, &sd, &make_box(20.0, 1.0));
519        for i in 0..10 {
520            let mut bd = default_body_def();
521            bd.type_ = BodyType::Dynamic;
522            bd.position = to_pos(Vec2 {
523                x: -2.0 + 0.45 * i as f32,
524                y: 2.0 + 0.5 * i as f32,
525            });
526            let body = create_body(&mut world, &bd);
527            create_polygon_shape(&mut world, body, &sd, &make_square(0.25));
528        }
529
530        // Settle a little before recording so the seed snapshot is nontrivial.
531        for _ in 0..15 {
532            world_step(&mut world, 1.0 / 60.0, 4);
533        }
534
535        assert!(world_start_recording(&mut world, Recording::new(0)).is_none());
536        // Double-start is refused and hands the buffer back.
537        assert!(world_start_recording(&mut world, Recording::new(0)).is_some());
538
539        for _ in 0..60 {
540            world_step(&mut world, 1.0 / 60.0, 4);
541        }
542
543        let recording = world_stop_recording(&mut world).expect("active session");
544        assert!(world.recording.is_none());
545        assert!(recording.have_bounds);
546        assert!(recording.buffer.len() > RecHeader::SIZE);
547
548        let result = replay_buffer(&recording.buffer);
549        assert!(result.ok, "stream must parse to the end marker");
550        assert!(!result.diverged, "replay hashes must match");
551        assert_eq!(result.steps, 60);
552        // Anchor hash + one per step
553        assert_eq!(result.hash_checks, 61);
554        assert!(validate_replay(&recording.buffer));
555
556        // Corrupting a recorded hash diverges but still parses. The stream
557        // tail is StateHash (16 bytes) + RecordingBounds (20) + DestroyWorld
558        // (8); the final hash payload sits at len-36..len-28.
559        let mut corrupt = recording.buffer.clone();
560        let len = corrupt.len();
561        corrupt[len - 30] ^= 0x01;
562        let bad = replay_buffer(&corrupt);
563        assert!(bad.diverged && bad.ok);
564    }
565
566    // World-config mutations recorded mid-stream replay through their
567    // dispatchers: a gravity flip and an explosion change the trajectory, so
568    // hashes only match if the ops re-execute at the right steps.
569    #[test]
570    fn config_ops_replay() {
571        let mut world_def = default_world_def();
572        world_def.gravity = Vec2 { x: 0.0, y: -10.0 };
573        let mut world = World::new(&world_def);
574
575        let bd = default_body_def();
576        let ground = create_body(&mut world, &bd);
577        let sd = default_shape_def();
578        create_polygon_shape(&mut world, ground, &sd, &make_box(15.0, 1.0));
579        for i in 0..6 {
580            let mut bd = default_body_def();
581            bd.type_ = BodyType::Dynamic;
582            bd.position = to_pos(Vec2 {
583                x: -2.0 + 0.8 * i as f32,
584                y: 2.0,
585            });
586            let body = create_body(&mut world, &bd);
587            create_polygon_shape(&mut world, body, &sd, &make_square(0.3));
588        }
589
590        assert!(world_start_recording(&mut world, Recording::new(0)).is_none());
591
592        for step in 0..90 {
593            if step == 20 {
594                let mut def = crate::types::default_explosion_def();
595                def.position = to_pos(Vec2 { x: 0.0, y: 1.5 });
596                def.radius = 2.0;
597                def.falloff = 2.0;
598                def.impulse_per_length = 4.0;
599                crate::world::world_explode(&mut world, &def);
600            }
601            if step == 40 {
602                crate::world::world_set_gravity(&mut world, Vec2 { x: 0.0, y: 3.0 });
603                crate::world::world_enable_sleeping(&mut world, false);
604            }
605            if step == 60 {
606                crate::world::world_set_gravity(&mut world, Vec2 { x: 0.0, y: -10.0 });
607                crate::world::world_set_contact_tuning(&mut world, 20.0, 5.0, 2.0);
608            }
609            world_step(&mut world, 1.0 / 60.0, 4);
610        }
611
612        let recording = world_stop_recording(&mut world).expect("active session");
613        let result = replay_buffer(&recording.buffer);
614        assert!(result.ok);
615        assert!(!result.diverged, "config ops must re-execute on replay");
616        assert_eq!(result.steps, 90);
617    }
618}