diff --git a/extras/CMakeLists.txt b/extras/CMakeLists.txt index edbac0174a..af1ff83eba 100644 --- a/extras/CMakeLists.txt +++ b/extras/CMakeLists.txt @@ -12,6 +12,7 @@ set(CMAKE_VISIBILITY_INLINES_HIDDEN ON) add_subdirectory(audioDrivers) add_subdirectory(videoDrivers) add_subdirectory(ai-battle) +add_subdirectory(replay-player) if(RTTR_BUNDLE AND APPLE) add_subdirectory(macosLauncher) endif() diff --git a/extras/replay-player/CMakeLists.txt b/extras/replay-player/CMakeLists.txt new file mode 100644 index 0000000000..4b5f80f4f0 --- /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 HeadlessReplay.cpp HeadlessReplay.h) +target_link_libraries(replay-player PRIVATE s25Main Boost::program_options Boost::nowide) + +if(WIN32) + include(GatherDll) + gather_dll_copy(replay-player) +endif() diff --git a/extras/replay-player/HeadlessReplay.cpp b/extras/replay-player/HeadlessReplay.cpp new file mode 100644 index 0000000000..729b02b26e --- /dev/null +++ b/extras/replay-player/HeadlessReplay.cpp @@ -0,0 +1,152 @@ +// 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 "GamePlayer.h" +#include "Replay.h" +#include "world/GameWorld.h" +#include "gameTypes/TeamTypes.h" +#include "gameData/GameConsts.h" +#include "gameData/NationConsts.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 + } +} + +static std::string fmtClock(const 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; +} + +static std::string fmtNum(unsigned n) +{ + std::ostringstream ss; + ss.imbue(std::locale("")); + ss << std::fixed << n; + return ss.str(); +} + +void printTable(const ReplayStatus& s) +{ + const unsigned curGF = s.game.em_->GetCurrentGF(); + const auto wallElapsed = + std::chrono::duration_cast(std::chrono::steady_clock::now() - s.startTime); + const auto gameElapsed = + std::chrono::duration_cast(SPEED_GF_LENGTHS[GameSpeed::Normal] * curGF); + const unsigned gfPerSec = curGF - s.lastReportGF; + const unsigned numPlayers = s.world.GetNumPlayers(); + + static bool firstCall = true; + if(!firstCall) + printConsole("\x1b[%uA", 8 + numPlayers); + firstCall = false; + + printConsole("┌──────────────────────────┬───────────────────────┬───────────────────────┬────────────────┐\n"); + printConsole("│ GF %10s / %-8s │ Game Clock %s │ Wall Clock %s │ %7s GF/sec │\n", fmtNum(curGF).c_str(), + fmtNum(s.totalGFs).c_str(), fmtClock(gameElapsed).c_str(), fmtClock(wallElapsed).c_str(), + fmtNum(gfPerSec).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 = s.world.GetPlayer(i); + printConsole("│ %s%-26s%s │ %18s │ %13s │ %10s │ %10s │\n", p.IsDefeated() ? "\x1b[9m" : "", p.name.c_str(), + p.IsDefeated() ? "\x1b[29m" : "", + fmtNum(p.GetStatisticCurrentValue(StatisticType::Country)).c_str(), + fmtNum(p.GetStatisticCurrentValue(StatisticType::Buildings)).c_str(), + fmtNum(p.GetStatisticCurrentValue(StatisticType::Military)).c_str(), + fmtNum(p.GetStatisticCurrentValue(StatisticType::Gold)).c_str()); + } + printConsole("└────────────────────────────┴────────────────────┴───────────────┴────────────┴────────────┘\n"); +} + +static 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"; + } +} + +void printInitialInfo(const Replay& replay, const GameWorld& world, bool isSavegame, unsigned startGF) +{ + const MapPoint mapSize = world.GetSize(); + bnw::cout << "\n"; + bnw::cout << "Replay: " << replay.GetMapName() << "\n"; + bnw::cout << "Version: " << static_cast(replay.GetMajorVersion()) << "." + << static_cast(replay.GetMinorVersion()) << "\n"; + bnw::cout << "Type: " << (isSavegame ? "savegame replay" : "new-game replay") << "\n"; + bnw::cout << "Map size: " << mapSize.x << " x " << mapSize.y << "\n"; + bnw::cout << "Seed: " << replay.getSeed() << "\n"; + bnw::cout << "GF range: " << startGF << " - " << replay.GetLastGF() << " (" << (replay.GetLastGF() - startGF) + << " GFs)\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(); +} diff --git a/extras/replay-player/HeadlessReplay.h b/extras/replay-player/HeadlessReplay.h new file mode 100644 index 0000000000..9b50a2da78 --- /dev/null +++ b/extras/replay-player/HeadlessReplay.h @@ -0,0 +1,42 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#pragma once + +#include "ILocalGameState.h" + +#include +#include + +class Game; +class GameWorld; +class Replay; + +#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 + +struct HeadlessGameState : 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 {} +}; + +struct ReplayStatus +{ + const Game& game; + const GameWorld& world; + unsigned totalGFs; + std::chrono::steady_clock::time_point startTime; + unsigned lastReportGF = 0; +}; + +void printTable(const ReplayStatus& s); +void printInitialInfo(const Replay& replay, const GameWorld& world, bool isSavegame, unsigned startGF); diff --git a/extras/replay-player/main.cpp b/extras/replay-player/main.cpp new file mode 100644 index 0000000000..df372dd0e8 --- /dev/null +++ b/extras/replay-player/main.cpp @@ -0,0 +1,265 @@ +// Copyright (C) 2005 - 2026 Settlers Freaks (sf-team at siedler25.org) +// +// SPDX-License-Identifier: GPL-2.0-or-later + +#include "AsyncChecksum.h" +#include "EventManager.h" +#include "Game.h" +#include "GamePlayer.h" +#include "HeadlessReplay.h" +#include "PlayerInfo.h" +#include "RTTR_Version.h" +#include "Replay.h" +#include "RttrConfig.h" +#include "Savegame.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 "libsiedler2/libsiedler2.h" +#include "s25util/System.h" +#include "s25util/tmpFile.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 the first 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(); + 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"; + + Replay replay; + if(!replay.LoadHeader(replayPath)) + { + bnw::cerr << "Failed to load replay header: " << replay.GetLastErrorMsg() << "\n"; + return 1; + } + MapInfo mapInfo; + if(!replay.LoadGameData(mapInfo)) + { + bnw::cerr << "Failed to load replay game data: " << replay.GetLastErrorMsg() << "\n"; + return 1; + } + + 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_; + + const bool isSavegame = (mapInfo.savegame != nullptr); + if(isSavegame) + { + HeadlessGameState gs; + mapInfo.savegame->sgd.ReadSnapshot(game, gs); + } else + { + TmpFile mapfile; + mapfile.close(); + if(!mapInfo.mapData.DecompressToFile(mapfile.filePath)) + { + bnw::cerr << "Failed to decompress embedded map data\n"; + return 1; + } + MapLoader loader(gameWorld); + if(!loader.Load(mapfile.filePath)) + { + bnw::cerr << "Failed to load map\n"; + return 1; + } + if(mapInfo.luaData.uncompressedLength > 0) + { + TmpFile luaFile(".lua"); + luaFile.close(); + if(!mapInfo.luaData.DecompressToFile(luaFile.filePath)) + { + bnw::cerr << "Failed to decompress embedded Lua script\n"; + return 1; + } + HeadlessGameState gs; + if(!loader.LoadLuaScript(game, gs, luaFile.filePath)) + { + bnw::cerr << "Failed to load embedded Lua script\n"; + return 1; + } + gameWorld.GetLua().setSuppressStdout(true); + bnw::cout << "Lua script loaded from replay.\n"; + } + const bool fixFish = !(replay.GetMajorVersion() == 8 && replay.GetMinorVersion() < 3); + MapLoader::SetupResources(gameWorld, fixFish); + if(!fixFish) + bnw::cout << "Note: fish fix skipped (replay version " + << static_cast(replay.GetMajorVersion()) << "." + << static_cast(replay.GetMinorVersion()) << " predates 8.3)\n"; + + for(unsigned i = 0; i < gameWorld.GetNumPlayers(); ++i) + gameWorld.GetPlayer(i).MakeStartPacts(); + } + + gameWorld.InitAfterLoad(); + + const unsigned startGF = game.em_->GetCurrentGF(); + printInitialInfo(replay, gameWorld, isSavegame, startGF); + + auto nextGF = replay.ReadGF(); + if(!nextGF) + { + bnw::cerr << "Empty replay: no commands found\n"; + return 1; + } + + ReplayStatus status{game, gameWorld, replay.GetLastGF(), std::chrono::steady_clock::now(), startGF}; + auto nextReport = status.startTime + std::chrono::seconds(1); + + bool endOfReplay = false; + unsigned asyncCount = 0; + + do + { + const unsigned curGF = game.em_->GetCurrentGF(); + + AsyncChecksum checksum; + if(*nextGF == curGF) + checksum = AsyncChecksum::create(game); + + while(*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& stored = gcmd.cmds.checksum; + if(stored.randChecksum != 0 && stored != checksum) + { + ++asyncCount; + if(asyncCount == 1) + { + bnw::cerr << "\nFirst desync at GF " << curGF << ":\n" + << " actual: " << checksum << "\n" + << " stored: " << stored << "\n"; + if(verbose) + { + for(const auto& entry : RANDOM.GetAsyncLog()) + bnw::cerr << " " << entry << "\n"; + } + } + } + }), + cmd); + + nextGF = replay.ReadGF(); + if(!nextGF) + { + endOfReplay = true; + break; + } + } + + game.RunGF(); + + const auto now = std::chrono::steady_clock::now(); + if(now >= nextReport) + { + nextReport += std::chrono::seconds(1); + printTable(status); + status.lastReportGF = game.em_->GetCurrentGF(); + } + } while(!endOfReplay); + + // final table + printTable(status); + printConsole("\n"); + + const unsigned finalGF = game.em_->GetCurrentGF(); + const float elapsed = + std::chrono::duration_cast>(std::chrono::steady_clock::now() - status.startTime) + .count(); + bnw::cout << "Finished " << finalGF << " GFs in " << elapsed << "s (" + << static_cast(finalGF / elapsed) << " GF/s)\n"; + + if(asyncCount > 0) + { + bnw::cerr << "FAIL: " << asyncCount << " async frame(s) detected.\n"; + 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/libGamedata/lua/LuaInterfaceBase.cpp b/libs/libGamedata/lua/LuaInterfaceBase.cpp index 573fe1ad96..ae74da7659 100644 --- a/libs/libGamedata/lua/LuaInterfaceBase.cpp +++ b/libs/libGamedata/lua/LuaInterfaceBase.cpp @@ -168,5 +168,5 @@ bool LuaInterfaceBase::validateUTF8(const std::string& scriptTxt) void LuaInterfaceBase::log(const std::string& msg) { - logger_.write("%s\n") % msg; + logger_.write("%s\n", suppressStdout_ ? LogTarget::File : LogTarget::FileAndStdout) % msg; } diff --git a/libs/libGamedata/lua/LuaInterfaceBase.h b/libs/libGamedata/lua/LuaInterfaceBase.h index 3876f3fefc..5134299202 100644 --- a/libs/libGamedata/lua/LuaInterfaceBase.h +++ b/libs/libGamedata/lua/LuaInterfaceBase.h @@ -35,6 +35,7 @@ class LuaInterfaceBase /// Disable or re-enable throwing an exception on error. /// Note: If error throwing is disabled you have to use HasErrorOccurred to detect an error situation void setThrowOnError(bool doThrow); + void setSuppressStdout(bool suppress) { suppressStdout_ = suppress; } bool hasErrorOccurred() const { return errorOccured_; } void clearErrorOccured() { errorOccured_ = false; } @@ -60,6 +61,7 @@ class LuaInterfaceBase private: Log& logger_; + bool suppressStdout_ = false; /// Sticky flag to signal an occurred error during execution of lua code bool errorOccured_; std::map translations_;