From a3bd3bd680b88290f7c2efb2c5683fa976ca1164 Mon Sep 17 00:00:00 2001 From: vyordan Date: Mon, 6 Jul 2026 23:49:19 -0600 Subject: [PATCH] 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_, 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 --- .../gamesettings/attributes/StartTime.js | 7 +++++ source/ps/Replay.cpp | 21 ++++++++++++--- source/ps/Util.cpp | 26 +++++++++++++++++++ source/ps/Util.h | 4 ++- 4 files changed, 53 insertions(+), 5 deletions(-) create mode 100644 binaries/data/mods/public/gamesettings/attributes/StartTime.js diff --git a/binaries/data/mods/public/gamesettings/attributes/StartTime.js b/binaries/data/mods/public/gamesettings/attributes/StartTime.js new file mode 100644 index 0000000000..24f60a2589 --- /dev/null +++ b/binaries/data/mods/public/gamesettings/attributes/StartTime.js @@ -0,0 +1,7 @@ +GameSettings.prototype.Attributes.StartTime = class extends GameSetting +{ + onFinalizeAttributes(attribs) + { + attribs.startTime = Date.now(); + } +}; diff --git a/source/ps/Replay.cpp b/source/ps/Replay.cpp index 8ef85fa93d..c8f5cd48ff 100644 --- a/source/ps/Replay.cpp +++ b/source/ps/Replay.cpp @@ -85,7 +85,17 @@ void CReplayLogger::StartGame(JS::MutableHandleValue attribs) Script::ToJSVal(rq, &mods, g_Mods.GetEnabledModsData()); Script::SetProperty(rq, attribs, "mods", mods); - m_Directory = createDateIndexSubdirectory(VisualReplay::GetDirectoryPath()); + 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); @@ -94,19 +104,22 @@ void CReplayLogger::StartGame(JS::MutableHandleValue attribs) void CReplayLogger::Turn(u32 n, u32 turnLength, std::vector& 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 diff --git a/source/ps/Util.cpp b/source/ps/Util.cpp index 1ac56c268b..a4309caf7d 100644 --- a/source/ps/Util.cpp +++ b/source/ps/Util.cpp @@ -33,8 +33,10 @@ #include "ps/Filesystem.h" #include "ps/Pyrogenesis.h" +#include #include #include +#include #include #include #include @@ -119,6 +121,30 @@ OsPath createDateIndexSubdirectory(const OsPath& parentDir) return path; } +OsPath createReplaySubdirectory(const OsPath& parentDir, const std::wstring& matchID, double startTimeMillis) +{ + using namespace std::chrono; + + const system_clock::time_point timePoint{milliseconds(static_cast(startTimeMillis))}; + const year_month_day ymd{floor(timePoint)}; + const std::string baseName = fmt::format("{:04}-{:02}-{:02}_{}", + static_cast(ymd.year()), static_cast(ymd.month()), static_cast(ymd.day()), + utf8_from_wstring(matchID)); + + for (int i = 0; ; ++i) + { + const OsPath path = parentDir / (i == 0 ? baseName : fmt::format("{}_{:04}", baseName, i)); + if (DirectoryExists(path) || std::filesystem::is_regular_file(path.string())) + continue; + if (CreateDirectories(path, 0700, false) != INFO::OK) + { + LOGERROR("Could not create replay directory '%s'", path.string8().c_str()); + return OsPath(); + } + return path; + } +} + std::string Hexify(const std::string& s) { std::stringstream str; diff --git a/source/ps/Util.h b/source/ps/Util.h index 36cd505f4e..a660c4d128 100644 --- a/source/ps/Util.h +++ b/source/ps/Util.h @@ -1,4 +1,4 @@ -/* Copyright (C) 2025 Wildfire Games. +/* 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 @@ -35,6 +35,8 @@ CStr GetStatusAsString(Status status); OsPath createDateIndexSubdirectory(const OsPath& parentDir); +OsPath createReplaySubdirectory(const OsPath& parentDir, const std::wstring& matchID, double startTimeMillis); + Status tex_write(Tex* t, const VfsPath& filename); std::string Hexify(const std::string& s);