Skip to content

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.

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.

  • Only + - * /, sqrt and floor (all exactly rounded or exact by IEEE) touch floats. No f64::sin, exp, powf and 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 + c into an FMA on its own, and nothing here calls mul_add. A release build with -C target-cpu=native on a machine with FMA emits no vfmadd for 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 consts from 400-digit arithmetic. Series coefficients are built by const fn from 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 Rng uses only 64-bit integer arithmetic. Floats come from integer bits: next_f64 is the top 53 bits times 2^-53 and next_f32 the top 24 times 2^-24, both exact and never 1.0.
  • Presentation code: easings, camera and spring motion in motion, particles, anything that only draws. These may use f32::sin and the rest; their output need not replay.
  • Rigid bodies (pfx-physics): not deterministic across platforms by promise.
  • Anything outside sim: std’s sin, exp, powf, ln, atan2, tan, cbrt, hypot and others, GPU math, HashMap iteration order, threads that race, the wall clock.
  • Functions sim does not have: tan, asin, acos, atan, sinh and 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.

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.
  • shuffle is Fisher-Yates from the back.
  • weighted_index takes finite non-negative weights summed in slice order and returns None for an empty list, all zeros, a negative, NaN or infinite weight, or a sum that overflows. A zero weight is never chosen. weighted_index_u32 is exact integer arithmetic (the weights sum in u64) 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() and from_state() snapshot and resume a stream (a save, a rollback, a peer resync). RngState carries the fork root as well as the four state words, so a resumed generator forks exactly as the original would.
  • save() and restore(&[u8]) write and read a versioned byte form of the state, for a run that quits and resumes on any machine (see below).
  • mix64 is the SplitMix64 finalizer, usable as a plain integer hash for per-cell or per-entity values: mix64(seed ^ cell).

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 in i32 (game units, sub-pixels or whatever the sim counts in): min inside, max outside, so two boxes that only touch don’t overlap, and an empty box (max <= min on either axis) hits nothing. Aabb::point(at) is the 1×1 box at a point, Aabb::around(centre, half) a box of 2 * half around a centre.
  • find(a, b) returns each overlapping pair once as Hit { a, b }, the indices into the two lists, sorted by a then b, 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 b into a uniform grid (cells about the boxes’ mean size, no more cells than four per box) and walks each a over 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.
  • Aabb3 and Overlaps3 do 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 Overlaps in 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.

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.

src/sim/golden/ holds the data the tests assert exactly:

  • math.txt and math32.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 against hi + lo stays 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’s decimal; inputs, inputs32, refs, consts) and rng_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. The bits column is this module’s own output, regenerated only when a change to the math is meant.
  • tests_guard.rs scans mod.rs, math.rs and rng.rs and fails on any std transcendental, mul_add, unsafe, clock or libm reference, so none can creep in. A clippy.toml disallowed-methods entry would apply to the whole workspace, where the std functions are fine, so the scan guards sim alone.
  • 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.