sots-engine/docs/mars-rng.md

3.2 KiB
Raw Blame History

mars::rng — the engine PRNG

src/mars/rng/mt19937.h — a textbook 32-bit Mersenne Twister (MT19937), the generator the strategy simulation draws every roll from (map generation, research, encounters, raids, …). Because all lockstep peers share one seeded stream, the reimplementation has to be bit-exact and consume words in the same order; the save file carries the generator state verbatim.

API

mars::rng::MT19937 r(seed);        // seed(): Knuth initializer, then one twist (left == 624)
uint32_t y = r.next_u32();         // tempered output
float    f = r.next_float();       // (float)(y * 2^-32), see below
uint32_t k = r.next_int(n);        // uniform [0, n): power-of-two mask + rejection
r.load_state(mt, left);            // or load_state(blob, 0x9c4) from a save's "RNG" item
r.save_state(out);                 // mt[624] + left, 0x9c4 bytes little-endian
r.left(); r.index(); r.state();

State model

member meaning
mt[624] the untempered state block
left words still unread in the current block; next output is mt[624 - left]

next_u32() twists when left is 0, hands out mt[624 - left], decrements left, and tempers. A freshly seeded generator has already twisted once, so left == 624 and the first draw is mt[0].

Serialized form (the save's RNG frame)

Sim → RNG { "." raw[2503] }: 624 × uint32 (mt) followed by one int32 (left) = 0x9c4 = 2500 bytes, plus the 3 joint-padding bytes of the item. MT19937::load_state(blob, n) parses it and rejects left outside 0..624; save_state writes the same layout.

Verified on the real saves (tests/mars_stream/test_save.cpp): the 624-word block in all three saves equals seed(CreateParams.RSeed) followed by exactly two whole twists, and left decreases turn over turn (454 → 432 → 413, i.e. ~20 draws per turn). That confirms the initializer, the twist, the seed source (RSeed) and the blob layout. It does not exercise the tempering or the float mapping (those never touch the saved state).

Reference vectors (tests/mars_stream/test_rng.cpp)

  • seed 5489 → 3499211612, 581869302, 3890346734, … ; the 10000th output is 4123659995.
  • save_state/load_state round trip, left positioning, malformed-blob rejection.

Choices that still need binary confirmation

  1. Float mapping. next_float() returns (float)((double)y * 2^-32). This is the mapping recorded in the RE notes for the engine's float roll (product in double, then narrowed). Note the narrowing rounds y >= 0xFFFFFF80 up to exactly 1.0f, so the range is [0, 1] in practice. Confirm against a captured sequence before relying on the exact bits.
  2. Twist timing at the block boundary. We twist lazily when left reaches 0 (so a saved state may carry left == 0). If the original twists eagerly after the last word of a block (left then never saved as 0, block already advanced), the output sequence is identical but the saved blob at that one boundary differs. The three saves (left = 454/432/413) do not distinguish the two.
  3. next_int(n) — mask = smallest 2^k - 1 >= n - 1, reject while r >= n. The rejection scheme matches the RE description; the exact mask computation is unconfirmed.