diff --git a/extras/CMakeLists.txt b/extras/CMakeLists.txt index edbac0174a..5f7d7b5133 100644 --- a/extras/CMakeLists.txt +++ b/extras/CMakeLists.txt @@ -1,4 +1,4 @@ -# Copyright (C) 2005 - 2021 Settlers Freaks +# Copyright (C) 2005 - 2026 Settlers Freaks # # SPDX-License-Identifier: GPL-2.0-or-later @@ -11,7 +11,9 @@ set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) add_subdirectory(audioDrivers) add_subdirectory(videoDrivers) +add_subdirectory(headlessConsole) add_subdirectory(ai-battle) +add_subdirectory(replay-player) if(RTTR_BUNDLE AND APPLE) add_subdirectory(macosLauncher) endif() diff --git a/extras/ai-battle/CMakeLists.txt b/extras/ai-battle/CMakeLists.txt index b10b41b441..38742f4791 100644 --- a/extras/ai-battle/CMakeLists.txt +++ b/extras/ai-battle/CMakeLists.txt @@ -1,9 +1,9 @@ -# Copyright (C) 2005 - 2024 Settlers Freaks +# Copyright (C) 2005 - 2026 Settlers Freaks # # SPDX-License-Identifier: GPL-2.0-or-later add_executable(ai-battle main.cpp HeadlessGame.cpp) -target_link_libraries(ai-battle PRIVATE s25Main Boost::program_options Boost::nowide) +target_link_libraries(ai-battle PRIVATE s25Main headlessConsole Boost::program_options Boost::nowide) if(WIN32) include(GatherDll) diff --git a/extras/ai-battle/HeadlessGame.cpp b/extras/ai-battle/HeadlessGame.cpp index 4724d09e84..070593b22a 100644 --- a/extras/ai-battle/HeadlessGame.cpp +++ b/extras/ai-battle/HeadlessGame.cpp @@ -5,77 +5,47 @@ #include "HeadlessGame.h" #include "EventManager.h" #include "GamePlayer.h" +#include "GameSetup.h" #include "GlobalGameSettings.h" +#include "HeadlessConsole.h" #include "PlayerInfo.h" #include "Savegame.h" #include "factories/AIFactory.h" #include "network/PlayerGameCommands.h" #include "world/GameWorld.h" -#include "world/MapLoader.h" #include "gameTypes/MapInfo.h" #include "gameData/GameConsts.h" #include "s25util/colors.h" #include #include -#include -#include -#ifdef WIN32 -# include "Windows.h" -#endif std::vector GeneratePlayerInfo(const std::vector& ais, const std::vector& teams); -std::string ToString(const std::chrono::milliseconds& time); -std::string HumanReadableNumber(unsigned num); namespace bfs = boost::filesystem; namespace bnw = boost::nowide; using bfs::canonical; -#ifdef WIN32 -HANDLE setupStdOut(); -#endif - -#if defined(__MINGW32__) && !defined(__clang__) -void printConsole(const char* fmt, ...) __attribute__((format(gnu_printf, 1, 2))); -#elif defined __GNUC__ -void printConsole(const char* fmt, ...) __attribute__((format(printf, 1, 2))); -#else -void printConsole(const char* fmt, ...); -#endif - HeadlessGame::HeadlessGame(const GlobalGameSettings& ggs, const bfs::path& map, const std::vector& ais, const bfs::path& luaPath, const std::vector& teams) : map_(map), game_(ggs, std::make_unique(0), GeneratePlayerInfo(ais, teams)), world_(game_.world_), em_(*static_cast(game_.em_.get())) { - MapLoader loader(world_); - if(!loader.Load(map)) - throw std::runtime_error("Could not load " + map.string()); - MapLoader::SetupResources(world_); - - // Establish the team alliances (ally + non-aggression pacts) exactly like GameClient::StartGame does - // for a fresh map. Without this, teammates have no pacts and are mutually attackable, so the AIs attack - // their own team; on replay GameClient *does* set up the pacts, so those recorded attack commands are - // handled differently and the replay desyncs (object-count divergence). MakeStartPacts is a no-op for - // teamless players, so this is safe regardless of whether --teams was given. - for(unsigned i = 0; i < world_.GetNumPlayers(); ++i) - world_.GetPlayer(i).MakeStartPacts(); + MapInfo mapInfo; + mapInfo.type = MapType::OldMap; + mapInfo.filepath = map; + mapInfo.luaFilepath = luaPath; + SetupGameWorld(game_, mapInfo, localState_, nullptr); - if(!luaPath.empty()) + if(world_.HasLua()) { - if(!loader.LoadLuaScript(game_, localState_, luaPath)) - throw std::runtime_error("Failed to load Lua script: " + luaPath.string()); world_.GetLua().setSuppressStdout(true); luaPath_ = luaPath; bnw::cout << "Lua script loaded: " << luaPath << '\n'; } - players_.clear(); for(unsigned playerId = 0; playerId < world_.GetNumPlayers(); ++playerId) players_.push_back(AIFactory::Create(world_.GetPlayer(playerId).aiInfo, playerId, world_)); - - world_.InitAfterLoad(); } HeadlessGame::~HeadlessGame() @@ -190,56 +160,16 @@ void HeadlessGame::SaveGame(const bfs::path& path) const bnw::cout << "Savegame written to " << canonical(path) << '\n'; } -std::string ToString(const std::chrono::milliseconds& time) -{ - char buffer[90]; - const auto hours = std::chrono::duration_cast(time); - const auto minutes = std::chrono::duration_cast(time % std::chrono::hours(1)); - const auto seconds = std::chrono::duration_cast(time % std::chrono::minutes(1)); - snprintf(buffer, std::size(buffer), "%03ld:%02ld:%02ld", static_cast(hours.count()), - static_cast(minutes.count()), static_cast(seconds.count())); - return std::string(buffer); -} - -std::string HumanReadableNumber(unsigned num) -{ - std::stringstream ss; - ss.imbue(std::locale("")); - ss << std::fixed << num; - return ss.str(); -} - void HeadlessGame::PrintState() { - static bool first_run = true; - if(first_run) - first_run = false; - else - printConsole("\x1b[%dA", 8 + world_.GetNumPlayers()); // Move cursor back up - - printConsole("┌───────────────┬───────────────────────┬───────────────────────┬────────────────┐\n"); - printConsole( - "│ GF %10s │ Game Clock %s │ Wall Clock %s │ %7s GF/sec │\n", HumanReadableNumber(em_.GetCurrentGF()).c_str(), - ToString(SPEED_GF_LENGTHS[GameSpeed::Normal] * em_.GetCurrentGF()).c_str(), // elapsed time - ToString(std::chrono::duration_cast(std::chrono::steady_clock::now() - gameStartTime_)) - .c_str(), // wall clock - HumanReadableNumber(em_.GetCurrentGF() - lastReportGf_).c_str()); // GF per second - printConsole("└───────────────┴───────────────────────┴───────────────────────┴────────────────┘\n"); - printConsole("\n"); - printConsole("┌────────────────────────┬─────────────────┬─────────────┬───────────┬───────────┐\n"); - printConsole("│ Player │ Country │ Buildings │ Military │ Gold │\n"); - printConsole("├────────────────────────┼─────────────────┼─────────────┼───────────┼───────────┤\n"); - for(const auto& player : world_.getPlayers()) - { - printConsole("│ %s%-22s%s │ %15s │ %11s │ %9s │ %9s │\n", player.IsDefeated() ? "\x1b[9m" : "", - player.name.c_str(), player.IsDefeated() ? "\x1b[29m" : "", - HumanReadableNumber(player.GetStatisticCurrentValue(StatisticType::Country)).c_str(), - HumanReadableNumber(player.GetStatisticCurrentValue(StatisticType::Buildings)).c_str(), - HumanReadableNumber(player.GetStatisticCurrentValue(StatisticType::Military)).c_str(), - HumanReadableNumber(player.GetStatisticCurrentValue(StatisticType::Gold)).c_str()); - } - printConsole("└────────────────────────┴─────────────────┴─────────────┴───────────┴───────────┘\n"); - + HeadlessStats stats; + stats.currentGF = em_.GetCurrentGF(); + stats.gameTime = + std::chrono::duration_cast(SPEED_GF_LENGTHS[GameSpeed::Normal] * em_.GetCurrentGF()); + stats.wallTime = + std::chrono::duration_cast(std::chrono::steady_clock::now() - gameStartTime_); + stats.gfPerSecond = em_.GetCurrentGF() - lastReportGf_; + statsPrinter_.print(stats, world_); lastReportGf_ = em_.GetCurrentGF(); } @@ -249,7 +179,7 @@ std::vector GeneratePlayerInfo(const std::vector& ais, con for(const AI::Info& ai : ais) { PlayerInfo pi; - pi.ps = PlayerState::Occupied; + pi.ps = PlayerState::AI; pi.aiInfo = ai; switch(ai.type) { @@ -264,31 +194,3 @@ std::vector GeneratePlayerInfo(const std::vector& ais, con } return ret; } - -#ifdef WIN32 -HANDLE setupStdOut() -{ - HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); - SetConsoleMode(h, ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING); - SetConsoleOutputCP(65001); - return h; -} -#endif - -void printConsole(const char* fmt, ...) -{ - char buffer[512]; - va_list args; - va_start(args, fmt); - const int len = vsnprintf(buffer, sizeof(buffer), fmt, args); - va_end(args); - if(len > 0 && (size_t)len < sizeof(buffer)) - { -#ifdef WIN32 - static auto h = setupStdOut(); - WriteConsoleA(h, buffer, len, 0, 0); -#else - bnw::cout << buffer; -#endif - } -} diff --git a/extras/ai-battle/HeadlessGame.h b/extras/ai-battle/HeadlessGame.h index 69e9931f03..3714970bf5 100644 --- a/extras/ai-battle/HeadlessGame.h +++ b/extras/ai-battle/HeadlessGame.h @@ -5,6 +5,7 @@ #pragma once #include "Game.h" +#include "HeadlessConsole.h" #include "ILocalGameState.h" #include "Replay.h" #include "ai/AIPlayer.h" @@ -38,15 +39,7 @@ class HeadlessGame private: void PrintState(); - struct LocalState : ILocalGameState - { - unsigned GetPlayerId() const override { return 0; } - bool IsHost() const override { return true; } - std::string FormatGFTime(unsigned) const override { return ""; } - void SystemChat(const std::string&) override {} - }; - - LocalState localState_; + NullLocalGameState localState_; boost::filesystem::path map_; Game game_; GameWorld& world_; @@ -59,4 +52,5 @@ class HeadlessGame unsigned lastReportGf_ = 0; std::chrono::steady_clock::time_point gameStartTime_; + StatsTablePrinter statsPrinter_; }; diff --git a/extras/headlessConsole/CMakeLists.txt b/extras/headlessConsole/CMakeLists.txt new file mode 100644 index 0000000000..e71a82f9c4 --- /dev/null +++ b/extras/headlessConsole/CMakeLists.txt @@ -0,0 +1,8 @@ +# Copyright (C) 2005 - 2026 Settlers Freaks +# +# SPDX-License-Identifier: GPL-2.0-or-later + +# Console output shared by the headless tools (ai-battle, replay-player) +add_library(headlessConsole STATIC HeadlessConsole.cpp HeadlessConsole.h) +target_link_libraries(headlessConsole PUBLIC s25Main PRIVATE Boost::nowide) +target_include_directories(headlessConsole PUBLIC .) diff --git a/extras/headlessConsole/HeadlessConsole.cpp b/extras/headlessConsole/HeadlessConsole.cpp new file mode 100644 index 0000000000..4384d1b7d3 --- /dev/null +++ b/extras/headlessConsole/HeadlessConsole.cpp @@ -0,0 +1,98 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "HeadlessConsole.h" +#include "GamePlayer.h" +#include "world/GameWorldBase.h" +#include +#include +#include +#include +#include + +#ifdef _WIN32 +# include +#endif + +namespace bnw = boost::nowide; + +#ifdef _WIN32 +static HANDLE setupStdOut() +{ + HANDLE h = GetStdHandle(STD_OUTPUT_HANDLE); + SetConsoleMode(h, ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING); + SetConsoleOutputCP(65001); + return h; +} +#endif + +void printConsole(const char* fmt, ...) +{ + char buffer[512]; + va_list args; + va_start(args, fmt); + const int len = vsnprintf(buffer, sizeof(buffer), fmt, args); + va_end(args); + if(len > 0 && static_cast(len) < sizeof(buffer)) + { +#ifdef _WIN32 + static HANDLE h = setupStdOut(); + WriteConsoleA(h, buffer, len, nullptr, nullptr); +#else + bnw::cout << buffer; +#endif + } +} + +std::string formatClock(std::chrono::milliseconds time) +{ + char buf[32]; + const auto h = std::chrono::duration_cast(time); + const auto m = std::chrono::duration_cast(time % std::chrono::hours(1)); + const auto s = std::chrono::duration_cast(time % std::chrono::minutes(1)); + snprintf(buf, sizeof(buf), "%03ld:%02ld:%02ld", static_cast(h.count()), static_cast(m.count()), + static_cast(s.count())); + return buf; +} + +std::string formatNumber(unsigned num) +{ + std::ostringstream ss; + ss.imbue(std::locale("")); + ss << std::fixed << num; + return ss.str(); +} + +void StatsTablePrinter::print(const HeadlessStats& stats, const GameWorldBase& world) +{ + const unsigned numPlayers = world.GetNumPlayers(); + if(!firstPrint_) + printConsole("\x1b[%uA", 8 + numPlayers); // Move back up over the previously printed table + firstPrint_ = false; + + const std::string gf = stats.totalGFs ? formatNumber(stats.currentGF) + " / " + formatNumber(*stats.totalGFs) : + formatNumber(stats.currentGF); + + printConsole("┌──────────────────────────┬───────────────────────┬───────────────────────┬────────────────┐\n"); + printConsole("│ GF %21s │ Game Clock %s │ Wall Clock %s │ %7s GF/sec │\n", gf.c_str(), + formatClock(stats.gameTime).c_str(), formatClock(stats.wallTime).c_str(), + formatNumber(stats.gfPerSecond).c_str()); + printConsole("└──────────────────────────┴───────────────────────┴───────────────────────┴────────────────┘\n"); + printConsole("\n"); + + printConsole("┌────────────────────────────┬────────────────────┬───────────────┬────────────┬────────────┐\n"); + printConsole("│ %-26s │ %-18s │ %-13s │ %-10s │ %-10s │\n", "Player", "Country", "Buildings", "Military", "Gold"); + printConsole("├────────────────────────────┼────────────────────┼───────────────┼────────────┼────────────┤\n"); + for(unsigned i = 0; i < numPlayers; ++i) + { + const GamePlayer& p = world.GetPlayer(i); + printConsole("│ %s%-26s%s │ %18s │ %13s │ %10s │ %10s │\n", p.IsDefeated() ? "\x1b[9m" : "", p.name.c_str(), + p.IsDefeated() ? "\x1b[29m" : "", + formatNumber(p.GetStatisticCurrentValue(StatisticType::Country)).c_str(), + formatNumber(p.GetStatisticCurrentValue(StatisticType::Buildings)).c_str(), + formatNumber(p.GetStatisticCurrentValue(StatisticType::Military)).c_str(), + formatNumber(p.GetStatisticCurrentValue(StatisticType::Gold)).c_str()); + } + printConsole("└────────────────────────────┴────────────────────┴───────────────┴────────────┴────────────┘\n"); +} diff --git a/extras/headlessConsole/HeadlessConsole.h b/extras/headlessConsole/HeadlessConsole.h new file mode 100644 index 0000000000..f5e1faddc4 --- /dev/null +++ b/extras/headlessConsole/HeadlessConsole.h @@ -0,0 +1,46 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include +#include +#include + +class GameWorldBase; + +/// Write to the console, enabling VT100 escapes and UTF-8 output on Windows. +/// On Windows nothing reaches a redirected stdout, e.g. a pipe or log file. +#if defined(__MINGW32__) && !defined(__clang__) +void printConsole(const char* fmt, ...) __attribute__((format(gnu_printf, 1, 2))); +#elif defined __GNUC__ +void printConsole(const char* fmt, ...) __attribute__((format(printf, 1, 2))); +#else +void printConsole(const char* fmt, ...); +#endif + +/// hhh:mm:ss +std::string formatClock(std::chrono::milliseconds time); +/// Number with the digit grouping of the current locale +std::string formatNumber(unsigned num); + +struct HeadlessStats +{ + unsigned currentGF = 0; + /// Shown next to the current GF when the end is known in advance, as it is for a replay + std::optional totalGFs; + std::chrono::milliseconds gameTime{}; + std::chrono::milliseconds wallTime{}; + unsigned gfPerSecond = 0; +}; + +/// Prints a status table over the one it printed before, so it updates in place. +class StatsTablePrinter +{ +public: + void print(const HeadlessStats& stats, const GameWorldBase& world); + +private: + bool firstPrint_ = true; +}; diff --git a/extras/replay-player/CMakeLists.txt b/extras/replay-player/CMakeLists.txt new file mode 100644 index 0000000000..29e0e0d0c0 --- /dev/null +++ b/extras/replay-player/CMakeLists.txt @@ -0,0 +1,11 @@ +# Copyright (C) 2005 - 2026 Settlers Freaks +# +# SPDX-License-Identifier: GPL-2.0-or-later + +add_executable(replay-player main.cpp ReplayOutput.cpp ReplayOutput.h) +target_link_libraries(replay-player PRIVATE s25Main headlessConsole Boost::program_options Boost::nowide) + +if(WIN32) + include(GatherDll) + gather_dll_copy(replay-player) +endif() diff --git a/extras/replay-player/ReplayOutput.cpp b/extras/replay-player/ReplayOutput.cpp new file mode 100644 index 0000000000..d96db4dd63 --- /dev/null +++ b/extras/replay-player/ReplayOutput.cpp @@ -0,0 +1,80 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "ReplayOutput.h" +#include "GamePlayer.h" +#include "HeadlessReplay.h" +#include "random/Random.h" +#include "random/randomIO.h" +#include "world/GameWorld.h" +#include "gameTypes/TeamTypes.h" +#include "gameData/NationConsts.h" +#include +#include + +namespace bnw = boost::nowide; + +namespace { +std::string teamStr(Team t) +{ + switch(t) + { + case Team::Team1: return "1"; + case Team::Team2: return "2"; + case Team::Team3: return "3"; + case Team::Team4: return "4"; + case Team::None: return "-"; + default: return "R"; + } +} +} // namespace + +void printInitialInfo(const HeadlessReplay& replay) +{ + const Replay& file = replay.getReplay(); + const GameWorld& world = replay.getWorld(); + const MapPoint mapSize = world.GetSize(); + + bnw::cout << "\n"; + bnw::cout << "Replay: " << file.GetMapName() << "\n"; + bnw::cout << "Version: " << static_cast(file.GetMajorVersion()) << "." + << static_cast(file.GetMinorVersion()) << "\n"; + bnw::cout << "Type: " << (replay.isFromSavegame() ? "savegame replay" : "new-game replay") << "\n"; + bnw::cout << "Map size: " << mapSize.x << " x " << mapSize.y << "\n"; + bnw::cout << "Seed: " << file.getSeed() << "\n"; + bnw::cout << "GF range: " << replay.getStartGF() << " - " << file.GetLastGF() << " (" + << (file.GetLastGF() + 1 - replay.getStartGF()) << " GFs)\n"; + if(world.HasLua()) + bnw::cout << "Lua: script loaded from replay\n"; + if(!replay.isFromSavegame() && !file.usesFishFix()) + bnw::cout << "Note: fish fix skipped, this replay predates version 8.3\n"; + bnw::cout << "\n"; + + bnw::cout << "Players:\n"; + bnw::cout << " # Name Nation Team\n"; + bnw::cout << " ─────────────────────────────────────────────\n"; + for(unsigned i = 0; i < world.GetNumPlayers(); ++i) + { + const GamePlayer& p = world.GetPlayer(i); + char buf[80]; + snprintf(buf, sizeof(buf), " %-2u %-24s %-12s %s\n", i, p.name.c_str(), NationNames[p.nation], + teamStr(p.team).c_str()); + bnw::cout << buf; + } + bnw::cout << "\n"; + bnw::cout.flush(); +} + +void printDesync(const ReplayDesync& desync, bool verbose) +{ + bnw::cerr << "\nDesync at GF " << desync.gf << ":\n" + << " actual: " << desync.actual << "\n" + << " stored: " << desync.expected << "\n"; + if(verbose) + { + for(const auto& entry : RANDOM.GetAsyncLog()) + bnw::cerr << " " << entry << "\n"; + } + bnw::cerr << "FAIL: Desync detected.\n"; +} diff --git a/extras/replay-player/ReplayOutput.h b/extras/replay-player/ReplayOutput.h new file mode 100644 index 0000000000..55aac3baa6 --- /dev/null +++ b/extras/replay-player/ReplayOutput.h @@ -0,0 +1,14 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +class HeadlessReplay; +struct ReplayDesync; + +/// Replay metadata and the player roster, printed before playback starts +void printInitialInfo(const HeadlessReplay& replay); + +/// With verbose, also dumps the async log of the random number generator. +void printDesync(const ReplayDesync& desync, bool verbose); diff --git a/extras/replay-player/main.cpp b/extras/replay-player/main.cpp new file mode 100644 index 0000000000..ddff0c837b --- /dev/null +++ b/extras/replay-player/main.cpp @@ -0,0 +1,148 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "HeadlessConsole.h" +#include "HeadlessReplay.h" +#include "RTTR_Version.h" +#include "Replay.h" +#include "ReplayOutput.h" +#include "RttrConfig.h" +#include "files.h" +#include "ogl/glAllocator.h" +#include "world/GameWorld.h" +#include "gameData/GameConsts.h" +#include "libsiedler2/libsiedler2.h" +#include "s25util/Log.h" +#include "s25util/System.h" + +#include +#include +#include +#include +#include +#include + +namespace bfs = boost::filesystem; +namespace bnw = boost::nowide; +namespace po = boost::program_options; + +int main(int argc, char** argv) +{ + bnw::nowide_filesystem(); + bnw::args _(argc, argv); + + po::options_description desc("Allowed options"); + // clang-format off + desc.add_options() + ("help,h", "Show help") + ("replay,r", po::value()->required(), "Replay file (.rpl) to play back\n" + "Supports placeholder (user data dir)") + ("verbose,V", "Print the async-log entries when a desync is detected") + ("version,v", "Show version information and exit") + ; + // clang-format on + po::positional_options_description pos; + pos.add("replay", 1); + + if(argc == 1) + { + bnw::cerr << desc << std::endl; + return 1; + } + + po::variables_map options; + try + { + po::store(po::command_line_parser(argc, argv).options(desc).positional(pos).run(), options); + if(options.count("help")) + { + bnw::cout << desc << std::endl; + return 0; + } + if(options.count("version")) + { + bnw::cout << rttr::version::GetTitle() << " v" << rttr::version::GetVersion() << "-" + << rttr::version::GetRevision() << std::endl + << "Compiled with " << System::getCompilerName() << " for " << System::getOSName() << std::endl; + return 0; + } + po::notify(options); + } catch(const std::exception& e) + { + bnw::cerr << "Error: " << e.what() << std::endl; + bnw::cerr << desc << std::endl; + return 1; + } + + for(int i = 0; i < argc; ++i) + bnw::cout << argv[i] << " "; + bnw::cout << "\n\n"; + + try + { + RTTRCONFIG.Init(); + // Lua output goes to the log file, and writing it throws if the log directory does not exist + const bfs::path logDir = RTTRCONFIG.ExpandPath(s25::folders::logs); + bfs::create_directories(logDir); + LOG.setLogFilepath(logDir); + libsiedler2::setAllocator(new GlAllocator); + + const bfs::path replayPath = RTTRCONFIG.ExpandPath(options["replay"].as()); + const bool verbose = options.count("verbose") > 0; + + bnw::cout << "Loading: " << replayPath << "\n"; + + HeadlessReplay replay(replayPath); + printInitialInfo(replay); + + const auto startTime = std::chrono::steady_clock::now(); + auto nextReport = startTime + std::chrono::seconds(1); + unsigned lastReportGF = replay.getStartGF(); + StatsTablePrinter statsPrinter; + + const auto printStats = [&] { + HeadlessStats stats; + stats.currentGF = replay.getCurrentGF(); + stats.totalGFs = replay.getReplay().GetLastGF() + 1; + stats.gameTime = std::chrono::duration_cast(SPEED_GF_LENGTHS[GameSpeed::Normal] + * stats.currentGF); + stats.wallTime = + std::chrono::duration_cast(std::chrono::steady_clock::now() - startTime); + stats.gfPerSecond = stats.currentGF - lastReportGF; + statsPrinter.print(stats, replay.getWorld()); + lastReportGF = stats.currentGF; + }; + + replay.Run([&](const HeadlessReplay&) { + const auto now = std::chrono::steady_clock::now(); + if(now >= nextReport) + { + nextReport += std::chrono::seconds(1); + printStats(); + } + }); + printStats(); + printConsole("\n"); + + const auto elapsed = + std::chrono::duration_cast>(std::chrono::steady_clock::now() - startTime) + .count(); + const unsigned playedGFs = replay.getCurrentGF() - replay.getStartGF(); + bnw::cout << "Finished " << playedGFs << " GFs in " << elapsed << "s (" + << static_cast(playedGFs / elapsed) << " GF/s)\n"; + + if(const auto desync = replay.getDesync()) + { + printDesync(*desync, verbose); + return 1; + } + bnw::cout << "OK: No desync detected.\n"; + return 0; + + } catch(const std::exception& e) + { + bnw::cerr << "Fatal error: " << e.what() << "\n"; + return 1; + } +} diff --git a/libs/s25main/GameSetup.cpp b/libs/s25main/GameSetup.cpp new file mode 100644 index 0000000000..7094fc7ab0 --- /dev/null +++ b/libs/s25main/GameSetup.cpp @@ -0,0 +1,98 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "GameSetup.h" +#include "Game.h" +#include "GamePlayer.h" +#include "ILocalGameState.h" +#include "LeatherLoader.h" +#include "RTTR_Assert.h" +#include "Replay.h" +#include "Savegame.h" +#include "world/GameWorld.h" +#include "world/MapLoader.h" +#include "gameTypes/MapInfo.h" +#include "gameTypes/SettingsTypes.h" +#include "gameTypes/VisualSettings.h" +#include "s25util/tmpFile.h" +#include +#include +#include + +namespace { +/* + We have to do this if we have a old replay starting from scratch not containing a savegame. If a savegame is + contained in the replay the compatibility code in GamePlayer deserialization function takes care of handling this. + If we have a replay starting from scratch in the constructor of the gameplayer the standard distributions are + loaded. These contain also the new leather addon buildings. When the distribution is recomputed these buildings + are added to the possible goals for wares. This leads to the problem that we have more buildings then before in + the list. So it happens for example for wood that the ware is deliverd to a different goal and then the replay + gets out of sync. +*/ +void removeLeatherAddonDistributions(Game& game, unsigned localPlayerId) +{ + VisualSettings settings; + game.world_.GetPlayer(localPlayerId).FillVisualSettings(settings); + + Distributions newDistributions = settings.distribution; + unsigned idx = 0; + for(const DistributionMapping& mapping : distributionMap) + { + if(leatheraddon::isLeatherAddonBuildingType(std::get<1>(mapping))) + newDistributions[idx] = 0; + idx++; + } + + for(auto& player : game.world_.getPlayers()) + player.ChangeDistribution(newDistributions); +} +} // namespace + +void SetupGameWorld(Game& game, const MapInfo& mapInfo, ILocalGameState& localGameState, const Replay* replay) +{ + GameWorld& gameWorld = game.world_; + if(mapInfo.savegame) + mapInfo.savegame->sgd.ReadSnapshot(game, localGameState); + else + { + RTTR_Assert(mapInfo.type != MapType::Savegame); + for(auto& player : gameWorld.getPlayers()) + player.MakeStartPacts(); + + // Kept until the loader has read them + std::optional tmpMap, tmpLua; + boost::filesystem::path mapFilePath = mapInfo.filepath; + if(mapFilePath.empty()) + { + tmpMap.emplace(); + tmpMap->close(); + if(!mapInfo.mapData.DecompressToFile(tmpMap->filePath)) + throw GameSetupError("Could not decompress the map data"); + mapFilePath = tmpMap->filePath; + } + boost::filesystem::path luaFilePath = mapInfo.luaFilepath; + if(luaFilePath.empty() && mapInfo.luaData.uncompressedLength > 0) + { + tmpLua.emplace(".lua"); + tmpLua->close(); + if(!mapInfo.luaData.DecompressToFile(tmpLua->filePath)) + throw GameSetupError("Could not decompress the Lua script"); + luaFilePath = tmpLua->filePath; + } + + MapLoader loader(gameWorld); + if(!loader.Load(mapFilePath)) + throw GameSetupError("Could not load the map " + mapFilePath.string()); + if(!luaFilePath.empty() && !loader.LoadLuaScript(game, localGameState, luaFilePath)) + throw GameSetupError("Could not load the Lua script " + luaFilePath.string()); + + MapLoader::SetupResources(gameWorld, !replay || replay->usesFishFix()); + } + if(replay) + gameWorld.SetReplayCompatVersion(replay->GetMinorVersion()); + gameWorld.InitAfterLoad(); + + if(replay && !mapInfo.savegame && replay->GetMinorVersion() < 2) + removeLeatherAddonDistributions(game, localGameState.GetPlayerId()); +} diff --git a/libs/s25main/GameSetup.h b/libs/s25main/GameSetup.h new file mode 100644 index 0000000000..72b53ce765 --- /dev/null +++ b/libs/s25main/GameSetup.h @@ -0,0 +1,28 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include + +class Game; +class ILocalGameState; +class MapInfo; +class Replay; + +/// The map or Lua script of a game could not be read +struct GameSetupError : std::runtime_error +{ + using std::runtime_error::runtime_error; +}; + +/// Fill the world of a freshly created game from the savegame or map in mapInfo. +/// The map and Lua script are taken from mapInfo.filepath/luaFilepath when set, otherwise they are +/// decompressed from the data embedded in mapInfo. A path that came out of a replay names a file on +/// the machine that recorded it, so clear it to use the embedded data instead. +/// Pass the replay a game is loaded from, if any: older replays need the world set up the way the +/// version they were recorded with did it. +/// Throws GameSetupError if the map or Lua script could not be read, SerializedGameData::Error if the savegame is +/// corrupt. +void SetupGameWorld(Game& game, const MapInfo& mapInfo, ILocalGameState& localGameState, const Replay* replay); diff --git a/libs/s25main/HeadlessReplay.cpp b/libs/s25main/HeadlessReplay.cpp new file mode 100644 index 0000000000..b1b7a3b769 --- /dev/null +++ b/libs/s25main/HeadlessReplay.cpp @@ -0,0 +1,111 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "HeadlessReplay.h" +#include "EventManager.h" +#include "Game.h" +#include "GameSetup.h" +#include "PlayerInfo.h" +#include "Savegame.h" +#include "ai/random.h" +#include "network/PlayerGameCommands.h" +#include "random/Random.h" +#include "variant.h" +#include "world/GameWorld.h" +#include +#include + +HeadlessReplay::HeadlessReplay(const boost::filesystem::path& replayPath) +{ + if(!replay_.LoadHeader(replayPath) || !replay_.LoadGameData(mapInfo_)) + { + const std::string reason = + replay_.GetLastErrorMsg().empty() ? std::string("Unknown error") : replay_.GetLastErrorMsg(); + throw std::runtime_error("Could not load replay " + replayPath.string() + ": " + reason); + } + + // Recorded on another machine, so use the map data embedded in the replay instead + mapInfo_.filepath.clear(); + + std::vector players; + players.reserve(replay_.GetNumPlayers()); + for(unsigned i = 0; i < replay_.GetNumPlayers(); ++i) + players.emplace_back(replay_.GetPlayer(i)); + + startGF_ = mapInfo_.savegame ? mapInfo_.savegame->start_gf : 0; + RANDOM.Init(replay_.getSeed()); + AI::getRandomGenerator().seed(replay_.getSeed()); + + game_ = std::make_unique(replay_.ggs, startGF_, players); + SetupGameWorld(*game_, mapInfo_, localGameState_, &replay_); + + // A script writing to stdout would corrupt whatever the caller prints + if(game_->world_.HasLua()) + game_->world_.GetLua().setSuppressStdout(true); + + game_->Start(isFromSavegame()); + nextGF_ = replay_.ReadGF(); +} + +HeadlessReplay::~HeadlessReplay() = default; + +const GameWorld& HeadlessReplay::getWorld() const +{ + return game_->world_; +} + +unsigned HeadlessReplay::getCurrentGF() const +{ + return game_->em_->GetCurrentGF(); +} + +bool HeadlessReplay::RunGF() +{ + const unsigned curGF = getCurrentGF(); + if(desync_ || curGF > replay_.GetLastGF()) + return false; + if(nextGF_ && *nextGF_ < curGF) + { + throw std::runtime_error("Corrupt replay: next command is for GF " + std::to_string(*nextGF_) + + " but the game is already at GF " + std::to_string(curGF)); + } + + AsyncChecksum checksum; + if(nextGF_ && *nextGF_ == curGF) + checksum = AsyncChecksum::create(*game_); + + while(nextGF_ && *nextGF_ == curGF) + { + const auto cmd = replay_.ReadCommand(); + visit(composeVisitor([](const Replay::ChatCommand&) {}, + [&](const Replay::GameCommand& gcmd) { + for(const gc::GameCommandPtr& gc : gcmd.cmds.gcs) + gc->Execute(game_->world_, gcmd.player); + + const AsyncChecksum& expected = gcmd.cmds.checksum; + // A zero checksum means the recording did not store one + if(!desync_ && expected.randChecksum != 0 && expected != checksum) + desync_ = ReplayDesync{curGF, expected, checksum}; + }), + cmd); + nextGF_ = replay_.ReadGF(); + } + + // Stop at a desync so the world and the RNG log still show the state that produced it + if(desync_) + return false; + game_->RunGF(); + return getCurrentGF() <= replay_.GetLastGF(); +} + +bool HeadlessReplay::Run(const std::function& onGameFrame) +{ + for(bool hasMoreFrames = true; hasMoreFrames;) + { + hasMoreFrames = RunGF(); + if(onGameFrame) + onGameFrame(*this); + } + return !desync_; +} diff --git a/libs/s25main/HeadlessReplay.h b/libs/s25main/HeadlessReplay.h new file mode 100644 index 0000000000..e5f4a2be90 --- /dev/null +++ b/libs/s25main/HeadlessReplay.h @@ -0,0 +1,63 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "AsyncChecksum.h" +#include "ILocalGameState.h" +#include "Replay.h" +#include "gameTypes/MapInfo.h" +#include +#include +#include +#include + +class Game; +class GameWorld; + +/// The game state recorded in the replay did not match the one the playback produced. +struct ReplayDesync +{ + unsigned gf; + AsyncChecksum expected; + AsyncChecksum actual; +}; + +class HeadlessReplay +{ +public: + /// Reinitializes the global RNG. + /// Throws std::runtime_error if the replay cannot be loaded. + explicit HeadlessReplay(const boost::filesystem::path& replayPath); + ~HeadlessReplay(); + + const Replay& getReplay() const { return replay_; } + const Game& getGame() const { return *game_; } + const GameWorld& getWorld() const; + bool isFromSavegame() const { return mapInfo_.savegame != nullptr; } + /// GF the replay starts at: 0, or where the savegame it was started from left off + unsigned getStartGF() const { return startGF_; } + unsigned getCurrentGF() const; + const std::optional& getDesync() const { return desync_; } + + /// Execute the commands recorded for the current GF and advance the game by one frame. + /// Returns false after the frame of the replay's last GF, where the recording ended, and at a desync. + /// The game then stays at the desynced GF: every following frame would desync too, so there is + /// nothing left to learn from continuing. + /// Throws std::runtime_error if the replay is corrupt, i.e. holds a command for a GF that already passed. + bool RunGF(); + /// Play back the whole replay, calling onGameFrame after each frame. + /// Returns false if a desync was found. + bool Run(const std::function& onGameFrame = {}); + +private: + NullLocalGameState localGameState_; + Replay replay_; + MapInfo mapInfo_; + std::unique_ptr game_; + unsigned startGF_ = 0; + /// GF the next recorded command applies to, empty once the replay is exhausted + std::optional nextGF_; + std::optional desync_; +}; diff --git a/libs/s25main/ILocalGameState.h b/libs/s25main/ILocalGameState.h index 09135f1517..a880e1ba72 100644 --- a/libs/s25main/ILocalGameState.h +++ b/libs/s25main/ILocalGameState.h @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2021 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -18,3 +18,12 @@ class ILocalGameState /// Send a chat message to the local player virtual void SystemChat(const std::string& text) = 0; }; + +/// For headless contexts: acts as the host playing as player 0 and discards all output. +struct NullLocalGameState : ILocalGameState +{ + unsigned GetPlayerId() const override { return 0; } + bool IsHost() const override { return true; } + std::string FormatGFTime(unsigned) const override { return ""; } + void SystemChat(const std::string&) override {} +}; diff --git a/libs/s25main/Replay.h b/libs/s25main/Replay.h index 786fe2f049..2d32fd6ce7 100644 --- a/libs/s25main/Replay.h +++ b/libs/s25main/Replay.h @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -84,6 +84,8 @@ class Replay : public SavedFile unsigned getSeed() const { return randomSeed_; } unsigned GetLastGF() const { return lastGF_; } + /// TODO(Replay): Remove, always true. Replays before 8.3 were recorded without the fish fix. + bool usesFishFix() const { return GetMinorVersion() >= 3; } protected: BinaryFile file_; diff --git a/libs/s25main/ReplayInfo.h b/libs/s25main/ReplayInfo.h index 201729e5cd..abe8438e4c 100644 --- a/libs/s25main/ReplayInfo.h +++ b/libs/s25main/ReplayInfo.h @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -14,10 +14,10 @@ struct ReplayInfo /// Replay file Replay replay; boost::filesystem::path filename; - /// Number of async GFs - int async = 0; + /// GF the first desync was detected at, if any + std::optional desyncGF; // GF for the next replay command if any std::optional next_gf; /// FoW deactivated? - bool all_visible; + bool all_visible = false; }; diff --git a/libs/s25main/network/GameClient.cpp b/libs/s25main/network/GameClient.cpp index 77236c2611..7b477fdd98 100644 --- a/libs/s25main/network/GameClient.cpp +++ b/libs/s25main/network/GameClient.cpp @@ -10,8 +10,8 @@ #include "GameLobby.h" #include "GameManager.h" #include "GameMessage_GameCommand.h" +#include "GameSetup.h" #include "JoinPlayerInfo.h" -#include "LeatherLoader.h" #include "Loader.h" #include "NWFInfo.h" #include "PlayerGameCommands.h" @@ -40,7 +40,6 @@ #include "random/randomIO.h" #include "world/GameWorld.h" #include "world/GameWorldView.h" -#include "world/MapLoader.h" #include "gameTypes/RoadBuildState.h" #include "gameData/GameConsts.h" #include "gameData/PortraitConsts.h" @@ -322,30 +321,15 @@ void GameClient::StartGame(const unsigned random_init) // Get standard settings before they get overwritten GetPlayer(GetPlayerId()).FillVisualSettings(default_settings); - GameWorld& gameWorld = game->world_; - if(mapinfo.savegame) - mapinfo.savegame->sgd.ReadSnapshot(*game, *this); - else + try { - RTTR_Assert(mapinfo.type != MapType::Savegame); - /// Startbündnisse setzen - for(auto& player : gameWorld.getPlayers()) - player.MakeStartPacts(); - - MapLoader loader(gameWorld); - if(!loader.Load(mapinfo.filepath) - || (!mapinfo.luaFilepath.empty() && !loader.LoadLuaScript(*game, *this, mapinfo.luaFilepath))) - { - OnError(ClientError::InvalidMap); - return; - } - // TODO (Replay): Always use true - const bool fixFish = !GetReplay() || GetReplay()->GetMinorVersion() >= 3; - MapLoader::SetupResources(gameWorld, fixFish); + SetupGameWorld(*game, mapinfo, *this, GetReplay()); + } catch(const GameSetupError& error) + { + LOG.write("%1%\n") % error.what(); + OnError(ClientError::InvalidMap); + return; } - if(replayMode && replayinfo) - gameWorld.SetReplayCompatVersion(replayinfo->replay.GetMinorVersion()); - gameWorld.InitAfterLoad(); // Update visual settings ResetVisualSettings(); @@ -1569,30 +1553,6 @@ bool GameClient::StartReplay(const boost::filesystem::path& path) return false; } - /* - We have to to this if we have a old replay starting from scratch not containing a savegame. If a savegame is - contained in the replay the compatibility code in GamePlayer deserialization function takes care of handling this. - If we have a replay starting from scratch in the constructor of the gameplayer the standard distributions are - loaded. These contain also the new leather addon buildings. When the distribution is recomputed these buildings - are added to the possible goals for wares. This leads to the problem that we have more buildings then before in - the list. So it happens for example for wood that the ware is deliverd to a different goal and then the replay - gets out of sync. - */ - if(!mapinfo.savegame && replayinfo->replay.GetMinorVersion() < 2) - { - auto newDistributions = default_settings.distribution; - unsigned idx = 0; - for(const DistributionMapping& mapping : distributionMap) - { - if(leatheraddon::isLeatherAddonBuildingType(std::get<1>(mapping))) - newDistributions[idx] = 0; - idx++; - } - - for(auto& player : game->world_.getPlayers()) - player.ChangeDistribution(newDistributions); - } - replayinfo->next_gf = replayinfo->replay.ReadGF(); return true; diff --git a/libs/s25main/network/GameClientGF_Replay.cpp b/libs/s25main/network/GameClientGF_Replay.cpp index 9f4a758f88..161ecf4d2f 100644 --- a/libs/s25main/network/GameClientGF_Replay.cpp +++ b/libs/s25main/network/GameClientGF_Replay.cpp @@ -1,4 +1,4 @@ -// Copyright (C) 2005 - 2024 Settlers Freaks (sf-team at siedler25.org) +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) // // SPDX-License-Identifier: GPL-2.0-or-later @@ -24,24 +24,23 @@ void GameClient::ExecuteGameFrame_Replay() while(replayinfo->next_gf && replayinfo->next_gf == curGF) { const auto cmd = replay.ReadCommand(); - visit( - composeVisitor( - [this](const Replay::ChatCommand& cmd) { - if(ci) - ci->CI_Chat(cmd.player, cmd.dest, cmd.msg); - }, - [this, &cmdsExecuted, &checksum, curGF](const Replay::GameCommand& cmd) { - cmdsExecuted = true; + visit(composeVisitor( + [this](const Replay::ChatCommand& cmd) { + if(ci) + ci->CI_Chat(cmd.player, cmd.dest, cmd.msg); + }, + [this, &cmdsExecuted, &checksum, curGF](const Replay::GameCommand& cmd) { + cmdsExecuted = true; - ExecuteAllGCs(cmd.player, cmd.cmds); - const AsyncChecksum& msgChecksum = cmd.cmds.checksum; + ExecuteAllGCs(cmd.player, cmd.cmds); + const AsyncChecksum& msgChecksum = cmd.cmds.checksum; - // Check for async if checksum data is valid - if(msgChecksum.randChecksum != 0 && msgChecksum != checksum) - { - // Show message if this is the first async GF - if(replayinfo->async == 0) + // Check for async if checksum data is valid. + // Only the first one is worth reporting: every NWF after it is async too. + if(msgChecksum.randChecksum != 0 && msgChecksum != checksum && !replayinfo->desyncGF) { + replayinfo->desyncGF = curGF; + if(ci) { ci->CI_ReplayAsync(helpers::format( @@ -57,11 +56,8 @@ void GameClient::ExecuteGameFrame_Replay() if(skiptogf) skiptogf = 0; } - - replayinfo->async++; - } - }), - cmd); + }), + cmd); // Read GF of next command replayinfo->next_gf = replayinfo->replay.ReadGF(); } @@ -88,14 +84,11 @@ void GameClient::ExecuteGameFrame_Replay() ci->CI_ReplayEndReached(text); } - if(replayinfo->async != 0) + if(replayinfo->desyncGF && ci) { // in-game messenger - if(ci) - { - ci->CI_ReplayEndReached( - helpers::format(_("Notice: Overall asynchronous frame count: %u"), replayinfo->async)); - } + ci->CI_ReplayEndReached( + helpers::format(_("Notice: The replay was out of sync from GF %u on."), *replayinfo->desyncGF)); } replayinfo->next_gf.reset(); framesinfo.isPaused = true; diff --git a/tests/s25Main/autoplay/main.cpp b/tests/s25Main/autoplay/main.cpp index 9eed29876f..804e25b42c 100644 --- a/tests/s25Main/autoplay/main.cpp +++ b/tests/s25Main/autoplay/main.cpp @@ -3,26 +3,21 @@ // SPDX-License-Identifier: GPL-2.0-or-later #define BOOST_TEST_MODULE RTTR_AutoplayTest -#include "EventManager.h" -#include "Game.h" -#include "GamePlayer.h" +#include "HeadlessReplay.h" +#include "PlayerInfo.h" #include "Replay.h" -#include "Savegame.h" #include "Timer.h" #include "helpers/chronoIO.h" -#include "network/PlayerGameCommands.h" #include "ogl/glAllocator.h" #include "random/Random.h" #include "random/randomIO.h" -#include "variant.h" -#include "world/GameWorld.h" -#include "world/MapLoader.h" #include "gameTypes/MapInfo.h" #include "test/testConfig.h" #include "libsiedler2/libsiedler2.h" #include "s25util/tmpFile.h" #include #include +#include #include #if RTTR_HAS_VLD @@ -33,102 +28,33 @@ struct Fixture : rttr::test::Fixture { Fixture() { libsiedler2::setAllocator(new GlAllocator); } }; -struct MockGameState : ILocalGameState -{ -public: - unsigned GetPlayerId() const override { return 0; } - bool IsHost() const override { return true; } - std::string FormatGFTime(unsigned) const override { return ""; } - void SystemChat(const std::string&) override {} -}; BOOST_GLOBAL_FIXTURE(Fixture); -static boost::test_tools::predicate_result verifyChecksum(const AsyncChecksum& actual, const AsyncChecksum& expected, - const bool fail = false) +static boost::test_tools::predicate_result verifyNoDesync(const std::optional& desync) { - if(!fail && (expected.randChecksum == 0 || actual == expected)) + if(!desync) return true; // LCOV_EXCL_START boost::test_tools::predicate_result result(false); - result.message() << '\n' << actual << " != \n" << expected << '\n'; + result.message() << "Desync at GF " << desync->gf << ":\n" + << desync->actual << " != \n" + << desync->expected << '\n'; for(const auto& entry : RANDOM.GetAsyncLog()) result.message() << entry << '\n'; return result; // LCOV_EXCL_STOP } -static void playReplay(const boost::filesystem::path& replayPath, const bool isSavegame) +static void playReplay(const boost::filesystem::path& replayPath) { - Replay replay; - BOOST_TEST_REQUIRE(replay.LoadHeader(replayPath)); - MapInfo mapInfo; - BOOST_TEST_REQUIRE(replay.LoadGameData(mapInfo)); - std::vector players; - for(unsigned i = 0; i < replay.GetNumPlayers(); i++) - players.emplace_back(replay.GetPlayer(i)); - Game game(replay.ggs, /*startGF*/ 0, players); - RANDOM.Init(replay.getSeed()); - GameWorld& gameWorld = game.world_; - - if(isSavegame) - { - BOOST_TEST_REQUIRE(mapInfo.savegame); - MockGameState gs; - mapInfo.savegame->sgd.ReadSnapshot(game, gs); - } else - { - TmpFile mapfile; - mapfile.close(); - BOOST_TEST_REQUIRE(!mapInfo.savegame); - BOOST_TEST_REQUIRE(mapInfo.mapData.DecompressToFile(mapfile.filePath)); - MapLoader loader(gameWorld); - BOOST_TEST_REQUIRE(loader.Load(mapfile.filePath)); - // TODO(replay): Since 8.3 invalid fish is removed when starting from map - BOOST_TEST_REQUIRE(replay.GetMajorVersion() == 8u); - BOOST_TEST_REQUIRE(replay.GetMinorVersion() < 3u); - MapLoader::SetupResources(gameWorld, false); - - for(auto& player : gameWorld.getPlayers()) - player.MakeStartPacts(); - } - - gameWorld.SetReplayCompatVersion(replay.GetMinorVersion()); - gameWorld.InitAfterLoad(); - - bool endOfReplay = false; - auto nextGF = replay.ReadGF(); - BOOST_TEST_REQUIRE(nextGF.has_value()); + HeadlessReplay replay(replayPath); const Timer timer(true); - do - { - const unsigned curGF = game.em_->GetCurrentGF(); - AsyncChecksum checksum; - if(*nextGF == curGF) - checksum = AsyncChecksum::create(game); - while(*nextGF == curGF) - { - BOOST_TEST_INFO("Current GF: " << curGF); - const auto cmd = replay.ReadCommand(); - visit(composeVisitor([](const Replay::ChatCommand&) {}, - [&](const Replay::GameCommand& cmd) { - for(const gc::GameCommandPtr& gc : cmd.cmds.gcs) - gc->Execute(game.world_, cmd.player); - BOOST_TEST_REQUIRE(verifyChecksum(checksum, cmd.cmds.checksum)); - }), - cmd); - nextGF = replay.ReadGF(); - if(!nextGF) - { - endOfReplay = true; - break; - } else - BOOST_TEST_REQUIRE(*nextGF <= replay.GetLastGF()); - } - game.RunGF(); - } while(!endOfReplay); + replay.Run(); const auto duration = std::chrono::duration_cast>(timer.getElapsed()); std::cout << "Replay " << replayPath.filename() << " took " << helpers::withUnit(duration) << std::endl; + + BOOST_TEST_REQUIRE(verifyNoDesync(replay.getDesync())); } BOOST_AUTO_TEST_CASE(Play200kReplay) @@ -141,7 +67,7 @@ BOOST_AUTO_TEST_CASE(Play200kReplay) // Save immediately, then load (so savegame is embedded instead of map) // 200k GFs run (+ a bit) const boost::filesystem::path replayPath = rttr::test::rttrBaseDir / "tests" / "testData" / "200kGFs.rpl"; - playReplay(replayPath, true); + playReplay(replayPath); } BOOST_AUTO_TEST_CASE(PlaySeaReplay) @@ -151,5 +77,32 @@ BOOST_AUTO_TEST_CASE(PlaySeaReplay) // No teams, Sea attacks enabled (harbors block), ships fast // 300k GFs run (+ a bit) const boost::filesystem::path replayPath = rttr::test::rttrBaseDir / "tests" / "testData" / "SeaMap300kGfs.rpl"; - playReplay(replayPath, false); + playReplay(replayPath); +} + +BOOST_AUTO_TEST_CASE(ReplayWithCommandOutOfOrderIsRejected) +{ + MapInfo mapInfo; + mapInfo.type = MapType::OldMap; + mapInfo.filepath = rttr::test::rttrBaseDir / "tests" / "testData" / "maps" / "LuaFunctions.SWD"; + BOOST_TEST_REQUIRE(mapInfo.mapData.CompressFromFile(mapInfo.filepath)); + PlayerInfo player; + player.ps = PlayerState::AI; + + Replay replay; + replay.AddPlayer(player); + TmpFile replayFile(".rpl"); + BOOST_TEST_REQUIRE(replayFile.isValid()); + replayFile.close(); + boost::filesystem::remove(replayFile.filePath); // Recording refuses to overwrite + BOOST_TEST_REQUIRE(replay.StartRecording(replayFile.filePath, mapInfo, 42)); + const PlayerGameCommands noCmds; + replay.AddGameCommand(10, 0, noCmds); + replay.AddGameCommand(5, 0, noCmds); + replay.UpdateLastGF(20); + BOOST_TEST_REQUIRE(replay.StopRecording()); + + HeadlessReplay headlessReplay(replayFile.filePath); + BOOST_CHECK_THROW(headlessReplay.Run(), std::runtime_error); + BOOST_TEST(headlessReplay.getCurrentGF() == 11u); }