mirror of
https://gitea.wildfiregames.com/0ad/0ad
synced 2026-08-15 14:43:32 -07:00
Replay folders were named YYYY-MM-DD_NNNN, a per-machine sequence counter. The same multiplayer match got a different folder name per participant, and unrelated matches could share a name, making it hard to correlate replays across players (rating aggregation, replay sharing, bug reports) without parsing commands.txt first. The engine already generates a matchID once per match, synced to all participants before the match starts. Replay folders are now named YYYY-MM-DD_<matchID>, so the same match produces the same folder name everywhere. The date comes from a new "startTime" attribute, set once by the host (StartTime.js, alongside MatchID.js) and synced like matchID, instead of each machine reading its own local clock. createDateIndexSubdirectory is untouched (still used by OOS logs and serialization tests); a new createReplaySubdirectory is added for replays only, following the same collision-handling pattern (incrementing suffix) as the existing function. This also means resuming a match (network rejoin, or a singleplayer game reloaded via StartSavedGame, both of which reuse the same matchID) saves an additional replay instead of overwriting the original. Fixes #8922
366 lines
11 KiB
C++
366 lines
11 KiB
C++
/* 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 <http://www.gnu.org/licenses/>.
|
|
*/
|
|
|
|
#include "precompiled.h"
|
|
|
|
#include "Replay.h"
|
|
|
|
#include "lib/code_generation.h"
|
|
#include "lib/debug.h"
|
|
#include "lib/file/file_system.h"
|
|
#include "lib/path.h"
|
|
#include "lib/timer.h"
|
|
#include "ps/CLogger.h"
|
|
#include "ps/CStr.h"
|
|
#include "ps/Errors.h"
|
|
#include "ps/Game.h"
|
|
#include "ps/GameSetup/CmdLineArgs.h"
|
|
#include "ps/GameSetup/GameSetup.h"
|
|
#include "ps/GameSetup/Paths.h"
|
|
#include "ps/Loader.h"
|
|
#include "ps/Mod.h"
|
|
#include "ps/Profile.h"
|
|
#include "ps/ProfileViewer.h"
|
|
#include "ps/Profiler2.h"
|
|
#include "ps/Pyrogenesis.h"
|
|
#include "ps/Util.h"
|
|
#include "ps/VisualReplay.h"
|
|
#include "scriptinterface/JSON.h"
|
|
#include "scriptinterface/Object.h"
|
|
#include "scriptinterface/Context.h"
|
|
#include "scriptinterface/Interface.h"
|
|
#include "scriptinterface/Request.h"
|
|
#include "scriptinterface/Stats.h"
|
|
#include "simulation2/Simulation2.h"
|
|
#include "simulation2/components/ICmpGuiInterface.h"
|
|
#include "simulation2/helpers/Player.h"
|
|
#include "simulation2/helpers/SimulationCommand.h"
|
|
#include "simulation2/system/Component.h"
|
|
#include "simulation2/system/Entity.h"
|
|
|
|
#include <ctime>
|
|
#include <fstream>
|
|
#include <memory>
|
|
|
|
/**
|
|
* Number of turns between two saved profiler snapshots.
|
|
* Keep in sync with source/tools/replayprofile/graph.js
|
|
*/
|
|
static const int PROFILE_TURN_INTERVAL = 20;
|
|
|
|
CReplayLogger::CReplayLogger(const Script::Interface& scriptInterface) :
|
|
m_ScriptInterface(scriptInterface), m_Stream(NULL)
|
|
{
|
|
}
|
|
|
|
CReplayLogger::~CReplayLogger()
|
|
{
|
|
delete m_Stream;
|
|
}
|
|
|
|
void CReplayLogger::StartGame(JS::MutableHandleValue attribs)
|
|
{
|
|
Script::Request rq(m_ScriptInterface);
|
|
|
|
// Add timestamp, since the file-modification-date can change
|
|
Script::SetProperty(rq, attribs, "timestamp", (double)std::time(nullptr));
|
|
|
|
// Add engine version and currently loaded mods for sanity checks when replaying
|
|
Script::SetProperty(rq, attribs, "engine_serialization_version", PS_SERIALIZATION_VERSION);
|
|
JS::RootedValue mods(rq.cx);
|
|
Script::ToJSVal(rq, &mods, g_Mods.GetEnabledModsData());
|
|
Script::SetProperty(rq, attribs, "mods", mods);
|
|
|
|
std::wstring matchID;
|
|
Script::GetProperty(rq, attribs, "matchID", matchID);
|
|
ENSURE(!matchID.empty());
|
|
|
|
double startTime = 0;
|
|
Script::GetProperty(rq, attribs, "startTime", startTime);
|
|
|
|
m_Directory = createReplaySubdirectory(VisualReplay::GetDirectoryPath(), matchID, startTime);
|
|
debug_printf("FILES| Replay written to '%s'\n", m_Directory.string8().c_str());
|
|
|
|
m_Stream = new std::ofstream(OsString(m_Directory / L"commands.txt"), std::ofstream::out | std::ofstream::trunc);
|
|
*m_Stream << "start " << Script::StringifyJSON(rq, attribs, false) << "\n";
|
|
}
|
|
|
|
void CReplayLogger::Turn(u32 n, u32 turnLength, std::vector<SimulationCommand>& commands)
|
|
{
|
|
Script::Request rq(m_ScriptInterface);
|
|
*m_Stream << "turn " << n << " " << turnLength << "\n";
|
|
for (SimulationCommand& command : commands)
|
|
*m_Stream << "cmd " << command.player << " " << Script::StringifyJSON(rq, &command.data, false) << "\n";
|
|
*m_Stream << "end\n";
|
|
m_Stream->flush();
|
|
}
|
|
|
|
void CReplayLogger::Hash(const std::string& hash, bool quick)
|
|
{
|
|
if (quick)
|
|
*m_Stream << "hash-quick " << Hexify(hash) << "\n";
|
|
else
|
|
*m_Stream << "hash " << Hexify(hash) << "\n";
|
|
}
|
|
|
|
void CReplayLogger::SaveMetadata(const CSimulation2& simulation)
|
|
{
|
|
CmpPtr<ICmpGuiInterface> cmpGuiInterface(simulation, SYSTEM_ENTITY);
|
|
if (!cmpGuiInterface)
|
|
{
|
|
LOGERROR("Could not save replay metadata!");
|
|
return;
|
|
}
|
|
|
|
Script::Interface& scriptInterface = simulation.GetScriptInterface();
|
|
Script::Request rq(scriptInterface);
|
|
|
|
JS::RootedValue arg(rq.cx);
|
|
JS::RootedValue metadata(rq.cx);
|
|
cmpGuiInterface->ScriptCall(INVALID_PLAYER, L"GetReplayMetadata", arg, &metadata);
|
|
|
|
const OsPath fileName = g_Game->GetReplayLogger().GetDirectory() / L"metadata.json";
|
|
CreateDirectories(fileName.Parent(), 0700);
|
|
|
|
std::ofstream stream (OsString(fileName), std::ofstream::out | std::ofstream::trunc);
|
|
if (stream)
|
|
{
|
|
stream << Script::StringifyJSON(rq, &metadata, false);
|
|
stream.close();
|
|
debug_printf("FILES| Replay metadata written to '%s'\n", fileName.string8().c_str());
|
|
}
|
|
else
|
|
debug_printf("FILES| Failed to write replay metadata to '%s'\n", fileName.string8().c_str());
|
|
|
|
}
|
|
|
|
OsPath CReplayLogger::GetDirectory() const
|
|
{
|
|
return m_Directory;
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////
|
|
|
|
CReplayPlayer::CReplayPlayer() :
|
|
m_Stream(NULL)
|
|
{
|
|
}
|
|
|
|
CReplayPlayer::~CReplayPlayer()
|
|
{
|
|
delete m_Stream;
|
|
}
|
|
|
|
void CReplayPlayer::Load(const OsPath& path)
|
|
{
|
|
ENSURE(!m_Stream);
|
|
|
|
m_Stream = new std::ifstream(OsString(path));
|
|
ENSURE(m_Stream->good());
|
|
}
|
|
|
|
namespace
|
|
{
|
|
CStr ModListToString(const std::vector<const Mod::ModData*>& list)
|
|
{
|
|
CStr text;
|
|
for (const Mod::ModData* data : list)
|
|
text += data->m_Pathname + " (" + data->m_Version + ")\n";
|
|
return text;
|
|
}
|
|
|
|
void CheckReplayMods(const std::vector<Mod::ModData>& replayMods)
|
|
{
|
|
std::vector<const Mod::ModData*> replayData;
|
|
replayData.reserve(replayMods.size());
|
|
for (const Mod::ModData& data : replayMods)
|
|
replayData.push_back(&data);
|
|
if (!Mod::AreModsPlayCompatible(g_Mods.GetEnabledModsData(), replayData))
|
|
LOGWARNING("Incompatible replay mods detected.\nThe mods of the replay are:\n%s\nThese mods are enabled:\n%s",
|
|
ModListToString(replayData), ModListToString(g_Mods.GetEnabledModsData()));
|
|
}
|
|
} // anonymous namespace
|
|
|
|
void CReplayPlayer::Replay(const int serializationtestturn, const int rejointestturn, const bool ooslog,
|
|
const bool testHashFull, const bool testHashQuick)
|
|
{
|
|
ENSURE(m_Stream);
|
|
|
|
new CProfileViewer;
|
|
new CProfileManager;
|
|
g_ScriptStatsTable = new Script::CScriptStatsTable;
|
|
g_ProfileViewer.AddRootTable(g_ScriptStatsTable);
|
|
|
|
const int contextSize = 384 * 1024 * 1024;
|
|
const int heapGrowthBytesGCTrigger = 12 * 1024 * 1024;
|
|
g_ScriptContext = std::make_shared<Script::Context>(contextSize, heapGrowthBytesGCTrigger);
|
|
|
|
std::vector<SimulationCommand> commands;
|
|
u32 turn = 0;
|
|
u32 turnLength = 0;
|
|
|
|
{
|
|
std::string type;
|
|
|
|
while ((*m_Stream >> type).good())
|
|
{
|
|
if (type == "start")
|
|
{
|
|
std::string attribsStr;
|
|
{
|
|
Script::Interface scriptInterface("Engine", "Replay", g_ScriptContext);
|
|
Script::Request rq(scriptInterface);
|
|
std::getline(*m_Stream, attribsStr);
|
|
JS::RootedValue attribs(rq.cx);
|
|
if (!Script::ParseJSON(rq, attribsStr, &attribs))
|
|
{
|
|
LOGERROR("Error parsing JSON attributes: %s", attribsStr);
|
|
// TODO: do something cleverer than crashing.
|
|
ENSURE(false);
|
|
}
|
|
|
|
// Load the mods specified in the replay.
|
|
std::vector<Mod::ModData> replayMods;
|
|
if (!Script::GetProperty(rq, attribs, "mods", replayMods))
|
|
{
|
|
LOGERROR("Could not get replay mod information.");
|
|
// TODO: do something cleverer than crashing.
|
|
ENSURE(false);
|
|
}
|
|
|
|
std::vector<CStr> mods;
|
|
for (const Mod::ModData& data : replayMods)
|
|
mods.emplace_back(data.m_Pathname);
|
|
|
|
// Ignore the return value, we check below.
|
|
g_Mods.UpdateAvailableMods(scriptInterface);
|
|
g_Mods.EnableMods(mods, false);
|
|
CheckReplayMods(replayMods);
|
|
|
|
MountMods(Paths(g_CmdLineArgs), g_Mods.GetEnabledMods());
|
|
}
|
|
|
|
if (serializationtestturn >= 0 && rejointestturn >= 0)
|
|
LOGERROR("serializationtest and rejointest can't be activ at the same time.");
|
|
|
|
SimulationDebugOptions debugOption{};
|
|
if (serializationtestturn >= 0)
|
|
{
|
|
debugOption.test =
|
|
SimulationDebugOptions::SerializationTest{serializationtestturn};
|
|
}
|
|
if (rejointestturn >= 0)
|
|
debugOption.test = SimulationDebugOptions::RejoinTest{rejointestturn};
|
|
if (ooslog)
|
|
debugOption.oosLog = true;
|
|
|
|
g_Game = new CGame(false, debugOption);
|
|
|
|
Script::Request rq(g_Game->GetSimulation2()->GetScriptInterface());
|
|
JS::RootedValue attribs(rq.cx);
|
|
ENSURE(Script::ParseJSON(rq, attribsStr, &attribs));
|
|
g_Game->StartGame(&attribs, "");
|
|
|
|
// TODO: Non progressive load can fail - need a decent way to handle this
|
|
PS::Loader::NonprogressiveLoad();
|
|
|
|
PSRETURN ret = g_Game->ReallyStartGame();
|
|
ENSURE(ret == PSRETURN_OK);
|
|
}
|
|
else if (type == "turn")
|
|
{
|
|
*m_Stream >> turn >> turnLength;
|
|
debug_printf("Turn %u (%u)...\n", turn, turnLength);
|
|
}
|
|
else if (type == "cmd")
|
|
{
|
|
player_id_t player;
|
|
*m_Stream >> player;
|
|
|
|
std::string line;
|
|
std::getline(*m_Stream, line);
|
|
Script::Request rq(g_Game->GetSimulation2()->GetScriptInterface());
|
|
JS::RootedValue data(rq.cx);
|
|
Script::ParseJSON(rq, line, &data);
|
|
Script::DeepFreezeObject(rq, data);
|
|
commands.emplace_back(SimulationCommand(player, rq.cx, data));
|
|
}
|
|
else if (type == "hash" || type == "hash-quick")
|
|
{
|
|
std::string replayHash;
|
|
*m_Stream >> replayHash;
|
|
TestHash(type, replayHash, testHashFull, testHashQuick);
|
|
}
|
|
else if (type == "end")
|
|
{
|
|
{
|
|
g_Profiler2.RecordFrameStart();
|
|
PROFILE2("frame");
|
|
g_Profiler2.IncrementFrameNumber();
|
|
PROFILE2_ATTR("%d", g_Profiler2.GetFrameNumber());
|
|
|
|
g_Game->GetSimulation2()->Update(turnLength, commands);
|
|
commands.clear();
|
|
}
|
|
|
|
g_Profiler.Frame();
|
|
|
|
if (turn % PROFILE_TURN_INTERVAL == 0)
|
|
g_ProfileViewer.SaveToFile();
|
|
}
|
|
else
|
|
debug_printf("Unrecognized replay token %s\n", type.c_str());
|
|
}
|
|
}
|
|
|
|
SAFE_DELETE(m_Stream);
|
|
|
|
g_Profiler2.SaveToFile();
|
|
|
|
std::string hash;
|
|
bool ok = g_Game->GetSimulation2()->ComputeStateHash(hash, false);
|
|
ENSURE(ok);
|
|
debug_printf("# Final state: %s\n", Hexify(hash).c_str());
|
|
timer_DisplayClientTotals();
|
|
|
|
SAFE_DELETE(g_Game);
|
|
|
|
// Must be explicitly destructed here to avoid callbacks from the JSAPI trying to use g_Profiler2 when
|
|
// it's already destructed.
|
|
g_ScriptContext.reset();
|
|
|
|
delete &g_Profiler;
|
|
delete &g_ProfileViewer;
|
|
SAFE_DELETE(g_ScriptStatsTable);
|
|
}
|
|
|
|
void CReplayPlayer::TestHash(const std::string& hashType, const std::string& replayHash, const bool testHashFull, const bool testHashQuick)
|
|
{
|
|
bool quick = (hashType == "hash-quick");
|
|
if ((quick && !testHashQuick) || (!quick && !testHashFull))
|
|
return;
|
|
|
|
std::string hash;
|
|
ENSURE(g_Game->GetSimulation2()->ComputeStateHash(hash, quick));
|
|
|
|
std::string hexHash = Hexify(hash);
|
|
|
|
if (hexHash == replayHash)
|
|
debug_printf("%s ok (%s)\n", hashType.c_str(), hexHash.c_str());
|
|
else
|
|
debug_printf("%s MISMATCH (%s != %s)\n", hashType.c_str(), hexHash.c_str(), replayHash.c_str());
|
|
}
|