From cf92509985427f2e8472923ffc9f91ad459f67cc Mon Sep 17 00:00:00 2001
From: Ceres <1281+ceres@giteauser>
Date: Fri, 18 Sep 2026 19:47:26 +0200
Subject: [PATCH] Add simRate determinism regression test
---
source/ps/tests/test_Game.h | 231 ++++++++++++++++++++++++++++++++++++
1 file changed, 231 insertions(+)
create mode 100644 source/ps/tests/test_Game.h
diff --git a/source/ps/tests/test_Game.h b/source/ps/tests/test_Game.h
new file mode 100644
index 0000000000..de6756fbc5
--- /dev/null
+++ b/source/ps/tests/test_Game.h
@@ -0,0 +1,231 @@
+/* Copyright (C) 2026 Wildfire Games.
+ * This file is part of 0 A.D.
+ *
+ * 0 A.D. is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * 0 A.D. is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with 0 A.D. If not, see .
+ */
+
+// File purpose: Verify that local simulation speed does not affect serialized game state.
+
+#include "lib/self_test.h"
+
+#include "gui/GUIManager.h"
+#include "graphics/MapReader.h"
+#include "lib/file/vfs/vfs.h"
+#include "lib/path.h"
+#include "maths/Fixed.h"
+#include "maths/FixedVector3D.h"
+#include "maths/NUSpline.h"
+#include "ps/ConfigDB.h"
+#include "ps/Filesystem.h"
+#include "ps/Game.h"
+#include "ps/Loader.h"
+#include "ps/Profile.h"
+#include "ps/ProfileViewer.h"
+#include "ps/XML/Xeromyces.h"
+#include "scriptinterface/Interface.h"
+#include "scriptinterface/Object.h"
+#include "scriptinterface/Request.h"
+#include "simulation2/Simulation2.h"
+#include "simulation2/components/ICmpCinemaManager.h"
+#include "simulation2/components/ICmpUnitMotion.h"
+#include "simulation2/helpers/CinemaPath.h"
+#include "simulation2/system/CmpPtr.h"
+#include "simulation2/system/Entity.h"
+#include "simulation2/system/TurnManager.h"
+
+#include
+#include
+#include
+#include
+#include
+#include
+
+namespace
+{
+template
+class ScopedGlobal
+{
+public:
+ ScopedGlobal(T& target, T replacement) :
+ m_Target(target), m_Previous(std::exchange(target, std::move(replacement)))
+ {
+ }
+
+ ~ScopedGlobal()
+ {
+ m_Target = std::move(m_Previous);
+ }
+
+private:
+ T& m_Target;
+ T m_Previous;
+};
+
+CCinemaPath MakeCinemaPath()
+{
+ const CFixedVector3D firstNode(fixed::FromInt(1), fixed::FromInt(0), fixed::FromInt(1));
+ const CFixedVector3D secondNode(fixed::FromInt(9), fixed::FromInt(0), fixed::FromInt(9));
+ const CFixedVector3D targetOffset(fixed::FromInt(3), fixed::FromInt(3), fixed::FromInt(3));
+
+ CCinemaData pathData;
+ pathData.m_Name = L"simrate-determinism";
+ pathData.m_Timescale = fixed::FromInt(1);
+ pathData.m_Orientation = L"target";
+ pathData.m_Mode = L"ease_inout";
+ pathData.m_Style = L"default";
+
+ TNSpline positionSpline;
+ positionSpline.AddNode(firstNode, CFixedVector3D(), fixed::FromInt(0));
+ positionSpline.AddNode(secondNode, CFixedVector3D(), fixed::FromInt(10));
+
+ TNSpline targetSpline;
+ targetSpline.AddNode(firstNode + targetOffset, CFixedVector3D(), fixed::FromInt(0));
+ targetSpline.AddNode(secondNode + targetOffset, CFixedVector3D(), fixed::FromInt(10));
+
+ return CCinemaPath(pathData, positionSpline, targetSpline);
+}
+}
+
+class TestGame : public CxxTest::TestSuite
+{
+ struct SimulationState
+ {
+ turn_id_t turn;
+ std::string hash;
+ std::string serializedState;
+ bool cinemaPlaying;
+ fixed cinemaElapsedTime;
+
+ bool operator==(const SimulationState&) const = default;
+ };
+
+ std::unique_ptr m_ConfigDB;
+ std::optional m_ProfileViewer;
+ std::optional m_ProfileManager;
+ std::optional m_XeromycesEngine;
+ std::optional m_GuiScriptInterface;
+
+public:
+ void setUp()
+ {
+ g_VFS = CreateVfs();
+ TS_ASSERT_OK(g_VFS->Mount(L"", DataDir() / "mods" / "mod" / "", VFS_MOUNT_MUST_EXIST));
+ TS_ASSERT_OK(g_VFS->Mount(L"", DataDir() / "mods" / "public" / "", VFS_MOUNT_MUST_EXIST));
+ TS_ASSERT_OK(g_VFS->Mount(L"cache", DataDir() / "_testcache" / "", 0, VFS_MAX_PRIORITY));
+
+ m_ConfigDB = std::make_unique();
+ m_ProfileViewer.emplace();
+ m_ProfileManager.emplace();
+ m_XeromycesEngine.emplace();
+ m_GuiScriptInterface.emplace("Engine", "TestGame", *g_ScriptContext);
+ g_GUI = new CGUIManager(*g_ScriptContext, *m_GuiScriptInterface);
+ }
+
+ void tearDown()
+ {
+ delete g_GUI;
+ m_GuiScriptInterface.reset();
+ m_XeromycesEngine.reset();
+ m_ProfileManager.reset();
+ m_ProfileViewer.reset();
+ m_ConfigDB.reset();
+ g_VFS.reset();
+ DeleteDirectory(DataDir() / "_testcache");
+ }
+
+ void test_simulation_state_is_independent_of_sim_rate()
+ {
+ const auto stateAtHalfSpeed = RunSimulation(0.5f);
+ const auto stateAtNormalSpeed = RunSimulation(1.0f);
+ const auto stateAtDoubleSpeed = RunSimulation(2.0f);
+
+ TS_ASSERT_EQUALS(stateAtHalfSpeed, stateAtNormalSpeed);
+ TS_ASSERT_EQUALS(stateAtNormalSpeed, stateAtDoubleSpeed);
+ }
+
+private:
+ std::array RunSimulation(float simRate)
+ {
+ CGame game(false);
+ ScopedGlobal scopedGame(g_Game, &game);
+
+ {
+ Script::Request rq(game.GetSimulation2()->GetScriptInterface());
+ CMapSummaryReader mapSummaryReader;
+ TS_ASSERT_EQUALS(mapSummaryReader.LoadMap(
+ L"maps/scenarios/unit_chasing_test.xml"), PSRETURN_OK);
+ JS::RootedValue mapData(rq.cx);
+ mapSummaryReader.GetMapSettings(game.GetSimulation2()->GetScriptInterface(), &mapData);
+ JS::RootedValue settings(rq.cx);
+ TS_ASSERT(Script::GetProperty(rq, mapData, "settings", &settings));
+ TS_ASSERT(Script::SetProperty(rq, settings, "AISeed", 2345));
+ TS_ASSERT(Script::SetProperty(rq, settings, "CheatsEnabled", false));
+ TS_ASSERT(Script::SetProperty(rq, settings, "Seed", 1234));
+ JS::RootedValue attributes(rq.cx);
+ Script::CreateObject(rq, &attributes,
+ "mapType", "scenario",
+ "map", "maps/scenarios/unit_chasing_test",
+ "mapPath", "maps/scenarios/",
+ "settings", settings);
+
+ game.StartGame(&attributes, "");
+ TS_ASSERT_OK(PS::Loader::NonprogressiveLoad());
+ TS_ASSERT_EQUALS(game.ReallyStartGame(), PSRETURN_OK);
+ }
+
+ CmpPtr cinema(*game.GetSimulation2(), SYSTEM_ENTITY);
+ TS_ASSERT(cinema);
+ cinema->AddPath(MakeCinemaPath());
+ cinema->PushPathToQueue(L"simrate-determinism");
+ cinema->StartPlayingQueue();
+
+ const size_t initialUnitMotionEntityCount =
+ game.GetSimulation2()->GetEntitiesWithInterface(IID_UnitMotion).size();
+
+ game.SetSimRate(simRate);
+ constexpr std::array checkpoints{20, 40, 60};
+ std::array states{};
+
+ for (size_t i = 0; i < checkpoints.size(); ++i)
+ {
+ const turn_id_t checkpoint = checkpoints[i];
+ while (game.GetTurnManager()->GetCurrentTurn() < checkpoint)
+ {
+ game.Update(DEFAULT_TURN_LENGTH / 1000.0, false);
+ TS_ASSERT_LESS_THAN_EQUALS(game.GetTurnManager()->GetCurrentTurn(), checkpoint);
+ }
+
+ TS_ASSERT_EQUALS(game.GetTurnManager()->GetCurrentTurn(), checkpoint);
+
+ if (i == 0)
+ TS_ASSERT_LESS_THAN(initialUnitMotionEntityCount,
+ game.GetSimulation2()->GetEntitiesWithInterface(IID_UnitMotion).size());
+
+ SimulationState& state = states[i];
+ state.turn = game.GetTurnManager()->GetCurrentTurn();
+ TS_ASSERT(game.GetSimulation2()->ComputeStateHash(state.hash, false));
+ std::stringstream serializedState;
+ TS_ASSERT(game.GetSimulation2()->SerializeState(serializedState));
+ state.serializedState = serializedState.str();
+ state.cinemaPlaying = cinema->IsPlayingQueue();
+ state.cinemaElapsedTime = cinema->GetActivePathElapsedTime();
+ }
+
+ TS_ASSERT(std::get<0>(states).cinemaPlaying);
+ TS_ASSERT(std::get<1>(states).cinemaPlaying);
+ TS_ASSERT(!std::get<2>(states).cinemaPlaying);
+
+ return states;
+ }
+};