Skip to main content

box2d_rust/recording/
mod.rs

1// Recording subsystem foundation from recording.h + recording.c: the wire
2// header, the append-only record buffer, record framing, and the world-state
3// hash shared by recorder and replayer.
4//
5// Porting decisions:
6// - b2RecBuffer becomes a plain Vec<u8>; the C countOnly sizing mode is an
7//   allocation optimization with no wire effect and is not ported.
8// - The recording mutex is dropped: the port is serial, so query records
9//   can never interleave.
10// - The C X-macro codegen over recording_ops.inl becomes explicit Rust in
11//   write.rs (writers) and the replay dispatcher (later slice); opcodes and
12//   field order are kept byte-identical.
13//
14// SPDX-FileCopyrightText: 2026 Erin Catto
15// SPDX-License-Identifier: MIT
16
17use crate::math_functions::{Aabb, Pos};
18use crate::world::World;
19
20mod ops;
21mod ops_body;
22mod ops_joint;
23mod ops_query;
24mod ops_shape;
25mod player;
26mod player_queries;
27mod snapshot;
28mod snapshot_joints;
29mod snapshot_structs;
30mod snapshot_world;
31mod write;
32
33pub use ops::*;
34pub use ops_body::*;
35pub use ops_joint::*;
36pub use ops_query::*;
37pub use ops_shape::*;
38pub use player::*;
39pub use player_queries::{RecQueryHit, RecQueryInfo, RecQueryType};
40pub use snapshot::*;
41pub use snapshot_world::*;
42pub use write::*;
43
44/// FNV-1a 64-bit initial value. (B2_SNAP_FNV_INIT)
45pub const SNAP_FNV_INIT: u64 = 14695981039346656037;
46
47/// FNV-1a 64-bit prime. (B2_SNAP_FNV_PRIME)
48pub const SNAP_FNV_PRIME: u64 = 1099511628211;
49
50/// Magic value 'B2RC' in little-endian. (B2_REC_MAGIC)
51pub const REC_MAGIC: u32 = 0x43523242;
52
53/// Recording format version. Any mismatch refuses to load. The minor tracks
54/// op stream layout changes that keep the 32 byte header shape.
55/// (B2_REC_VERSION_MAJOR/MINOR)
56pub const REC_VERSION_MAJOR: u16 = 3;
57pub const REC_VERSION_MINOR: u16 = 2;
58
59/// Mix a world position at full width, or the determinism gates would
60/// validate only to float precision and pass vacuously far from the origin.
61/// (b2FnvMixPosition)
62pub fn fnv_mix_position(mut hash: u64, p: Pos) -> u64 {
63    // In the single-precision build Pos components are f32 and widen to u64
64    // through u32 bits, matching the C #else branch.
65    let (bx, by) = pos_bits(p);
66    hash = (hash ^ bx).wrapping_mul(SNAP_FNV_PRIME);
67    hash = (hash ^ by).wrapping_mul(SNAP_FNV_PRIME);
68    hash
69}
70
71#[cfg(feature = "double-precision")]
72fn pos_bits(p: Pos) -> (u64, u64) {
73    (p.x.to_bits(), p.y.to_bits())
74}
75
76#[cfg(not(feature = "double-precision"))]
77fn pos_bits(p: Pos) -> (u64, u64) {
78    (p.x.to_bits() as u64, p.y.to_bits() as u64)
79}
80
81/// File header, fixed 32 bytes, little-endian. (b2RecHeader)
82#[derive(Debug, Clone, Copy, PartialEq)]
83pub struct RecHeader {
84    /// 'B2RC' = 0x43523242
85    pub magic: u32,
86    pub version_major: u16,
87    pub version_minor: u16,
88    /// The world length scale
89    pub length_scale: f32,
90    /// sizeof(void*) in C, gates POD-def memcpy; always 8 here
91    pub pointer_width: u8,
92    /// 0 on all supported targets
93    pub big_endian: u8,
94    /// 1 if built with validation, only for diagnostics on a layout mismatch
95    pub validation_enabled: u8,
96    /// bytes of snapshot blob after the header
97    pub snapshot_size: u64,
98}
99
100impl RecHeader {
101    pub const SIZE: usize = 32;
102
103    /// Serialize in the exact C struct layout (little-endian, with the
104    /// reserved fields zeroed).
105    pub fn write(&self, buf: &mut Vec<u8>) {
106        rec_w_u32(buf, self.magic);
107        rec_w_u16(buf, self.version_major);
108        rec_w_u16(buf, self.version_minor);
109        rec_w_u32(buf, 0); // reserved2
110        rec_w_f32(buf, self.length_scale);
111        rec_w_u8(buf, 0); // reserved3
112        rec_w_u8(buf, self.pointer_width);
113        rec_w_u8(buf, self.big_endian);
114        rec_w_u8(buf, self.validation_enabled);
115        rec_w_u32(buf, 0); // reserved1
116        rec_w_u64(buf, self.snapshot_size);
117    }
118
119    /// Parse a 32-byte header. Returns None if the data is too short.
120    pub fn read(data: &[u8]) -> Option<RecHeader> {
121        if data.len() < Self::SIZE {
122            return None;
123        }
124        let u16_at = |o: usize| u16::from_le_bytes([data[o], data[o + 1]]);
125        let u32_at =
126            |o: usize| u32::from_le_bytes([data[o], data[o + 1], data[o + 2], data[o + 3]]);
127        let u64_at = |o: usize| {
128            u64::from_le_bytes([
129                data[o],
130                data[o + 1],
131                data[o + 2],
132                data[o + 3],
133                data[o + 4],
134                data[o + 5],
135                data[o + 6],
136                data[o + 7],
137            ])
138        };
139        Some(RecHeader {
140            magic: u32_at(0),
141            version_major: u16_at(4),
142            version_minor: u16_at(6),
143            length_scale: f32::from_bits(u32_at(12)),
144            pointer_width: data[17],
145            big_endian: data[18],
146            validation_enabled: data[19],
147            snapshot_size: u64_at(24),
148        })
149    }
150}
151
152/// User-owned recording buffer. The world appends into it while recording;
153/// the user saves and destroys it. (b2Recording — the mutex is dropped in
154/// the serial port)
155#[derive(Debug, Default)]
156pub struct Recording {
157    pub buffer: Vec<u8>,
158
159    /// Offset of the 3-byte size field for u24 backpatch.
160    record_start: usize,
161
162    /// Union of world bounds over every recorded step, written out at stop so
163    /// a replay can frame the whole motion. have_bounds gates the first union
164    /// the same way world_get_bounds does.
165    pub accumulated_bounds: Aabb,
166    pub have_bounds: bool,
167}
168
169impl Recording {
170    /// (b2CreateRecording — the capacity hint sizes the buffer up front)
171    pub fn new(capacity_hint: usize) -> Recording {
172        Recording {
173            buffer: Vec::with_capacity(capacity_hint),
174            record_start: 0,
175            accumulated_bounds: Aabb::default(),
176            have_bounds: false,
177        }
178    }
179
180    /// Start a framed record: opcode byte plus a 3-byte payload-size slot
181    /// backpatched by [`Recording::end_record`]. (b2RecBeginRecord)
182    pub fn begin_record(&mut self, opcode: u8) {
183        rec_w_u8(&mut self.buffer, opcode);
184        self.record_start = self.buffer.len();
185        self.buffer.extend_from_slice(&[0, 0, 0]);
186    }
187
188    /// Compute the final payload size and record it in the 24-bit space
189    /// reserved right after the opcode. (b2RecEndRecord)
190    pub fn end_record(&mut self) {
191        let payload_size = self.buffer.len() - self.record_start - 3;
192        debug_assert!(payload_size < (1 << 24));
193        let p = &mut self.buffer[self.record_start..self.record_start + 3];
194        p[0] = payload_size as u8;
195        p[1] = (payload_size >> 8) as u8;
196        p[2] = (payload_size >> 16) as u8;
197    }
198
199    /// Append a completed record (opcode + u24 size + payload) in one shot.
200    /// (b2RecCommitRecord — lock-free in the serial port)
201    pub fn commit_record(&mut self, opcode: u8, payload: &[u8]) {
202        debug_assert!(payload.len() < (1 << 24));
203        rec_w_u8(&mut self.buffer, opcode);
204        let size = payload.len();
205        self.buffer
206            .extend_from_slice(&[size as u8, (size >> 8) as u8, (size >> 16) as u8]);
207        self.buffer.extend_from_slice(payload);
208    }
209
210    /// Fold one step's world bounds into the running union the recorder
211    /// writes out at stop. (b2RecAccumulateBounds)
212    pub fn accumulate_bounds(&mut self, bounds: Aabb) {
213        if self.have_bounds {
214            self.accumulated_bounds =
215                crate::math_functions::aabb_union(self.accumulated_bounds, bounds);
216        } else {
217            self.accumulated_bounds = bounds;
218            self.have_bounds = true;
219        }
220    }
221}
222
223/// Reserve a u32 slot for backfill (query hit counts). Returns its offset.
224/// (b2RecReserveU32)
225pub fn rec_reserve_u32(buf: &mut Vec<u8>) -> usize {
226    let offset = buf.len();
227    buf.extend_from_slice(&[0, 0, 0, 0]);
228    offset
229}
230
231/// Backfill a reserved u32 slot. (b2RecPatchU32)
232pub fn rec_patch_u32(buf: &mut [u8], offset: usize, v: u32) {
233    debug_assert!(offset + 4 <= buf.len());
234    buf[offset..offset + 4].copy_from_slice(&v.to_le_bytes());
235}
236
237/// Deterministic hash over all body transforms and velocities. Called by
238/// both recorder and replayer to verify simulation reproduces exactly.
239/// (b2HashWorldState)
240pub fn hash_world_state(world: &World) -> u64 {
241    let mut hash = SNAP_FNV_INIT;
242    let prime = SNAP_FNV_PRIME;
243
244    let mix_f32 = |hash: &mut u64, f: f32| {
245        *hash = (*hash ^ f.to_bits() as u64).wrapping_mul(prime);
246    };
247
248    for (i, body) in world.bodies.iter().enumerate() {
249        if body.id != i as i32 {
250            // Free or never-used slot
251            continue;
252        }
253
254        let sim = &world.solver_sets[body.set_index as usize].body_sims[body.local_index as usize];
255
256        hash = fnv_mix_position(hash, sim.transform.p);
257        mix_f32(&mut hash, sim.transform.q.c);
258        mix_f32(&mut hash, sim.transform.q.s);
259
260        if body.set_index == crate::solver_set::AWAKE_SET {
261            let state = &world.solver_sets[crate::solver_set::AWAKE_SET as usize].body_states
262                [body.local_index as usize];
263            mix_f32(&mut hash, state.linear_velocity.x);
264            mix_f32(&mut hash, state.linear_velocity.y);
265            mix_f32(&mut hash, state.angular_velocity);
266        }
267    }
268
269    hash
270}