pfx_core::sim: simulation math that replays bit for bit
A game run that has to replay from its seed (daily seeded runs, leaderboards, a peer-to-peer 1v1 where both sides simulate) may call only what this module promises. Everything in sim is pure Rust over IEEE 754 binary64 basic operations (+ - * /, comparison, sqrt, floor, bit casts and integer arithmetic), so the same inputs give the same output bits on Windows, Linux and any other target with IEEE doubles.
What is promised
Section titled “What is promised”For the same inputs, every function below returns the same bits on every supported platform, in debug and release, with any target-cpu:
| item | accuracy |
|---|---|
sin, cos, exp, ln, pow, atan2 (f64) |
within 1 ulp of the exact result over the whole domain; the tested worst cases are 0.82 ulp (atan2), 0.68 (pow), 0.61 (exp), 0.57 (cos), 0.54 (sin), 0.49 (ln) |
sinf, cosf, expf, lnf, powf, atan2f (f32) |
the f64 result rounded to f32: within 0.501 ulp of the exact result in every tested case |
sqrt, sqrtf |
exact (IEEE rounds it correctly), so std’s is used |
Rng |
xoshiro256** seeded through SplitMix64; the stream of every method is frozen by golden tests |
Overlaps |
integer boxes and integer compares only, so its hits are exact; their order is fixed (see below) |
director |
integers and Rng forks only; its orders and saved bytes are frozen by a digest test (docs/director.md) |
shots |
integer game units, angles through sim::math and rounded once; frozen by per-tick golden hashes (docs/shots.md) |
journal |
integers only; its bytes are specified exactly and frozen by a golden test (docs/journal.md) |
Special values follow C99 Annex F. Every NaN result is the canonical f64::NAN (or f32::NAN), whatever payload came in, so even NaN bits replay. sin and cos take any finite argument: below 1e6 a Cody-Waite reduction by four parts of pi/2, above it a Payne-Hanek reduction from 1,280 bits of 2/pi, so the accuracy above holds at f64::MAX too. sin(±inf) and cos(±inf) are NaN. pow(x, y) takes ln x as a double-double and multiplies it by y in double-double before exp, so large exponents do not amplify the error; a power-of-two base with an integer exponent is exact.
The f32 functions call the f64 ones and round once with as f32; they never evaluate in f32, so they cannot differ by the platform’s float width.
How it stays bit-exact
Section titled “How it stays bit-exact”- Only
+ - * /,sqrtandfloor(all exactly rounded or exact by IEEE) touch floats. Nof64::sin,exp,powfand friends: those call the platform’s libm, and glibc, musl and the Windows CRT disagree in the last bits. - No fused multiply-add. Rust never contracts
a * b + cinto an FMA on its own, and nothing here callsmul_add. A release build with-C target-cpu=nativeon a machine with FMA emits novfmaddfor the crate (checked when this module landed). The module also assumes no fast-math flags, which stable Rust cannot enable. - Constants (the pi/2 parts, ln 2, the arctangent table, the bits of 2/pi) are written as bit patterns, generated by
src/sim/golden/ref.py constsfrom 400-digit arithmetic. Series coefficients are built byconst fnfrom exact integers, so compile-time evaluation is IEEE too. - Targets without strict binary64 (x87 on 32-bit x86) are not supported. x86-64 (SSE2) and aarch64 are.
- The
Rnguses only 64-bit integer arithmetic. Floats come from integer bits:next_f64is the top 53 bits times 2^-53 andnext_f32the top 24 times 2^-24, both exact and never 1.0.
What is not promised
Section titled “What is not promised”- Presentation code: easings, camera and spring motion in
motion, particles, anything that only draws. These may usef32::sinand the rest; their output need not replay. - Rigid bodies (
pfx-physics): not deterministic across platforms by promise. - Anything outside
sim: std’ssin,exp,powf,ln,atan2,tan,cbrt,hypotand others, GPU math,HashMapiteration order, threads that race, the wall clock. - Functions
simdoes not have:tan,asin,acos,atan,sinhand the rest. Ask for them to be added rather than reaching for std.
Game logic also has to keep its own order fixed: sums over a collection add in a fixed order, and a state that is hashed or sent to a peer is built from integers or from these functions’ outputs.
The RNG
Section titled “The RNG”let mut rng = Rng::new(seed);rng.next_u64(); rng.next_u32(); rng.next_bool();rng.next_f64(); rng.next_f32();rng.below(n); rng.below_u32(n); rng.index(len);rng.range_i64(lo, hi); rng.range_i32(lo, hi); rng.range_u64(lo, hi);rng.range_f64(lo, hi); rng.range_f32(lo, hi); rng.chance(p);rng.shuffle(&mut items); rng.choose(&items);rng.weighted_index(&[f64]); rng.weighted_index_u32(&[u32]);let lane = rng.fork(label);let saved = rng.state(); let rng = Rng::from_state(saved);let bytes: [u8; 44] = rng.save(); let rng = Rng::restore(&bytes)?;below(n)and everything built on it (range_*,index,shuffle,weighted_index_u32) use Lemire’s multiply-and-reject, so a range has no modulo bias. Ranges are half-open (lo..hi);below(0)and an empty range panic.shuffleis Fisher-Yates from the back.weighted_indextakes finite non-negative weights summed in slice order and returnsNonefor an empty list, all zeros, a negative, NaN or infinite weight, or a sum that overflows. A zero weight is never chosen.weighted_index_u32is exact integer arithmetic (the weights sum inu64) and the better choice for tables the game balances by hand.fork(label)makes an independent stream from the generator’s seed and the label alone, not from its position in the stream. So a lane’s stream (rng.fork(LANE_BASE + lane),rng.fork(wave)) stays the same however much its parent or its siblings draw, and forks nest (seed.fork(a).fork(b)). Draw nothing from the root for per-lane work: fork it. Different labels give streams that are, for any practical purpose, uncorrelated; the same label under the same seed always gives the same stream.state()andfrom_state()snapshot and resume a stream (a save, a rollback, a peer resync).RngStatecarries the fork root as well as the four state words, so a resumed generator forks exactly as the original would.save()andrestore(&[u8])write and read a versioned byte form of the state, for a run that quits and resumes on any machine (see below).mix64is the SplitMix64 finalizer, usable as a plain integer hash for per-cell or per-entity values:mix64(seed ^ cell).
Overlaps: a 2D and 3D broadphase
Section titled “Overlaps: a 2D and 3D broadphase”Overlaps finds every overlapping pair between two lists of integer boxes, such as a game’s shots and its targets, once per tick:
let mut overlaps = Overlaps::new();for hit in overlaps.find(&shots, &targets) { hit_target(hit.a, hit.b);}Aabb { min, max }is half-open ini32(game units, sub-pixels or whatever the sim counts in):mininside,maxoutside, so two boxes that only touch don’t overlap, and an empty box (max <= minon either axis) hits nothing.Aabb::point(at)is the 1×1 box at a point,Aabb::around(centre, half)a box of2 * halfaround a centre.find(a, b)returns each overlapping pair once asHit { a, b }, the indices into the two lists, sorted byathenb, the order a nested loop over the two lists would give. Only integers touch the result, so it replays bit for bit on every platform.- How: when the boxes are small next to the field it bins
binto a uniform grid (cells about the boxes’ mean size, no more cells than four per box) and walks eachaover its cells in order, reporting a pair only in the cell holding the low corner of their overlap, so hits come out already sorted. When a few boxes would cover too many cells (one huge box among many small ones), it sweeps both lists along the axis where the corners spread wider instead. Both paths give the same hits. Aabb3andOverlaps3do the same in 3D with[i32; 3]corners.- It keeps its buffers between calls: after the first tick at a given size it allocates nothing. Keep one
Overlapsin the sim, not in a thread-shared place. - A fast shot can pass through a thin target between two ticks; give such a shot the box of its whole step (from where it was to where it is) and test the segment in the game.
- Cost, release build on a Ryzen 9 9950X (
bench_shots_against_targets_on_a_wide_field, the fastest of 400 runs): 2,000 shots swept to 7,000 × 34,000 against 400 targets of half-extent 22,000 to 120,000 on a 1,900,000 × 1,000,000 field, 9,240 hits a tick, take 0.12 ms (the sweep alone took 0.34 ms); 2,000 shots against 400 targets on a 3840 × 2160 field take 0.04 ms.
tests_overlap.rs checks find against every pair tested in order, both ways round, on random scenes with ties, points, empty boxes, negative corners, a box over the whole i32 range and a few huge boxes among many small ones (the sweep path), and Overlaps3 the same way.
Stream version and saved state
Section titled “Stream version and saved state”sim::STREAM_VERSION is 1. It names the stream of the generator, its seeding, fork and every derived draw (next_*, below, range_*, index, shuffle, choose, weighted_index*, chance).
The promise. Under one version, a seed and a chain of fork labels give the same sequence forever, and a saved state continues with the same draws, on every platform and in every future release of pfx. A golden test pins each part of it. Any change to those algorithms (the generator, the SplitMix64 seeding, the fork mix, the way a range, float, shuffle or weighted choice turns bits into a value) is a new version, STREAM_VERSION goes up, and the old version stays readable and runnable for as long as saved runs need it; nothing is replaced in place. A game that saves a run stores the version with the state, so a build that cannot run that version refuses the save instead of replaying it wrong.
math (sin, cos, exp, ln, pow, atan2) is promised bit for bit by its own golden tests and is not part of the stream version: an improvement there would arrive as new functions, never as new bits under an old name.
The save format. RngState::to_bytes() -> [u8; 44], all little-endian:
| bytes | field |
|---|---|
| 0..4 | u32 stream version (1) |
| 4..12 | u64 root, the seed that fork derives from |
| 12..44 | four u64 state words, s[0] first |
Rng::save() is state().to_bytes(), and Rng::restore(&[u8]) -> Result<Rng, SimError> reads it back, so the restored generator draws and forks exactly as the saved one would, and a fork saved mid-stream restores the same way. A state whose words are all zero in memory means “fresh from root” (Rng::from_state seeds it from the root); to_bytes writes that stream’s real words, so a saved state never holds zeros.
RngState::from_bytes and Rng::restore refuse, with SimError:
Length { found }: anything but 44 bytes;Version { found }: a version other than the one this build reads;ZeroState: four zero words, the one state xoshiro cannot leave.
The root is not checked: every u64 is a valid seed. A game keeps its own save around the 44 bytes (its tick, its other state) and its own checksum if it wants one.
Tests and golden data
Section titled “Tests and golden data”src/sim/golden/ holds the data the tests assert exactly:
math.txtandmath32.txt: for each function a fixed table of inputs (special values, near-multiples of pi/2, huge sine arguments, subnormals, random draws), the exact result as two doubles (hi+lo, far more than 53 bits) and the bits this module returns. The tests assert the bits exactly and that the error againsthi + lostays inside the promise above.rng.txt: the first 1,000 draws of seeds 0, 42 and 0xdeadbeefcafef00d, golden outputs of every derived method and of forks, and the 44 saved bytes of seeds 0, 42 and 0xdeadbeefcafef00d after 0, 1 and 1,000 draws and of a fork after 0 and 100.ref.py(arbitrary-precision references with Python’sdecimal;inputs,inputs32,refs,consts) andrng_ref.py(an independent port of the generator, its methods and the save layout) regenerate the references and prove the Rust against a second implementation. Thebitscolumn is this module’s own output, regenerated only when a change to the math is meant.tests_guard.rsscansmod.rs,math.rsandrng.rsand fails on any std transcendental,mul_add,unsafe, clock orlibmreference, so none can creep in. Aclippy.tomldisallowed-methodsentry would apply to the whole workspace, where the std functions are fine, so the scan guardssimalone.- Windows: the golden tests need no GPU and no files at run time (the tables are
include_str!), so on a Windows runner,cargo test -p pfx-core --lib sim::runs there unchanged, and a mismatch in any golden test is a determinism bug.