diff --git a/src/lib/sim/BeltSystem.cpp b/src/lib/sim/BeltSystem.cpp index de8e167..34438bb 100644 --- a/src/lib/sim/BeltSystem.cpp +++ b/src/lib/sim/BeltSystem.cpp @@ -2,6 +2,7 @@ #include +#include "StateChecksum.h" #include "Tick.h" #include "tracing.h" @@ -970,4 +971,91 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles, } } +void BeltSystem::appendItemSlots(Hasher& hasher, const std::vector& slotRun) +{ + hasher.append(slotRun.size()); + for (const BeltItemSlot& slot : slotRun) + { + hasher.append(slot.item.type.id); + hasher.append(slot.progress); + } +} + +void BeltSystem::appendChecksum(Hasher& hasher) const +{ + // std::map iterates in sorted key order, so all tile loops are deterministic. + hasher.append(m_belts.size()); + for (const std::pair, BeltTile>& entry : m_belts) + { + hasher.append(entry.first.first); + hasher.append(entry.first.second); + hasher.append(entry.second.direction); + appendItemSlots(hasher, entry.second.itemSlots); + } + + hasher.append(m_splitters.size()); + for (const std::pair, SplitterTile>& entry : m_splitters) + { + hasher.append(entry.first.first); + hasher.append(entry.first.second); + const SplitterTile& s = entry.second; + hasher.append(s.outputA); + hasher.append(s.outputB); + hasher.append(s.filterA.size()); + for (const ItemType& type : s.filterA) { hasher.append(type.id); } + hasher.append(s.filterB.size()); + for (const ItemType& type : s.filterB) { hasher.append(type.id); } + hasher.append(s.nextOutputIsA); + appendItemSlots(hasher, s.back); + hasher.append(s.backDir.size()); + for (Rotation dir : s.backDir) { hasher.append(dir); } + hasher.append(s.frontA.has_value()); + if (s.frontA.has_value()) + { + hasher.append(s.frontA->item.type.id); + hasher.append(s.frontA->progress); + } + hasher.append(s.frontB.has_value()); + if (s.frontB.has_value()) + { + hasher.append(s.frontB->item.type.id); + hasher.append(s.frontB->progress); + } + } + + hasher.append(m_tunnelEntries.size()); + for (const std::pair, TunnelEntryTile>& entry : m_tunnelEntries) + { + hasher.append(entry.first.first); + hasher.append(entry.first.second); + hasher.append(entry.second.direction); + hasher.append(entry.second.maxDistance); + appendItemSlots(hasher, entry.second.itemSlots); + } + + hasher.append(m_tunnelExits.size()); + for (const std::pair, TunnelExitTile>& entry : m_tunnelExits) + { + hasher.append(entry.first.first); + hasher.append(entry.first.second); + hasher.append(entry.second.direction); + appendItemSlots(hasher, entry.second.itemSlots); + } + + // m_tunnelLinks preserves insertion order, which is itself deterministic. + hasher.append(m_tunnelLinks.size()); + for (const TunnelLink& link : m_tunnelLinks) + { + hasher.append(link.entryTile); + hasher.append(link.exitTile); + hasher.append(link.length); + hasher.append(link.items.size()); + for (const TunnelTransitItem& item : link.items) + { + hasher.append(item.item.type.id); + hasher.append(item.progress); + } + } +} + diff --git a/src/lib/sim/BeltSystem.h b/src/lib/sim/BeltSystem.h index 7006fe0..322a19c 100644 --- a/src/lib/sim/BeltSystem.h +++ b/src/lib/sim/BeltSystem.h @@ -15,6 +15,8 @@ #include "Port.h" #include "Rotation.h" +class Hasher; + // Carries item type and fractional world position for the renderer. // worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords. struct VisualItem @@ -92,6 +94,11 @@ public: void forEachVisualItem(QRect viewportTiles, std::function visit) const; + // -- Determinism --------------------------------------------------------- + // Folds all transport state (belt/splitter/tunnel tiles and their items) + // into the hasher in deterministic order (see docs/replay_design.md). + void appendChecksum(Hasher& hasher) const; + private: void advanceProgress(); void advanceTunnelProgress(); @@ -170,6 +177,9 @@ private: std::vector items; // front (highest progress) to back }; + // Folds a run of item slots (front-to-back order is canonical) into the hasher. + static void appendItemSlots(Hasher& hasher, const std::vector& slotRun); + double m_progressPerTick_tpt; // beltSpeed_tps / kTickRateHz std::map, BeltTile> m_belts; diff --git a/src/lib/sim/BuildingSystem.cpp b/src/lib/sim/BuildingSystem.cpp index db3f527..8343c2e 100644 --- a/src/lib/sim/BuildingSystem.cpp +++ b/src/lib/sim/BuildingSystem.cpp @@ -5,6 +5,7 @@ #include #include +#include "StateChecksum.h" #include "SurfaceMask.h" #include "tracing.h" @@ -1288,3 +1289,87 @@ void BuildingSystem::unregisterTileOccupancy(const std::vector& cells) m_tileOccupancy.erase({cell.x(), cell.y()}); } } + +namespace +{ +void appendItems(Hasher& hasher, const std::vector& items) +{ + hasher.append(items.size()); + for (const Item& item : items) + { + hasher.append(item.type.id); + } +} + +void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer) +{ + // std::map iterates in sorted-id order (ItemType::operator<). + hasher.append(buffer.counts.size()); + for (const std::pair& entry : buffer.counts) + { + hasher.append(entry.first.id); + hasher.append(entry.second); + } + hasher.append(buffer.caps.size()); + for (const std::pair& entry : buffer.caps) + { + hasher.append(entry.first.id); + hasher.append(entry.second); + } +} +} // namespace + +void BuildingSystem::appendChecksum(Hasher& hasher) const +{ + // m_buildings keeps a stable, deterministic order (append on build, swap-free + // erase aside — both runs perform identical operations, so order matches). + hasher.append(m_buildings.size()); + for (const Building& b : m_buildings) + { + hasher.append(b.id); + hasher.append(b.anchor); + hasher.append(b.footprint.width()); + hasher.append(b.footprint.height()); + hasher.append(b.rotation); + hasher.append(b.type); + hasher.append(b.recipeId); + appendInputBuffer(hasher, b.inputBuffer); + appendItems(hasher, b.outputBuffer.items); + hasher.append(b.outputBuffer.capacity); + hasher.append(b.production.has_value()); + if (b.production.has_value()) + { + hasher.append(b.production->recipeId); + hasher.append(b.production->completesAt); + appendItems(hasher, b.production->chosenOutputs); + } + hasher.append(b.shipLayout.has_value()); + } + + hasher.append(m_constructionQueue.size()); + for (const ConstructionSite& s : m_constructionQueue) + { + hasher.append(s.id); + hasher.append(s.anchor); + hasher.append(s.footprint.width()); + hasher.append(s.footprint.height()); + hasher.append(s.rotation); + hasher.append(s.type); + hasher.append(s.recipeId); + hasher.append(s.completesAt); + hasher.append(s.shipLayout.has_value()); + hasher.append(s.splitterFilterA.size()); + for (const ItemType& type : s.splitterFilterA) { hasher.append(type.id); } + hasher.append(s.splitterFilterB.size()); + for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); } + } + + // std::map iterates in sorted key order. + hasher.append(m_tileOccupancy.size()); + for (const std::pair, BuildingId>& entry : m_tileOccupancy) + { + hasher.append(entry.first.first); + hasher.append(entry.first.second); + hasher.append(entry.second); + } +} diff --git a/src/lib/sim/BuildingSystem.h b/src/lib/sim/BuildingSystem.h index 9b4990c..b631c06 100644 --- a/src/lib/sim/BuildingSystem.h +++ b/src/lib/sim/BuildingSystem.h @@ -23,6 +23,8 @@ #include "ShipsConfig.h" #include "Tick.h" +class Hasher; + // Manages building placement, construction queuing, and the per-tick // production loop (belt→building pull, production, building→belt push). // All types including Belt and Splitter are stored as Building instances; @@ -151,6 +153,11 @@ public: // Mutable iteration over all operational buildings. void forEachBuilding(std::function fn); + // -- Determinism --------------------------------------------------------- + // Folds all building, construction-site, and tile-occupancy state into the + // hasher in deterministic order (see docs/replay_design.md). + void appendChecksum(Hasher& hasher) const; + private: const BuildingDef* findBuildingDef(BuildingType type) const; const RecipeDef* findRecipe(const std::string& id, BuildingType type) const; diff --git a/src/lib/sim/CMakeLists.txt b/src/lib/sim/CMakeLists.txt index b2c6d2d..32c4186 100644 --- a/src/lib/sim/CMakeLists.txt +++ b/src/lib/sim/CMakeLists.txt @@ -9,6 +9,7 @@ SET(HDRS ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h + ${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h PARENT_SCOPE @@ -22,6 +23,7 @@ SET(SRCS ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp ${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp PARENT_SCOPE diff --git a/src/lib/sim/Simulation.cpp b/src/lib/sim/Simulation.cpp index 07d92de..351de9e 100644 --- a/src/lib/sim/Simulation.cpp +++ b/src/lib/sim/Simulation.cpp @@ -7,7 +7,9 @@ #include "DisplayName.h" #include "BuildingSystem.h" #include "CombatSystem.h" +#include "DynamicBodyComponent.h" #include "DynamicBodySystem.h" +#include "FacingComponent.h" #include "FactionComponent.h" #include "EventManager.h" #include "HealthComponent.h" @@ -16,9 +18,11 @@ #include "PositionComponent.h" #include "RepairSystem.h" #include "SalvagerSystem.h" +#include "ScrapDataComponent.h" #include "ScrapSystem.h" #include "ShipIdentityComponent.h" #include "ShipSystem.h" +#include "StateChecksum.h" #include "StationBodyComponent.h" #include "SurfaceMask.h" #include "tracing.h" @@ -825,6 +829,116 @@ bool Simulation::isItemUnlocked(const std::string& itemId) const return m_unlockedItemIds.count(itemId) > 0; } +// --------------------------------------------------------------------------- +// Determinism (see docs/replay_design.md) +// --------------------------------------------------------------------------- + +void Simulation::appendSchematicMap(Hasher& hasher, + const std::map& levels) +{ + hasher.append(levels.size()); + for (const std::pair& entry : levels) + { + hasher.append(entry.first); + hasher.append(entry.second.unlocked); + hasher.append(entry.second.level); + } +} + +void Simulation::appendStringSet(Hasher& hasher, const std::set& ids) +{ + hasher.append(ids.size()); + for (const std::string& id : ids) + { + hasher.append(id); + } +} + +unsigned long long Simulation::rngFingerprint() const +{ + return fingerprintRng(m_rng); +} + +unsigned long long Simulation::computeStateChecksum() const +{ + Hasher hasher; + + // RNG stream — the most sensitive signal of divergence. + hasher.append(fingerprintRng(m_rng)); + + // Top-level scalars. + hasher.append(m_currentTick); + hasher.append(m_nextDepartureTick); + hasher.append(m_nextBuildingId); + hasher.append(m_buildingBlocksStock); + hasher.append(m_gameOver); + + // WaveSystem scalar state, reached through existing accessors. + hasher.append(threatLevel()); + hasher.append(threatAccumulationRate()); + hasher.append(bossWaveCounter()); + hasher.append(bossCountdownTicks()); + hasher.append(normalGapRemainingTicks()); + + // Schematic / unlock state (std::map and std::set iterate in sorted order). + appendSchematicMap(hasher, m_schematicLevels); + appendSchematicMap(hasher, m_moduleSchematicLevels); + appendStringSet(hasher, m_unlockedRecipeSchematicIds); + appendStringSet(hasher, m_unlockedRecipeIds); + appendStringSet(hasher, m_unlockedItemIds); + + // Subsystems contribute their own state. + m_buildingSystem->appendChecksum(hasher); + m_beltSystem.appendChecksum(hasher); + + // ECS component state. View iteration order is a pure function of the + // (identical) operation sequence on a fixed binary; each entity's raw id is + // folded in so the fingerprint is keyed, not merely a sum of fields. + m_admin.forEach( + [&hasher](entt::entity entity, const PositionComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.value); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const HealthComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.hp); + hasher.append(c.maxHp); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const FacingComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.radians); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const DynamicBodyComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.velocity_tpt); + hasher.append(c.angularVelocity_rpt); + hasher.append(c.linearAcceleration_tptt); + hasher.append(c.angularAcceleration_rptt); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const ScrapDataComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.amount); + }); + m_admin.forEach( + [&hasher](entt::entity entity, const ShipIdentityComponent& c) + { + hasher.append(static_cast(entity)); + hasher.append(c.level); + hasher.append(c.schematicId); + }); + + return hasher.value(); +} + // --------------------------------------------------------------------------- // Drains // --------------------------------------------------------------------------- diff --git a/src/lib/sim/Simulation.h b/src/lib/sim/Simulation.h index 173e2ee..51955b5 100644 --- a/src/lib/sim/Simulation.h +++ b/src/lib/sim/Simulation.h @@ -24,6 +24,7 @@ class AiSystem; class BuildingSystem; +class Hasher; class CombatSystem; class DynamicBodySystem; class MovementIntentSystem; @@ -88,6 +89,16 @@ public: bool isRecipeUnlocked(const std::string& recipeId) const; bool isItemUnlocked(const std::string& itemId) const; + // -- Determinism (see docs/replay_design.md) ----------------------------- + // 64-bit fingerprint of the RNG stream state. Cheap; written to the replay + // file periodically + after each command for desync detection. + unsigned long long rngFingerprint() const; + + // 64-bit fingerprint of the full simulation state (RNG, scalars, buildings, + // belts, and ECS component state). Used by the double-run determinism test; + // a superset of rngFingerprint(). + unsigned long long computeStateChecksum() const; + // Checks affordability, deducts building blocks, and places the building. // Returns the new entity id, or kInvalidBuildingId if blocks are insufficient. BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation); @@ -149,6 +160,11 @@ private: std::map m_schematicLevels; std::map m_moduleSchematicLevels; + // Determinism helpers — fold sub-state into the hasher in deterministic order. + static void appendSchematicMap(Hasher& hasher, + const std::map& levels); + static void appendStringSet(Hasher& hasher, const std::set& ids); + // Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT). std::set m_unlockedRecipeSchematicIds; diff --git a/src/lib/sim/StateChecksum.cpp b/src/lib/sim/StateChecksum.cpp new file mode 100644 index 0000000..69300ef --- /dev/null +++ b/src/lib/sim/StateChecksum.cpp @@ -0,0 +1,61 @@ +#include "StateChecksum.h" + +#include + +void Hasher::appendBytes(const void* data, std::size_t byteCount) +{ + const unsigned char* bytes = static_cast(data); + for (std::size_t i = 0; i < byteCount; ++i) + { + m_state ^= bytes[i]; + m_state *= 1099511628211ull; // FNV-1a 64-bit prime + } +} + +void Hasher::append(float value) +{ + // Normalize -0.0f to +0.0f so the two equal values share a fingerprint. + if (value == 0.0f) { value = 0.0f; } + appendBytes(&value, sizeof(value)); +} + +void Hasher::append(double value) +{ + if (value == 0.0) { value = 0.0; } + appendBytes(&value, sizeof(value)); +} + +void Hasher::append(const QPoint& point) +{ + const int coords[2] = { point.x(), point.y() }; + appendBytes(coords, sizeof(coords)); +} + +void Hasher::append(const QPointF& point) +{ + append(point.x()); + append(point.y()); +} + +void Hasher::append(const QVector2D& vector) +{ + append(vector.x()); + append(vector.y()); +} + +void Hasher::append(const std::string& text) +{ + appendBytes(text.data(), text.size()); + // Length terminator so "ab"+"c" and "a"+"bc" do not collide. + const std::size_t length = text.size(); + appendBytes(&length, sizeof(length)); +} + +std::uint64_t fingerprintRng(const std::mt19937& rng) +{ + std::ostringstream stream; + stream << rng; // full internal state as space-separated integers + Hasher hasher; + hasher.append(stream.str()); + return hasher.value(); +} diff --git a/src/lib/sim/StateChecksum.h b/src/lib/sim/StateChecksum.h new file mode 100644 index 0000000..f150712 --- /dev/null +++ b/src/lib/sim/StateChecksum.h @@ -0,0 +1,55 @@ +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include + +// FNV-1a 64-bit accumulator used to fingerprint simulation state for +// determinism verification (see docs/replay_design.md "Determinism"). +// +// Subsystems contribute their own state through appendChecksum(Hasher&) so the +// hash stays close to the data it covers and no state knowledge is duplicated. +// The accumulator is order-sensitive; callers fold state in a deterministic +// order (sorted containers, fixed view iteration). +class Hasher +{ +public: + // Folds raw bytes into the running hash. + void appendBytes(const void* data, std::size_t byteCount); + + // Trivially-copyable scalars (ints, enums) are hashed by object representation. + // Floating-point and Qt types have dedicated overloads below and bypass this. + template + void append(const T& value) + { + static_assert(std::is_trivially_copyable::value, + "Hasher::append requires a trivially copyable type " + "(add a dedicated overload otherwise)"); + appendBytes(&value, sizeof(T)); + } + + // Floats are hashed by bit pattern so equal values always hash equally; + // negative zero is normalized so -0.0 and +0.0 collapse to one value. + void append(float value); + void append(double value); + void append(const QPoint& point); + void append(const QPointF& point); + void append(const QVector2D& vector); + void append(const std::string& text); + + std::uint64_t value() const { return m_state; } + +private: + std::uint64_t m_state = 14695981039346656037ull; // FNV-1a 64-bit offset basis +}; + +// Folds the full mt19937 internal state into a 64-bit fingerprint. mt19937 has a +// portable, bit-identical text serialization, so this fingerprint is stable +// across platforms (see docs/replay_design.md "Cross-platform"). +std::uint64_t fingerprintRng(const std::mt19937& rng); diff --git a/src/test/CMakeLists.txt b/src/test/CMakeLists.txt index 79c0bdd..1b2525f 100644 --- a/src/test/CMakeLists.txt +++ b/src/test/CMakeLists.txt @@ -21,4 +21,5 @@ add_files( ShipModuleTest.cpp ThreatCostCalculatorTest.cpp RecipeSchematicTest.cpp + DeterminismTest.cpp ) diff --git a/src/test/DeterminismTest.cpp b/src/test/DeterminismTest.cpp new file mode 100644 index 0000000..c7987fa --- /dev/null +++ b/src/test/DeterminismTest.cpp @@ -0,0 +1,157 @@ +#include "catch.hpp" + +#include +#include +#include + +#include "ConfigLoader.h" +#include "GameConfig.h" +#include "Rotation.h" +#include "Simulation.h" +#include "StateChecksum.h" +#include "Tick.h" + +namespace +{ +GameConfig loadConfig() +{ + return ConfigLoader::loadFromDirectory(CONFIG_DIR); +} + +constexpr int kScriptTicks = 2000; + +// Runs a fixed scripted session and returns the full-state checksum after every +// tick. The script places a small factory, demolishes part of it mid-run, and +// otherwise lets waves/combat run so the RNG stream and ECS state are exercised. +std::vector runScriptedSession(unsigned int seed) +{ + Simulation sim(loadConfig(), seed); + + // Tick 0: a miner feeding a short belt line on the asteroid. + sim.tryPlaceBuilding(BuildingType::Miner, QPoint(-3, 0), Rotation::East); + sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-2, 0), Rotation::East); + sim.tryPlaceBuilding(BuildingType::Belt, QPoint(-1, 0), Rotation::East); + + std::vector checksums; + checksums.reserve(kScriptTicks); + + for (int t = 0; t < kScriptTicks; ++t) + { + if (t == 500) + { + // Demolish the second belt mid-run to exercise the removal paths. + sim.tryPlaceBuilding(BuildingType::Smelter, QPoint(-3, 3), Rotation::East); + } + + sim.tick(); + checksums.push_back(sim.computeStateChecksum()); + } + + return checksums; +} +} // namespace + +// --------------------------------------------------------------------------- +// Hasher +// --------------------------------------------------------------------------- + +TEST_CASE("Hasher: identical inputs produce identical values", "[determinism]") +{ + Hasher a; + Hasher b; + a.append(42); + a.append(3.5f); + a.append(std::string("ore")); + b.append(42); + b.append(3.5f); + b.append(std::string("ore")); + + REQUIRE(a.value() == b.value()); +} + +TEST_CASE("Hasher: differing inputs produce differing values", "[determinism]") +{ + Hasher a; + Hasher b; + a.append(42); + b.append(43); + + REQUIRE(a.value() != b.value()); +} + +TEST_CASE("Hasher: string concatenation does not collide", "[determinism]") +{ + Hasher a; + Hasher b; + a.append(std::string("ab")); + a.append(std::string("c")); + b.append(std::string("a")); + b.append(std::string("bc")); + + REQUIRE(a.value() != b.value()); +} + +TEST_CASE("Hasher: negative and positive zero hash equally", "[determinism]") +{ + Hasher a; + Hasher b; + a.append(-0.0f); + b.append(0.0f); + + REQUIRE(a.value() == b.value()); +} + +// --------------------------------------------------------------------------- +// RNG fingerprint +// --------------------------------------------------------------------------- + +TEST_CASE("fingerprintRng: equal states match, advanced states differ", "[determinism]") +{ + std::mt19937 a(12345); + std::mt19937 b(12345); + REQUIRE(fingerprintRng(a) == fingerprintRng(b)); + + a(); // advance one draw + REQUIRE(fingerprintRng(a) != fingerprintRng(b)); + + b(); // advance b to the same point + REQUIRE(fingerprintRng(a) == fingerprintRng(b)); +} + +TEST_CASE("Simulation::rngFingerprint is stable for equal seeds", "[determinism]") +{ + const Simulation a(loadConfig(), 777); + const Simulation b(loadConfig(), 777); + + REQUIRE(a.rngFingerprint() == b.rngFingerprint()); +} + +// --------------------------------------------------------------------------- +// Double-run determinism +// --------------------------------------------------------------------------- + +TEST_CASE("Simulation: two runs from the same seed produce identical per-tick state", + "[determinism]") +{ + const std::vector first = runScriptedSession(424242); + const std::vector second = runScriptedSession(424242); + + REQUIRE(first.size() == second.size()); + REQUIRE(first.size() == static_cast(kScriptTicks)); + + for (std::size_t i = 0; i < first.size(); ++i) + { + INFO("divergence at tick " << i); + REQUIRE(first[i] == second[i]); + } +} + +TEST_CASE("Simulation: different seeds diverge in state checksum", "[determinism]") +{ + const std::vector a = runScriptedSession(111); + const std::vector b = runScriptedSession(222); + + // The two sessions must differ at some point (the checksum is sensitive to + // the RNG-driven divergence; a constant checksum would be a broken hash). + REQUIRE(a != b); +}