From cfe9a8ccdd5025e956c748e0caa4cfa4955aa4d7 Mon Sep 17 00:00:00 2001 From: "Stephen C. Wills" Date: Tue, 17 Sep 2024 17:34:44 -0400 Subject: [PATCH] Introduce xoshiro RNG to generate dungeon seeds (#7030) --- Source/engine/random.cpp | 54 ++++++++++++- Source/engine/random.hpp | 158 ++++++++++++++++++++++++++++++++++++++- Source/loadsave.cpp | 2 +- Source/msg.cpp | 2 +- Source/multi.cpp | 9 ++- Source/multi.h | 5 +- Source/stores.cpp | 2 - test/random_test.cpp | 8 +- 8 files changed, 222 insertions(+), 18 deletions(-) diff --git a/Source/engine/random.cpp b/Source/engine/random.cpp index 0b0f9754d4e..4c36129cb29 100644 --- a/Source/engine/random.cpp +++ b/Source/engine/random.cpp @@ -1,7 +1,10 @@ #include "engine/random.hpp" +#include +#include #include #include +#include #include #include @@ -13,6 +16,51 @@ uint32_t sglGameSeed; /** Borland C/C++ psuedo-random number generator needed for vanilla compatibility */ std::linear_congruential_engine diabloGenerator; +/** Xoshiro pseudo-random number generator to provide less predictable seeds */ +xoshiro128plusplus seedGenerator; + +uint32_t xoshiro128plusplus::next() +{ + const uint32_t result = std::rotl(s[0] + s[3], 7) + s[0]; + + const uint32_t t = s[1] << 9; + + s[2] ^= s[0]; + s[3] ^= s[1]; + s[1] ^= s[2]; + s[0] ^= s[3]; + + s[2] ^= t; + + s[3] = std::rotl(s[3], 11); + + return result; +} + +uint64_t xoshiro128plusplus::timeSeed() +{ + auto now = std::chrono::system_clock::now(); + auto nano = std::chrono::nanoseconds(now.time_since_epoch()); + return static_cast(nano.count()); +} + +void xoshiro128plusplus::copy(state &dst, const state &src) +{ + memcpy(dst, src, sizeof(dst)); +} + +xoshiro128plusplus ReserveSeedSequence() +{ + xoshiro128plusplus reserved = seedGenerator; + seedGenerator.jump(); + return reserved; +} + +uint32_t GenerateSeed() +{ + return seedGenerator.next(); +} + void SetRndSeed(uint32_t seed) { diabloGenerator.seed(seed); @@ -27,12 +75,12 @@ uint32_t GetLCGEngineState() void DiscardRandomValues(unsigned count) { while (count != 0) { - GenerateSeed(); + GenerateRandomNumber(); count--; } } -uint32_t GenerateSeed() +uint32_t GenerateRandomNumber() { sglGameSeed = diabloGenerator(); return sglGameSeed; @@ -40,7 +88,7 @@ uint32_t GenerateSeed() int32_t AdvanceRndSeed() { - const int32_t seed = static_cast(GenerateSeed()); + const int32_t seed = static_cast(GenerateRandomNumber()); // since abs(INT_MIN) is undefined behavior, handle this value specially return seed == std::numeric_limits::min() ? std::numeric_limits::min() : std::abs(seed); } diff --git a/Source/engine/random.hpp b/Source/engine/random.hpp index ae81b7cd0fd..670b9a7c689 100644 --- a/Source/engine/random.hpp +++ b/Source/engine/random.hpp @@ -136,6 +136,162 @@ class DiabloGenerator { } }; +// Based on fmix32 implementation from MurmurHash3 created by Austin Appleby in 2008 +// https://github.com/aappleby/smhasher/blob/61a0530f28277f2e850bfc39600ce61d02b518de/src/MurmurHash3.cpp#L68 +// and adapted from https://prng.di.unimi.it/splitmix64.c written in 2015 by Sebastiano Vigna +// +// See also: +// Guy L. Steele, Doug Lea, and Christine H. Flood. 2014. +// Fast splittable pseudorandom number generators. SIGPLAN Not. 49, 10 (October 2014), 453–472. +// https://doi.org/10.1145/2714064.2660195 +class SplitMix32 { + uint32_t state; + +public: + SplitMix32(uint32_t state) + : state(state) + { + } + + uint32_t next() + { + uint32_t z = (state += 0x9e3779b9); + z = (z ^ (z >> 16)) * 0x85ebca6b; + z = (z ^ (z >> 13)) * 0xc2b2ae35; + return z ^ (z >> 16); + } + + void generate(uint32_t *begin, const uint32_t *end) + { + while (begin != end) { + *begin = next(); + ++begin; + } + } +}; + +// Adapted from https://prng.di.unimi.it/splitmix64.c written in 2015 by Sebastiano Vigna +// +// See also: +// Guy L. Steele, Doug Lea, and Christine H. Flood. 2014. +// Fast splittable pseudorandom number generators. SIGPLAN Not. 49, 10 (October 2014), 453–472. +// https://doi.org/10.1145/2714064.2660195 +class SplitMix64 { + uint64_t state; + +public: + SplitMix64(uint64_t state) + : state(state) + { + } + + uint64_t next() + { + uint64_t z = (state += 0x9e3779b97f4a7c15); + z = (z ^ (z >> 30)) * 0xbf58476d1ce4e5b9; + z = (z ^ (z >> 27)) * 0x94d049bb133111eb; + return z ^ (z >> 31); + } + + void generate(uint64_t *begin, const uint64_t *end) + { + while (begin != end) { + *begin = next(); + ++begin; + } + } +}; + +/** Adapted from https://prng.di.unimi.it/xoshiro128plusplus.c written in 2019 by David Blackman and Sebastiano Vigna */ +class xoshiro128plusplus { +public: + typedef uint32_t state[4]; + + xoshiro128plusplus() { seed(); } + xoshiro128plusplus(const state &s) { copy(this->s, s); } + xoshiro128plusplus(uint64_t initialSeed) { seed(initialSeed); } + xoshiro128plusplus(uint32_t initialSeed) { seed(initialSeed); } + + uint32_t next(); + + /* This is the jump function for the generator. It is equivalent + to 2^64 calls to next(); it can be used to generate 2^64 + non-overlapping subsequences for parallel computations. */ + void jump() + { + static constexpr uint32_t JUMP[] = { 0x8764000b, 0xf542d2d3, 0x6fa035c3, 0x77f2db5b }; + + uint32_t s0 = 0; + uint32_t s1 = 0; + uint32_t s2 = 0; + uint32_t s3 = 0; + for (const uint32_t entry : JUMP) + for (int b = 0; b < 32; b++) { + if (entry & UINT32_C(1) << b) { + s0 ^= s[0]; + s1 ^= s[1]; + s2 ^= s[2]; + s3 ^= s[3]; + } + next(); + } + + s[0] = s0; + s[1] = s1; + s[2] = s2; + s[3] = s3; + } + + void save(state &s) const + { + copy(s, this->s); + } + +private: + state s; + + void seed(uint64_t value) + { + uint64_t seeds[2]; + SplitMix64 seedSequence { value }; + seedSequence.generate(seeds, seeds + 2); + + s[0] = static_cast(seeds[0] >> 32); + s[1] = static_cast(seeds[0]); + s[2] = static_cast(seeds[1] >> 32); + s[3] = static_cast(seeds[1]); + } + + void seed(uint32_t value) + { + SplitMix32 seedSequence { value }; + seedSequence.generate(s, s + 4); + } + + void seed() + { + seed(timeSeed()); + + static std::random_device rd; + std::uniform_int_distribution dist; + for (uint32_t &cell : s) + cell ^= dist(rd); + } + + static uint64_t timeSeed(); + static void copy(state &dst, const state &src); +}; + +/** + * @brief Returns a copy of the global seed generator and fast-forwards the global seed generator to avoid collisions + */ +xoshiro128plusplus ReserveSeedSequence(); + +/** + * @brief Advances the global seed generator state and returns the new value + */ +uint32_t GenerateSeed(); + /** * @brief Set the state of the RandomNumberEngine used by the base game to the specific seed * @param seed New engine state @@ -163,7 +319,7 @@ void DiscardRandomValues(unsigned count); /** * @brief Advances the global RandomNumberEngine state and returns the new value */ -uint32_t GenerateSeed(); +uint32_t GenerateRandomNumber(); /** * @brief Generates a random non-negative integer (most of the time) using the vanilla RNG diff --git a/Source/loadsave.cpp b/Source/loadsave.cpp index 1962308c87f..7d62fbbca9a 100644 --- a/Source/loadsave.cpp +++ b/Source/loadsave.cpp @@ -1839,7 +1839,7 @@ void SaveLevel(SaveWriter &saveWriter, LevelConversionData *levelConversionData) DoUnVision(myPlayer.position.tile, myPlayer._pLightRad); // fix for vision staying on the level if (leveltype == DTYPE_TOWN) - DungeonSeeds[0] = AdvanceRndSeed(); + DungeonSeeds[0] = GenerateSeed(); char szName[MaxMpqPathSize]; GetTempLevelNames(szName); diff --git a/Source/msg.cpp b/Source/msg.cpp index 216aadad0da..0a6e2e445bd 100644 --- a/Source/msg.cpp +++ b/Source/msg.cpp @@ -750,7 +750,7 @@ void DeltaLeaveSync(uint8_t bLevel) if (!gbIsMultiplayer) return; if (leveltype == DTYPE_TOWN) { - DungeonSeeds[0] = AdvanceRndSeed(); + DungeonSeeds[0] = GenerateSeed(); return; } diff --git a/Source/multi.cpp b/Source/multi.cpp index fb99a31b8ed..8de5fac4141 100644 --- a/Source/multi.cpp +++ b/Source/multi.cpp @@ -6,7 +6,6 @@ #include #include -#include #include #include @@ -485,8 +484,10 @@ bool InitMulti(GameData *gameData) void InitGameInfo() { + xoshiro128plusplus gameGenerator = ReserveSeedSequence(); + gameGenerator.save(sgGameInitInfo.gameSeed); + sgGameInitInfo.size = sizeof(sgGameInitInfo); - sgGameInitInfo.dwSeed = static_cast(time(nullptr)); sgGameInitInfo.programid = GAME_ID; sgGameInitInfo.versionMajor = PROJECT_VERSION_MAJOR; sgGameInitInfo.versionMinor = PROJECT_VERSION_MINOR; @@ -787,11 +788,11 @@ bool NetInit(bool bSinglePlayer) NetClose(); gbSelectProvider = false; } - SetRndSeed(sgGameInitInfo.dwSeed); + xoshiro128plusplus gameGenerator(sgGameInitInfo.gameSeed); gnTickDelay = 1000 / sgGameInitInfo.nTickRate; for (int i = 0; i < NUMLEVELS; i++) { - DungeonSeeds[i] = AdvanceRndSeed(); + DungeonSeeds[i] = gameGenerator.next(); LevelSeeds[i] = std::nullopt; } PublicGame = DvlNet_IsPublicGame(); diff --git a/Source/multi.h b/Source/multi.h index b71c1aef914..2f44dbed560 100644 --- a/Source/multi.h +++ b/Source/multi.h @@ -22,8 +22,7 @@ struct Player; struct GameData { int32_t size; - /** Used to initialise the seed table for dungeon levels so players in multiplayer games generate the same layout */ - uint32_t dwSeed; + uint8_t reserved[4]; uint32_t programid; uint8_t versionMajor; uint8_t versionMinor; @@ -35,6 +34,8 @@ struct GameData { uint8_t bCowQuest; uint8_t bFriendlyFire; uint8_t fullQuests; + /** Used to initialise the seed table for dungeon levels so players in multiplayer games generate the same layout */ + uint32_t gameSeed[4]; }; /* @brief Contains info of running public game (for game list browsing) */ diff --git a/Source/stores.cpp b/Source/stores.cpp index 537de975f84..06229799904 100644 --- a/Source/stores.cpp +++ b/Source/stores.cpp @@ -2132,8 +2132,6 @@ void SetupTownStores() if (myPlayer._pLvlVisited[i]) l = i; } - } else { - SetRndSeed(DungeonSeeds[currlevel] * SDL_GetTicks()); } l = std::clamp(l + 2, 6, 16); diff --git a/test/random_test.cpp b/test/random_test.cpp index ed8a8bdc6b5..0da0a97bd21 100644 --- a/test/random_test.cpp +++ b/test/random_test.cpp @@ -17,10 +17,10 @@ TEST(RandomTest, RandomEngineParams) SetRndSeed(0); // Starting from a seed of 0 means the multiplicand is dropped and the state advances by increment only - ASSERT_EQ(GenerateSeed(), increment) << "Increment factor is incorrect"; + ASSERT_EQ(GenerateRandomNumber(), increment) << "Increment factor is incorrect"; // LCGs use a formula of mult * seed + inc. Using a long form in the code to document the expected factors. - ASSERT_EQ(GenerateSeed(), (multiplicand * 1) + increment) << "Multiplicand factor is incorrect"; + ASSERT_EQ(GenerateRandomNumber(), (multiplicand * 1) + increment) << "Multiplicand factor is incorrect"; // C++11 defines the default seed for a LCG engine as 1. The ten thousandth value is commonly used for sanity checking // a sequence, so as we've had one round since state 1 we need to discard another 9998 values to get to the 10000th state. @@ -28,9 +28,9 @@ TEST(RandomTest, RandomEngineParams) DiscardRandomValues(9997); uint32_t expectedState = 3495122800U; - EXPECT_EQ(GenerateSeed(), expectedState) << "Wrong engine state after 9999 invocations"; + EXPECT_EQ(GenerateRandomNumber(), expectedState) << "Wrong engine state after 9999 invocations"; expectedState = 3007658545U; - ASSERT_EQ(GenerateSeed(), expectedState) << "Wrong engine state after 10000 invocations"; + ASSERT_EQ(GenerateRandomNumber(), expectedState) << "Wrong engine state after 10000 invocations"; } TEST(RandomTest, AbsDistribution)