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/// (b2ReplayFile core loop, serial)
319pub fn replay_buffer(data: &[u8]) -> ReplayResult {
320    let mut result = ReplayResult::default();
321
322    let Some(header) = RecHeader::read(data) else {
323        return result;
324    };
325    if header.magic != super::REC_MAGIC
326        || header.version_major != super::REC_VERSION_MAJOR
327        || header.version_minor != super::REC_VERSION_MINOR
328    {
329        return result;
330    }
331
332    let snapshot_start = RecHeader::SIZE;
333    let snapshot_end = snapshot_start + header.snapshot_size as usize;
334    if snapshot_end > data.len() {
335        return result;
336    }
337
338    let Some(mut world) = super::create_world_from_snapshot(&data[snapshot_start..snapshot_end])
339    else {
340        return result;
341    };
342
343    let mut r = SnapReader::new(&data[snapshot_end..]);
344    while r.ok && r.cursor < r.data.len() {
345        let opcode = r.r_u8();
346        // u24 payload size
347        let payload_size = r.r_u8() as usize | (r.r_u8() as usize) << 8 | (r.r_u8() as usize) << 16;
348        let payload_start = r.cursor;
349        if !r.ok || payload_start + payload_size > r.data.len() {
350            return result;
351        }
352
353        match opcode {
354            OP_STEP => {
355                let _world_id = r.r_u32();
356                let dt = r.r_f32();
357                let sub_step_count = r.r_i32();
358                crate::world::world_step(&mut world, dt, sub_step_count);
359                result.steps += 1;
360            }
361            OP_STATE_HASH => {
362                let _world_id = r.r_u32();
363                let recorded = r.r_u64();
364                let computed = super::hash_world_state(&world);
365                result.hash_checks += 1;
366                if recorded != computed {
367                    // Non-fatal: reading continues so a viewer can show where
368                    // divergence begins
369                    result.diverged = true;
370                }
371            }
372            OP_WORLD_ENABLE_SLEEPING => {
373                let _ = r.r_u32();
374                let flag = r.r_bool();
375                crate::world::world_enable_sleeping(&mut world, flag);
376            }
377            OP_WORLD_ENABLE_CONTINUOUS => {
378                let _ = r.r_u32();
379                let flag = r.r_bool();
380                crate::world::world_enable_continuous(&mut world, flag);
381            }
382            OP_WORLD_SET_RESTITUTION_THRESHOLD => {
383                let _ = r.r_u32();
384                let value = r.r_f32();
385                crate::world::world_set_restitution_threshold(&mut world, value);
386            }
387            OP_WORLD_SET_HIT_EVENT_THRESHOLD => {
388                let _ = r.r_u32();
389                let value = r.r_f32();
390                crate::world::world_set_hit_event_threshold(&mut world, value);
391            }
392            OP_WORLD_SET_GRAVITY => {
393                let _ = r.r_u32();
394                let gravity = crate::math_functions::Vec2 {
395                    x: r.r_f32(),
396                    y: r.r_f32(),
397                };
398                crate::world::world_set_gravity(&mut world, gravity);
399            }
400            OP_WORLD_EXPLODE => {
401                let _ = r.r_u32();
402                let mut def = crate::types::default_explosion_def();
403                def.mask_bits = r.r_u64();
404                def.position = read_position(&mut r);
405                def.radius = r.r_f32();
406                def.falloff = r.r_f32();
407                def.impulse_per_length = r.r_f32();
408                crate::world::world_explode(&mut world, &def);
409            }
410            OP_WORLD_SET_CONTACT_TUNING => {
411                let _ = r.r_u32();
412                let hertz = r.r_f32();
413                let damping_ratio = r.r_f32();
414                let push_speed = r.r_f32();
415                crate::world::world_set_contact_tuning(
416                    &mut world,
417                    hertz,
418                    damping_ratio,
419                    push_speed,
420                );
421            }
422            OP_WORLD_SET_CONTACT_RECYCLE_DISTANCE => {
423                let _ = r.r_u32();
424                let value = r.r_f32();
425                crate::world::world_set_contact_recycle_distance(&mut world, value);
426            }
427            OP_WORLD_SET_MAXIMUM_LINEAR_SPEED => {
428                let _ = r.r_u32();
429                let value = r.r_f32();
430                crate::world::world_set_maximum_linear_speed(&mut world, value);
431            }
432            OP_WORLD_ENABLE_WARM_STARTING => {
433                let _ = r.r_u32();
434                let flag = r.r_bool();
435                crate::world::world_enable_warm_starting(&mut world, flag);
436            }
437            OP_WORLD_REBUILD_STATIC_TREE => {
438                let _ = r.r_u32();
439                crate::world::world_rebuild_static_tree(&mut world);
440            }
441            OP_WORLD_ENABLE_SPECULATIVE => {
442                let _ = r.r_u32();
443                let flag = r.r_bool();
444                crate::world::world_enable_speculative(&mut world, flag);
445            }
446            OP_RECORDING_BOUNDS => {
447                result.bounds = Aabb {
448                    lower_bound: crate::math_functions::Vec2 {
449                        x: r.r_f32(),
450                        y: r.r_f32(),
451                    },
452                    upper_bound: crate::math_functions::Vec2 {
453                        x: r.r_f32(),
454                        y: r.r_f32(),
455                    },
456                };
457                result.have_bounds = true;
458            }
459            OP_DESTROY_WORLD => {
460                // End-of-session marker
461                result.ok = true;
462                return result;
463            }
464            _ => {
465                let handled = super::ops_body::dispatch_body_op(opcode, &mut r, &mut world)
466                    .or_else(|| super::ops_shape::dispatch_shape_op(opcode, &mut r, &mut world))
467                    .or_else(|| super::ops_joint::dispatch_joint_op(opcode, &mut r, &mut world))
468                    .or_else(|| super::ops_query::dispatch_query_op(opcode, &mut r, &mut world));
469                if let Some(ids_match) = handled {
470                    if !ids_match {
471                        // A create op returned a different id than recorded:
472                        // the replay is not deterministic.
473                        result.diverged = true;
474                    }
475                } else {
476                    // Mutation ops gain dispatchers as their hooks land; skip
477                    // by framed size
478                }
479            }
480        }
481
482        r.cursor = payload_start + payload_size;
483    }
484
485    result.ok = r.ok;
486    result
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::body::create_body;
493    use crate::geometry::{make_box, make_square};
494    use crate::math_functions::to_pos;
495    use crate::math_functions::Vec2;
496    use crate::shape::create_polygon_shape;
497    use crate::types::{default_body_def, default_shape_def, default_world_def, BodyType};
498    use crate::world::world_step;
499
500    // Record a settling pile, then replay from the seed snapshot: every
501    // recorded per-step StateHash must match the recomputed hash.
502    #[test]
503    fn record_and_validate_replay() {
504        let world_def = default_world_def();
505        let mut world = World::new(&world_def);
506
507        let bd = default_body_def();
508        let ground = create_body(&mut world, &bd);
509        let sd = default_shape_def();
510        create_polygon_shape(&mut world, ground, &sd, &make_box(20.0, 1.0));
511        for i in 0..10 {
512            let mut bd = default_body_def();
513            bd.type_ = BodyType::Dynamic;
514            bd.position = to_pos(Vec2 {
515                x: -2.0 + 0.45 * i as f32,
516                y: 2.0 + 0.5 * i as f32,
517            });
518            let body = create_body(&mut world, &bd);
519            create_polygon_shape(&mut world, body, &sd, &make_square(0.25));
520        }
521
522        // Settle a little before recording so the seed snapshot is nontrivial.
523        for _ in 0..15 {
524            world_step(&mut world, 1.0 / 60.0, 4);
525        }
526
527        assert!(world_start_recording(&mut world, Recording::new(0)).is_none());
528        // Double-start is refused and hands the buffer back.
529        assert!(world_start_recording(&mut world, Recording::new(0)).is_some());
530
531        for _ in 0..60 {
532            world_step(&mut world, 1.0 / 60.0, 4);
533        }
534
535        let recording = world_stop_recording(&mut world).expect("active session");
536        assert!(world.recording.is_none());
537        assert!(recording.have_bounds);
538        assert!(recording.buffer.len() > RecHeader::SIZE);
539
540        let result = replay_buffer(&recording.buffer);
541        assert!(result.ok, "stream must parse to the end marker");
542        assert!(!result.diverged, "replay hashes must match");
543        assert_eq!(result.steps, 60);
544        // Anchor hash + one per step
545        assert_eq!(result.hash_checks, 61);
546        assert!(validate_replay(&recording.buffer));
547
548        // Corrupting a recorded hash diverges but still parses. The stream
549        // tail is StateHash (16 bytes) + RecordingBounds (20) + DestroyWorld
550        // (8); the final hash payload sits at len-36..len-28.
551        let mut corrupt = recording.buffer.clone();
552        let len = corrupt.len();
553        corrupt[len - 30] ^= 0x01;
554        let bad = replay_buffer(&corrupt);
555        assert!(bad.diverged && bad.ok);
556    }
557
558    // World-config mutations recorded mid-stream replay through their
559    // dispatchers: a gravity flip and an explosion change the trajectory, so
560    // hashes only match if the ops re-execute at the right steps.
561    #[test]
562    fn config_ops_replay() {
563        let mut world_def = default_world_def();
564        world_def.gravity = Vec2 { x: 0.0, y: -10.0 };
565        let mut world = World::new(&world_def);
566
567        let bd = default_body_def();
568        let ground = create_body(&mut world, &bd);
569        let sd = default_shape_def();
570        create_polygon_shape(&mut world, ground, &sd, &make_box(15.0, 1.0));
571        for i in 0..6 {
572            let mut bd = default_body_def();
573            bd.type_ = BodyType::Dynamic;
574            bd.position = to_pos(Vec2 {
575                x: -2.0 + 0.8 * i as f32,
576                y: 2.0,
577            });
578            let body = create_body(&mut world, &bd);
579            create_polygon_shape(&mut world, body, &sd, &make_square(0.3));
580        }
581
582        assert!(world_start_recording(&mut world, Recording::new(0)).is_none());
583
584        for step in 0..90 {
585            if step == 20 {
586                let mut def = crate::types::default_explosion_def();
587                def.position = to_pos(Vec2 { x: 0.0, y: 1.5 });
588                def.radius = 2.0;
589                def.falloff = 2.0;
590                def.impulse_per_length = 4.0;
591                crate::world::world_explode(&mut world, &def);
592            }
593            if step == 40 {
594                crate::world::world_set_gravity(&mut world, Vec2 { x: 0.0, y: 3.0 });
595                crate::world::world_enable_sleeping(&mut world, false);
596            }
597            if step == 60 {
598                crate::world::world_set_gravity(&mut world, Vec2 { x: 0.0, y: -10.0 });
599                crate::world::world_set_contact_tuning(&mut world, 20.0, 5.0, 2.0);
600            }
601            world_step(&mut world, 1.0 / 60.0, 4);
602        }
603
604        let recording = world_stop_recording(&mut world).expect("active session");
605        let result = replay_buffer(&recording.buffer);
606        assert!(result.ok);
607        assert!(!result.diverged, "config ops must re-execute on replay");
608        assert_eq!(result.steps, 90);
609    }
610}