sots-engine/docs/mars-rng.md

5 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
double   u = r.next_unit();        // y / (2^32 - 1) in double -- see "Draw mappings"
float    f = r.next_float();       // the same draw narrowed to float32; range [0, 1] CLOSED
uint32_t k = r.next_int_inclusive(n);  // uniform [0, n] INCLUSIVE: mask covering n + 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.

Draw mappings (settled from the binary, B3)

Both public draws were re-read instruction by instruction for B3 (docs/B3.md), and both corrections below are behaviour changes, not cosmetics.

  1. The unit divisor is 2^32 - 1, not 2^32. The multiplier in the image is the double 0x3df0000000001000, which is 1/4294967295, and the sequence is: sign-extending integer load of the tempered word, + 2^32 when the signed reading is negative (the unsigned fix-up), then the multiply. So next_unit() == y / (2^32 - 1) and the range is closed: y == 0xffffffff maps to exactly 1.0, not to just below it. MT19937::kUnitScale holds the constant. The previous 2^-32 mapping differed by 2^-32 relative, which is far below a float32 ulp, so the two agree for roughly 99 words in 100 once narrowed. That is worth stating plainly: a behavioural compare over a handful of turns is not strong evidence for either divisor, and tests/mars_stream/test_rng.cpp pins the difference so nobody reads it that way.

  2. The value stays in the x87 register. The function leaves the product in st(0) and the caller narrows it; every consumer in the strategic sim stores it to a 4-byte float first, which is what next_float() models. The one thing the binary cannot tell us is the x87 precision-control field in force at run time: at the MSVC default (53-bit) the multiply rounds to double and the caller's store rounds again, while a Direct3D 9 device created without FPU_PRESERVE leaves 24-bit precision, in which the fix-up and the multiply each round to 24 bits. float_from_pc24() models the second case; the shim records the control word with every ProcessResearch call so one run settles it. The two mappings can only differ in the last bit of the float.

  3. next_int_inclusive(n) is inclusive. The mask is the smallest 2^k - 1 that is >= n (computed from n itself, not n - 1), and the loop re-draws while the masked word is greater than n — so the result is uniform on [0, n], one value wider than the half-open range the notes assumed. n == 0 masks to 0 and still consumes a word. The bound reaches the callee by pointer, which is why the prototype had stayed unverified.

Choices that still need binary confirmation

  1. Twist timing at the block boundary. We twist lazily when left reaches 0 (so a saved state may carry left == 0). The original's NextFloat/NextInt both test left == 0 on entry, which is the same lazy rule; what the three saves (left = 454/432/413) still do not show is a state saved exactly at the boundary.