86 lines
5 KiB
Markdown
86 lines
5 KiB
Markdown
# 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
|
||
|
||
```cpp
|
||
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.
|