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