From 40ffe02435c572224a7591a4f54cfbf18576b57c Mon Sep 17 00:00:00 2001 From: learncold Date: Thu, 16 Apr 2026 15:32:25 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EC=97=94=EC=A7=84=20runtime-owned=20reset?= =?UTF-8?q?=20contract=20=ED=99=95=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 2 + src/engine/DeterministicRng.h | 47 ++++++++++++++ src/engine/EngineRuntime.cpp | 44 ++++++------- src/engine/EngineRuntime.h | 2 + tests/DeterministicRngTests.cpp | 28 +++++++++ tests/EngineRuntimeTests.cpp | 61 +++++++++++++++++++ uml/engine-overview.puml | 3 + uml/engine-runtime-core.puml | 4 ++ ...ime-core.puml \355\225\264\354\204\244.md" | 2 + 9 files changed, 171 insertions(+), 22 deletions(-) create mode 100644 src/engine/DeterministicRng.h create mode 100644 tests/DeterministicRngTests.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index c3ffb95..0d01845 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -43,6 +43,7 @@ add_library(ecs_engine STATIC src/engine/ComponentRegistry.h src/engine/EcsCore.h src/engine/EngineConfig.h + src/engine/DeterministicRng.h src/engine/EngineRuntime.h src/engine/EngineWorld.h src/engine/EngineState.h @@ -138,6 +139,7 @@ if (BUILD_TESTING) tests/SystemSchedulerTests.cpp tests/EngineIntegrationTests.cpp tests/ResourceStoreTests.cpp + tests/DeterministicRngTests.cpp ) target_include_directories(safecrowd_tests diff --git a/src/engine/DeterministicRng.h b/src/engine/DeterministicRng.h new file mode 100644 index 0000000..d5d2d8b --- /dev/null +++ b/src/engine/DeterministicRng.h @@ -0,0 +1,47 @@ +#pragma once + +#include + +namespace safecrowd::engine { + +class DeterministicRng { +public: + explicit DeterministicRng(std::uint64_t seed = 1) noexcept { + reseed(seed); + } + + void reseed(std::uint64_t seed) noexcept { + baseSeed_ = seed == 0 ? 1 : seed; + state_ = mix(baseSeed_); + } + + [[nodiscard]] std::uint64_t baseSeed() const noexcept { + return baseSeed_; + } + + [[nodiscard]] std::uint64_t derive(std::uint64_t runIndex, + std::uint64_t fixedStepIndex) const noexcept { + auto state = mix(baseSeed_); + state = mix(state ^ mix(runIndex)); + state = mix(state ^ mix(fixedStepIndex)); + return state; + } + + [[nodiscard]] std::uint64_t next() noexcept { + state_ = mix(state_); + return state_; + } + +private: + static std::uint64_t mix(std::uint64_t value) noexcept { + value += 0x9e3779b97f4a7c15ULL; + value = (value ^ (value >> 30U)) * 0xbf58476d1ce4e5b9ULL; + value = (value ^ (value >> 27U)) * 0x94d049bb133111ebULL; + return value ^ (value >> 31U); + } + + std::uint64_t baseSeed_{1}; + std::uint64_t state_{0}; +}; + +} // namespace safecrowd::engine diff --git a/src/engine/EngineRuntime.cpp b/src/engine/EngineRuntime.cpp index c0161b0..38e8e59 100644 --- a/src/engine/EngineRuntime.cpp +++ b/src/engine/EngineRuntime.cpp @@ -19,13 +19,26 @@ EngineConfig normalizeConfig(EngineConfig config) { return config; } +EngineStepContext makeStepContext(const DeterministicRng& rng, std::uint64_t frameIndex, + std::uint64_t fixedStepIndex, double alpha, + std::uint64_t runIndex) { + return EngineStepContext{ + .frameIndex = frameIndex, + .fixedStepIndex = fixedStepIndex, + .alpha = alpha, + .runIndex = runIndex, + .derivedSeed = rng.derive(runIndex, fixedStepIndex), + }; +} + } // namespace EngineRuntime::EngineRuntime(EngineConfig config) : config_(normalizeConfig(config)), scheduler_(core_, buffer_), world_(EngineWorld::ConstructionToken{}, core_, resources_, buffer_), - frameClock_(config_) { + frameClock_(config_), + rng_(config_.baseSeed) { } void EngineRuntime::addSystem(std::unique_ptr system, @@ -39,19 +52,15 @@ void EngineRuntime::initialize() { resources_ = ResourceStore{}; buffer_ = CommandBuffer{}; scheduler_.resetCadenceState(); + rng_.reseed(config_.baseSeed); stats_ = {}; stats_.state = EngineState::Ready; ++runIndex_; scheduler_.configure(world_); - const EngineStepContext startupCtx{ - .frameIndex = stats_.frameIndex, - .fixedStepIndex = stats_.fixedStepIndex, - .alpha = 0.0, - .runIndex = runIndex_, - .derivedSeed = 0, - }; + const EngineStepContext startupCtx = makeStepContext( + rng_, stats_.frameIndex, stats_.fixedStepIndex, 0.0, runIndex_); scheduler_.executeStartup(world_, startupCtx); } @@ -75,6 +84,7 @@ void EngineRuntime::stop() { resources_ = ResourceStore{}; buffer_ = CommandBuffer{}; scheduler_.resetCadenceState(); + rng_.reseed(config_.baseSeed); stats_ = {}; stats_.state = EngineState::Stopped; } @@ -94,13 +104,8 @@ void EngineRuntime::stepFrame(double deltaSeconds) { ++stats_.frameIndex; stats_.fixedStepsThisFrame = 0; - EngineStepContext ctx{ - .frameIndex = stats_.frameIndex, - .fixedStepIndex = stats_.fixedStepIndex, - .alpha = frameClock_.alpha(), - .runIndex = runIndex_, - .derivedSeed = 0, - }; + auto ctx = makeStepContext(rng_, stats_.frameIndex, stats_.fixedStepIndex, + frameClock_.alpha(), runIndex_); scheduler_.executePhase(UpdatePhase::PreSimulation, world_, ctx); @@ -109,13 +114,8 @@ void EngineRuntime::stepFrame(double deltaSeconds) { ++stats_.fixedStepIndex; ++stats_.fixedStepsThisFrame; - ctx = EngineStepContext{ - .frameIndex = stats_.frameIndex, - .fixedStepIndex = stats_.fixedStepIndex, - .alpha = frameClock_.alpha(), - .runIndex = runIndex_, - .derivedSeed = 0, - }; + ctx = makeStepContext(rng_, stats_.frameIndex, stats_.fixedStepIndex, + frameClock_.alpha(), runIndex_); scheduler_.executePhase(UpdatePhase::FixedSimulation, world_, ctx); } diff --git a/src/engine/EngineRuntime.h b/src/engine/EngineRuntime.h index 6c7d823..46a0a77 100644 --- a/src/engine/EngineRuntime.h +++ b/src/engine/EngineRuntime.h @@ -4,6 +4,7 @@ #include #include "engine/CommandBuffer.h" +#include "engine/DeterministicRng.h" #include "engine/EcsCore.h" #include "engine/EngineConfig.h" #include "engine/EngineStats.h" @@ -45,6 +46,7 @@ class EngineRuntime { SystemScheduler scheduler_; EngineWorld world_; FrameClock frameClock_; + DeterministicRng rng_; std::uint64_t runIndex_{0}; }; diff --git a/tests/DeterministicRngTests.cpp b/tests/DeterministicRngTests.cpp new file mode 100644 index 0000000..0db9973 --- /dev/null +++ b/tests/DeterministicRngTests.cpp @@ -0,0 +1,28 @@ +#include "TestSupport.h" + +#include "engine/DeterministicRng.h" + +SC_TEST(DeterministicRng_Derive_IsStableForSameInputs) { + safecrowd::engine::DeterministicRng rng{17}; + + const auto first = rng.derive(2, 5); + const auto second = rng.derive(2, 5); + const auto differentRun = rng.derive(3, 5); + const auto differentStep = rng.derive(2, 6); + + SC_EXPECT_EQ(first, second); + SC_EXPECT_TRUE(first != differentRun); + SC_EXPECT_TRUE(first != differentStep); +} + +SC_TEST(DeterministicRng_Reseed_RestartsSequence) { + safecrowd::engine::DeterministicRng rng{23}; + + const auto first = rng.next(); + const auto second = rng.next(); + + rng.reseed(23); + + SC_EXPECT_EQ(rng.next(), first); + SC_EXPECT_EQ(rng.next(), second); +} diff --git a/tests/EngineRuntimeTests.cpp b/tests/EngineRuntimeTests.cpp index 4b5836d..57b49b6 100644 --- a/tests/EngineRuntimeTests.cpp +++ b/tests/EngineRuntimeTests.cpp @@ -1,6 +1,7 @@ #include "TestSupport.h" #include +#include #include #include #include @@ -67,6 +68,22 @@ class RecordPhaseSystem : public safecrowd::engine::EngineSystem { } }; +class RecordSeedSystem : public safecrowd::engine::EngineSystem { +public: + std::vector& seeds; + std::vector& runs; + + RecordSeedSystem(std::vector& seedLog, + std::vector& runLog) + : seeds(seedLog), runs(runLog) {} + + void update(safecrowd::engine::EngineWorld&, + const safecrowd::engine::EngineStepContext& step) override { + seeds.push_back(step.derivedSeed); + runs.push_back(step.runIndex); + } +}; + class ResourceSetupSystem : public safecrowd::engine::EngineSystem { public: void configure(safecrowd::engine::EngineWorld& world) override { @@ -371,3 +388,47 @@ SC_TEST(EngineRuntime_Initialize_ClearsExistingWorldResources) { SC_EXPECT_TRUE(!runtime.world().resources().contains()); } + +SC_TEST(EngineRuntime_StopAndRestart_RebuildsDeterministicSeedStream) { + std::vector firstSeeds; + std::vector firstRuns; + safecrowd::engine::EngineRuntime firstRuntime({ + .fixedDeltaTime = 0.25, + .maxCatchUpSteps = 4, + .baseSeed = 19, + }); + + firstRuntime.addSystem(std::make_unique(firstSeeds, firstRuns)); + firstRuntime.play(); + firstRuntime.stepFrame(0.25); + firstRuntime.stop(); + firstRuntime.play(); + firstRuntime.stepFrame(0.25); + + std::vector secondSeeds; + std::vector secondRuns; + safecrowd::engine::EngineRuntime secondRuntime({ + .fixedDeltaTime = 0.25, + .maxCatchUpSteps = 4, + .baseSeed = 19, + }); + + secondRuntime.addSystem(std::make_unique(secondSeeds, secondRuns)); + secondRuntime.play(); + secondRuntime.stepFrame(0.25); + secondRuntime.stop(); + secondRuntime.play(); + secondRuntime.stepFrame(0.25); + + SC_EXPECT_EQ(firstSeeds.size(), std::size_t{2}); + SC_EXPECT_EQ(secondSeeds.size(), std::size_t{2}); + SC_EXPECT_EQ(firstRuns.size(), std::size_t{2}); + SC_EXPECT_EQ(secondRuns.size(), std::size_t{2}); + SC_EXPECT_EQ(firstRuns[0], 1ULL); + SC_EXPECT_EQ(firstRuns[1], 2ULL); + SC_EXPECT_EQ(secondRuns[0], 1ULL); + SC_EXPECT_EQ(secondRuns[1], 2ULL); + SC_EXPECT_EQ(firstSeeds[0], secondSeeds[0]); + SC_EXPECT_EQ(firstSeeds[1], secondSeeds[1]); + SC_EXPECT_TRUE(firstSeeds[0] != firstSeeds[1]); +} diff --git a/uml/engine-overview.puml b/uml/engine-overview.puml index 713b4f5..b90e909 100644 --- a/uml/engine-overview.puml +++ b/uml/engine-overview.puml @@ -45,6 +45,9 @@ note bottom of Runtime Time, phase order, deferred mutation, deterministic random streams, and execution orchestration. + Runtime-owned cadence and seed state + reset at initialize/stop boundaries + so restarts do not inherit hidden state. Initial engine scope prioritizes fixed-step scheduling over advanced event or snapshot extensions. diff --git a/uml/engine-runtime-core.puml b/uml/engine-runtime-core.puml index 300ec94..4e61fce 100644 --- a/uml/engine-runtime-core.puml +++ b/uml/engine-runtime-core.puml @@ -168,6 +168,8 @@ note bottom of SystemScheduler and before/after constraints live here. Runtime reaches world state through EngineWorld, not through raw ECS storage. + Interval cadence state is reset when + the runtime is initialized or stopped. end note note bottom of ResourceStore @@ -191,6 +193,8 @@ end note note right of DeterministicRng Repeatable seed streams are derived per run so scenario batches can be reproduced. + The runtime reseeds it at initialize/stop + boundaries before a new run begins. end note note right of EcsCore diff --git "a/uml/engine-runtime-core.puml \355\225\264\354\204\244.md" "b/uml/engine-runtime-core.puml \355\225\264\354\204\244.md" index a419183..3c201f2 100644 --- "a/uml/engine-runtime-core.puml \355\225\264\354\204\244.md" +++ "b/uml/engine-runtime-core.puml \355\225\264\354\204\244.md" @@ -107,6 +107,7 @@ - 목적: 어떤 시스템이 언제 어떤 순서로 실행되는지 중앙에서 관리한다. - 현재 구현 포인트: phase 호출이 들어오면 descriptor의 `TriggerPolicy`를 직접 판정하고, `Interval`용 cadence 상태는 scheduler가 보관했다가 `initialize()`/`stop()` 경계에서 reset된다. - 유의사항: scheduler는 직접 도메인 규칙을 품는 객체가 아니라 실행 순서를 보장하는 범용 도구여야 한다. +- 구현 메모: `Interval` trigger의 cadence 상태는 runtime lifecycle에 속하므로 `initialize()`와 `stop()` 경계에서 reset되어야 한다. - 후속 개선 사항: descriptor validation, parallel phase, system profiling을 확장할 수 있다. ## `ResourceStore` @@ -125,6 +126,7 @@ - 개요: seed 기반 반복 가능한 난수 스트림 제공자다. - 목적: 같은 시나리오와 설정이면 같은 실행을 재현할 수 있게 한다. - 유의사항: 전역 랜덤 사용을 허용하면 재현성이 쉽게 깨지므로, 스트림 진입점을 통일하는 편이 좋다. +- 구현 메모: runtime은 새 run을 시작하기 전에 `DeterministicRng`를 base seed로 다시 reseed해 이전 실행의 숨은 상태가 남지 않게 해야 한다. - 후속 개선 사항: system별 stream 분리, jump-ahead, 통계 검증 도구를 추가할 수 있다. ## `EcsCore` From 0ca642e34cdd717a526f454e0021f62c89ad90b7 Mon Sep 17 00:00:00 2001 From: learncold Date: Thu, 16 Apr 2026 16:14:57 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81:=20?= =?UTF-8?q?RNG=20=EA=B3=84=EC=95=BD=20=EB=AC=B8=EC=84=9C=EC=99=80=20reset?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CMakeLists.txt | 1 + src/engine/EngineRuntime.h | 6 ++++ src/engine/internal/EngineRuntimeTestAccess.h | 19 +++++++++++ tests/EngineRuntimeTests.cpp | 34 +++++++++++++++++++ uml/engine-runtime-core.puml | 17 ++++------ ...ime-core.puml \355\225\264\354\204\244.md" | 8 ++--- 6 files changed, 68 insertions(+), 17 deletions(-) create mode 100644 src/engine/internal/EngineRuntimeTestAccess.h diff --git a/CMakeLists.txt b/CMakeLists.txt index 0d01845..362eb62 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -62,6 +62,7 @@ add_library(ecs_engine STATIC src/engine/EngineRuntime.cpp src/engine/FrameClock.cpp src/engine/internal/EngineWorldFactory.h + src/engine/internal/EngineRuntimeTestAccess.h src/engine/SystemScheduler.cpp ) diff --git a/src/engine/EngineRuntime.h b/src/engine/EngineRuntime.h index 46a0a77..ffed2ff 100644 --- a/src/engine/EngineRuntime.h +++ b/src/engine/EngineRuntime.h @@ -17,6 +17,10 @@ namespace safecrowd::engine { +namespace internal { +class EngineRuntimeTestAccess; +} + class EngineRuntime { public: explicit EngineRuntime(EngineConfig config = {}); @@ -38,6 +42,8 @@ class EngineRuntime { std::uint64_t runIndex() const noexcept; private: + friend class internal::EngineRuntimeTestAccess; + EngineConfig config_; EngineStats stats_; EcsCore core_; diff --git a/src/engine/internal/EngineRuntimeTestAccess.h b/src/engine/internal/EngineRuntimeTestAccess.h new file mode 100644 index 0000000..b413345 --- /dev/null +++ b/src/engine/internal/EngineRuntimeTestAccess.h @@ -0,0 +1,19 @@ +#pragma once + +#include "engine/EngineRuntime.h" + +namespace safecrowd::engine::internal { + +class EngineRuntimeTestAccess { +public: + [[nodiscard]] static DeterministicRng& rng(EngineRuntime& runtime) noexcept { + return runtime.rng_; + } + + [[nodiscard]] static const DeterministicRng& rng( + const EngineRuntime& runtime) noexcept { + return runtime.rng_; + } +}; + +} // namespace safecrowd::engine::internal diff --git a/tests/EngineRuntimeTests.cpp b/tests/EngineRuntimeTests.cpp index 57b49b6..f9958d4 100644 --- a/tests/EngineRuntimeTests.cpp +++ b/tests/EngineRuntimeTests.cpp @@ -7,6 +7,7 @@ #include #include "engine/EngineRuntime.h" +#include "engine/internal/EngineRuntimeTestAccess.h" namespace { @@ -389,6 +390,39 @@ SC_TEST(EngineRuntime_Initialize_ClearsExistingWorldResources) { SC_EXPECT_TRUE(!runtime.world().resources().contains()); } +SC_TEST(EngineRuntime_Initialize_RebuildsDeterministicRngState) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 0.25, + .maxCatchUpSteps = 4, + .baseSeed = 23, + }); + + runtime.initialize(); + auto& rng = safecrowd::engine::internal::EngineRuntimeTestAccess::rng(runtime); + const auto firstAfterInitialize = rng.next(); + (void)rng.next(); + + runtime.initialize(); + + SC_EXPECT_EQ(rng.next(), firstAfterInitialize); +} + +SC_TEST(EngineRuntime_Stop_RebuildsDeterministicRngState) { + safecrowd::engine::EngineRuntime runtime({ + .fixedDeltaTime = 0.25, + .maxCatchUpSteps = 4, + .baseSeed = 29, + }); + + auto& rng = safecrowd::engine::internal::EngineRuntimeTestAccess::rng(runtime); + const auto expectedFirstValue = rng.next(); + (void)rng.next(); + + runtime.stop(); + + SC_EXPECT_EQ(rng.next(), expectedFirstValue); +} + SC_TEST(EngineRuntime_StopAndRestart_RebuildsDeterministicSeedStream) { std::vector firstSeeds; std::vector firstRuns; diff --git a/uml/engine-runtime-core.puml b/uml/engine-runtime-core.puml index 4e61fce..7c785cf 100644 --- a/uml/engine-runtime-core.puml +++ b/uml/engine-runtime-core.puml @@ -73,12 +73,6 @@ package "engine::api" { +derivedSeed } - class RunContext { - +runIndex - +baseSeed - +derivedSeed - } - enum UpdatePhase { Startup PreSimulation @@ -127,7 +121,9 @@ package "engine::runtime" { class DeterministicRng { +reseed(seed: uint64) - +streamFor(systemKey, runIndex) + +baseSeed(): uint64 + +derive(runIndex, fixedStepIndex): uint64 + +next(): uint64 } } @@ -144,7 +140,6 @@ EngineRuntime *-- DeterministicRng EngineRuntime o-- IRenderBridge EngineRuntime --> EngineConfig EngineRuntime --> EngineStats -EngineRuntime --> RunContext EngineWorld *-- WorldQuery EngineWorld *-- WorldResources @@ -160,7 +155,6 @@ SystemScheduler --> SystemDescriptor SystemScheduler --> EngineWorld : uses facade SystemScheduler --> CommandBuffer : flushes at phase boundaries -DeterministicRng --> RunContext : derives repeatable streams CommandBuffer --> EcsCore : applies deferred mutations note bottom of SystemScheduler @@ -191,8 +185,9 @@ note bottom of WorldQuery end note note right of DeterministicRng - Repeatable seed streams are derived per run - so scenario batches can be reproduced. + Repeatable step seeds are derived from + baseSeed, runIndex, and fixedStepIndex, + then stored in EngineStepContext. The runtime reseeds it at initialize/stop boundaries before a new run begins. end note diff --git "a/uml/engine-runtime-core.puml \355\225\264\354\204\244.md" "b/uml/engine-runtime-core.puml \355\225\264\354\204\244.md" index 3c201f2..286dcaf 100644 --- "a/uml/engine-runtime-core.puml \355\225\264\354\204\244.md" +++ "b/uml/engine-runtime-core.puml \355\225\264\354\204\244.md" @@ -69,15 +69,10 @@ ## `EngineStepContext` - 개요: 현재 프레임/스텝의 문맥 정보를 담는 실행 컨텍스트다. - 목적: 시스템이 현재 프레임 인덱스, fixed step 인덱스, alpha, run 정보를 명시적으로 받을 수 있게 한다. +- 현재 구현 포인트: `derivedSeed`는 `DeterministicRng::derive(runIndex, fixedStepIndex)`로 계산된 현재 step seed를 담는다. - 유의사항: 컨텍스트는 읽기 전용 메타데이터로 유지하는 편이 좋다. - 후속 개선 사항: phase 정보, delta time 파생값, debug flag를 추가할 수 있다. -## `RunContext` -- 개요: 실행 단위의 run index와 seed 파생 정보를 담는 컨텍스트다. -- 목적: 반복 실행과 재현성 관리에 필요한 실행 단위 식별자를 제공한다. -- 유의사항: 프레임 단위 정보와 실행 단위 정보를 섞지 말고 책임을 분리하는 편이 좋다. -- 후속 개선 사항: batch id, scenario id, replay token 같은 상위 실행 메타데이터를 연결할 수 있다. - ## `UpdatePhase` - 개요: 시스템이 어느 단계에서 실행되는지 나타내는 phase 열거형이다. - 목적: startup, simulation, render sync를 구분해 안정적인 실행 순서를 만들기 위해 필요하다. @@ -125,6 +120,7 @@ ## `DeterministicRng` - 개요: seed 기반 반복 가능한 난수 스트림 제공자다. - 목적: 같은 시나리오와 설정이면 같은 실행을 재현할 수 있게 한다. +- 현재 구현 포인트: runtime은 `derive(runIndex, fixedStepIndex)`로 step seed를 만들고, 필요할 때 `next()`로 같은 base seed 기반의 내부 시퀀스를 전진시킬 수 있다. - 유의사항: 전역 랜덤 사용을 허용하면 재현성이 쉽게 깨지므로, 스트림 진입점을 통일하는 편이 좋다. - 구현 메모: runtime은 새 run을 시작하기 전에 `DeterministicRng`를 base seed로 다시 reseed해 이전 실행의 숨은 상태가 남지 않게 해야 한다. - 후속 개선 사항: system별 stream 분리, jump-ahead, 통계 검증 도구를 추가할 수 있다.