0ad/source/ps/Replay.cpp
vyordan a3bd3bd680 Replay: name replay folders after matchID instead of a per-machine index
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
2026-08-04 14:39:04 -06:00

375 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);
if (m_Directory.empty())
return;
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)
{
if (!m_Stream)
return;
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 (!m_Stream)
return;
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());
}