From 4b696c03410d7a8358b15259b6b161e25a56c37e Mon Sep 17 00:00:00 2001 From: Bill Rich Date: Tue, 10 Mar 2026 18:24:56 +0000 Subject: [PATCH] feat(stats): Store match stats with last replay --- Generals/Code/GameEngine/CMakeLists.txt | 2 + .../GameEngine/Include/Common/GlobalData.h | 4 + .../GameEngine/Include/Common/ScoreKeeper.h | 10 +- .../GameEngine/Include/Common/StatsExporter.h | 35 + .../GameEngine/Source/Common/CommandLine.cpp | 11 + .../GameEngine/Source/Common/GlobalData.cpp | 1 + .../GameEngine/Source/Common/Recorder.cpp | 5 + .../Source/Common/StatsExporter.cpp | 690 +++++++++++++++++ .../Source/GameLogic/System/GameLogic.cpp | 6 + GeneralsMD/Code/GameEngine/CMakeLists.txt | 2 + .../GameEngine/Include/Common/AcademyStats.h | 20 + .../GameEngine/Include/Common/GlobalData.h | 4 + .../GameEngine/Include/Common/ScoreKeeper.h | 10 +- .../GameEngine/Include/Common/StatsExporter.h | 35 + .../GameEngine/Source/Common/CommandLine.cpp | 11 + .../GameEngine/Source/Common/GlobalData.cpp | 1 + .../GameEngine/Source/Common/Recorder.cpp | 5 + .../Source/Common/StatsExporter.cpp | 714 ++++++++++++++++++ .../Source/GameLogic/System/GameLogic.cpp | 6 + 19 files changed, 1570 insertions(+), 2 deletions(-) create mode 100644 Generals/Code/GameEngine/Include/Common/StatsExporter.h create mode 100644 Generals/Code/GameEngine/Source/Common/StatsExporter.cpp create mode 100644 GeneralsMD/Code/GameEngine/Include/Common/StatsExporter.h create mode 100644 GeneralsMD/Code/GameEngine/Source/Common/StatsExporter.cpp diff --git a/Generals/Code/GameEngine/CMakeLists.txt b/Generals/Code/GameEngine/CMakeLists.txt index 124246f68f4..30be264514e 100644 --- a/Generals/Code/GameEngine/CMakeLists.txt +++ b/Generals/Code/GameEngine/CMakeLists.txt @@ -105,6 +105,7 @@ set(GAMEENGINE_SRC Include/Common/StackDump.h Include/Common/StateMachine.h Include/Common/StatsCollector.h + Include/Common/StatsExporter.h # Include/Common/STLTypedefs.h # Include/Common/StreamingArchiveFile.h # Include/Common/SubsystemInterface.h @@ -586,6 +587,7 @@ set(GAMEENGINE_SRC Source/Common/SkirmishBattleHonors.cpp Source/Common/StateMachine.cpp Source/Common/StatsCollector.cpp + Source/Common/StatsExporter.cpp # Source/Common/System/ArchiveFile.cpp # Source/Common/System/ArchiveFileSystem.cpp # Source/Common/System/AsciiString.cpp diff --git a/Generals/Code/GameEngine/Include/Common/GlobalData.h b/Generals/Code/GameEngine/Include/Common/GlobalData.h index 7974dd6486f..f88aee1cf69 100644 --- a/Generals/Code/GameEngine/Include/Common/GlobalData.h +++ b/Generals/Code/GameEngine/Include/Common/GlobalData.h @@ -120,6 +120,10 @@ class GlobalData : public SubsystemInterface // Run game without graphics, input or audio. Bool m_headless; + // TheSuperHackers @feature bill-rich 11/03/2026 + // Export game stats as JSON alongside replay file. + Bool m_exportStats; + Bool m_windowed; Int m_xResolution; Int m_yResolution; diff --git a/Generals/Code/GameEngine/Include/Common/ScoreKeeper.h b/Generals/Code/GameEngine/Include/Common/ScoreKeeper.h index 8c0f4bbac84..0fd5864bc8e 100644 --- a/Generals/Code/GameEngine/Include/Common/ScoreKeeper.h +++ b/Generals/Code/GameEngine/Include/Common/ScoreKeeper.h @@ -96,6 +96,15 @@ class ScoreKeeper : public Snapshot // for battle honor calculation. done once at the end of each online game Int getTotalUnitsBuilt( KindOfMaskType validMask, KindOfMaskType invalidMask ); + // TheSuperHackers @feature bill-rich 10/03/2026 Public accessors for game stats export. + typedef std::map ObjectCountMap; + Int getUnitsDestroyedByPlayer( Int idx ) const { return m_totalUnitsDestroyed[idx]; } + Int getBuildingsDestroyedByPlayer( Int idx ) const { return m_totalBuildingsDestroyed[idx]; } + const ObjectCountMap& getObjectsBuilt() const { return m_objectsBuilt; } + const ObjectCountMap* getObjectsDestroyedArray() const { return m_objectsDestroyed; } + const ObjectCountMap& getObjectsLost() const { return m_objectsLost; } + const ObjectCountMap& getObjectsCaptured() const { return m_objectsCaptured; } + protected: // snapshot methods @@ -119,7 +128,6 @@ class ScoreKeeper : public Snapshot Int m_myPlayerIdx; ///< We need to not score kills on ourselves... so we need to know who we are - typedef std::map ObjectCountMap; typedef ObjectCountMap::iterator ObjectCountMapIt; ObjectCountMap m_objectsBuilt; ///< How many and what kinds of objects did we build ObjectCountMap m_objectsDestroyed[MAX_PLAYER_COUNT]; ///< How many and what kinds and who's did we kill diff --git a/Generals/Code/GameEngine/Include/Common/StatsExporter.h b/Generals/Code/GameEngine/Include/Common/StatsExporter.h new file mode 100644 index 00000000000..2a20b66a796 --- /dev/null +++ b/Generals/Code/GameEngine/Include/Common/StatsExporter.h @@ -0,0 +1,35 @@ +/* +** Command & Conquer Generals(tm) +** Copyright 2026 TheSuperHackers +** +** This program 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 3 of the License, or +** (at your option) any later version. +** +** This program 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 this program. If not, see . +*/ + +// TheSuperHackers @feature bill-rich 10/03/2026 Game stats JSON exporter. + +#pragma once + +class AsciiString; + +/// Export game statistics as a JSON file alongside the replay file. +/// @param replayDir Directory containing replays (e.g. "[UserDataPath]/Replays/") +/// @param replayFileName Replay filename with extension (e.g. "LastReplay.rep") +void ExportGameStatsJSON(const AsciiString& replayDir, const AsciiString& replayFileName); + +/// Collect a time-series snapshot of all players' stats (called every game logic frame). +/// Snapshots are taken every 30 frames (~1 second) and stored in memory. +void StatsExporterCollectSnapshot(); + +/// Clear all stored time-series snapshots (called at game start/reset). +void StatsExporterClearSnapshots(); diff --git a/Generals/Code/GameEngine/Source/Common/CommandLine.cpp b/Generals/Code/GameEngine/Source/Common/CommandLine.cpp index 09c9258d8c7..46f13a536e9 100644 --- a/Generals/Code/GameEngine/Source/Common/CommandLine.cpp +++ b/Generals/Code/GameEngine/Source/Common/CommandLine.cpp @@ -417,6 +417,13 @@ Int parseHeadless(char *args[], int num) return 1; } +// TheSuperHackers @feature bill-rich 11/03/2026 +Int parseExportStats(char *args[], int num) +{ + TheWritableGlobalData->m_exportStats = TRUE; + return 1; +} + Int parseReplay(char *args[], int num) { if (num > 1) @@ -1148,6 +1155,10 @@ static CommandLineParam paramsForStartup[] = // (If you have 4 cores, call it with -jobs 4) // If you do not call this, all replays will be simulated in sequence in the same process. { "-jobs", parseJobs }, + + // TheSuperHackers @feature bill-rich 11/03/2026 + // Export game stats as JSON alongside replay file. + { "-exportStats", parseExportStats }, }; // These Params are parsed during Engine Init before INI data is loaded diff --git a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp index a3cf9c0dac0..3090e29cb44 100644 --- a/Generals/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/Generals/Code/GameEngine/Source/Common/GlobalData.cpp @@ -627,6 +627,7 @@ GlobalData::GlobalData() m_framesPerSecondLimit = 0; m_chipSetType = 0; m_headless = FALSE; + m_exportStats = FALSE; m_windowed = 0; m_xResolution = DEFAULT_DISPLAY_WIDTH; m_yResolution = DEFAULT_DISPLAY_HEIGHT; diff --git a/Generals/Code/GameEngine/Source/Common/Recorder.cpp b/Generals/Code/GameEngine/Source/Common/Recorder.cpp index 19b88a76b38..dc950f8537e 100644 --- a/Generals/Code/GameEngine/Source/Common/Recorder.cpp +++ b/Generals/Code/GameEngine/Source/Common/Recorder.cpp @@ -47,6 +47,8 @@ #include "Common/CRCDebug.h" #include "Common/OptionPreferences.h" #include "Common/version.h" +// TheSuperHackers @feature bill-rich 10/03/2026 Export game stats as JSON alongside replay file. +#include "Common/StatsExporter.h" constexpr const char s_genrep[] = "GENREP"; constexpr const UnsignedInt replayBufferBytes = 8192; @@ -731,6 +733,9 @@ void RecorderClass::stopRecording() { if (m_archiveReplays) archiveReplay(m_fileName); } + // TheSuperHackers @feature bill-rich 10/03/2026 Export game stats as JSON alongside replay file. + if (TheGlobalData->m_exportStats && !m_fileName.isEmpty()) + ExportGameStatsJSON(getReplayDir(), m_fileName); m_fileName.clear(); } diff --git a/Generals/Code/GameEngine/Source/Common/StatsExporter.cpp b/Generals/Code/GameEngine/Source/Common/StatsExporter.cpp new file mode 100644 index 00000000000..13cfc25dd7c --- /dev/null +++ b/Generals/Code/GameEngine/Source/Common/StatsExporter.cpp @@ -0,0 +1,690 @@ +/* +** Command & Conquer Generals(tm) +** Copyright 2026 TheSuperHackers +** +** This program 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 3 of the License, or +** (at your option) any later version. +** +** This program 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 this program. If not, see . +*/ + +// TheSuperHackers @feature bill-rich 10/03/2026 Game stats JSON exporter. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#include "Common/StatsExporter.h" +#include "Common/Player.h" +#include "Common/PlayerList.h" +#include "Common/PlayerTemplate.h" +#include "Common/GlobalData.h" +#include "Common/Energy.h" +#include "Common/ThingTemplate.h" +#include "Common/RandomValue.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Module/BattlePlanUpdate.h" + +#include + +//----------------------------------------------------------------------------- + +static void fprintJsonString(FILE *f, const char *s) +{ + fputc('"', f); + if (s != nullptr) + { + for (; *s != '\0'; ++s) + { + switch (*s) + { + case '"': fputs("\\\"", f); break; + case '\\': fputs("\\\\", f); break; + case '\n': fputs("\\n", f); break; + case '\r': fputs("\\r", f); break; + case '\t': fputs("\\t", f); break; + default: + if (static_cast(*s) < 0x20) + fprintf(f, "\\u%04x", static_cast(static_cast(*s))); + else + fputc(*s, f); + break; + } + } + } + fputc('"', f); +} + +//----------------------------------------------------------------------------- + +static void fprintJsonWideString(FILE *f, const WideChar *s) +{ + fputc('"', f); + if (s != nullptr) + { + for (; *s != L'\0'; ++s) + { + unsigned int c = static_cast(*s); + if (c == '"') + fputs("\\\"", f); + else if (c == '\\') + fputs("\\\\", f); + else if (c < 0x20) + fprintf(f, "\\u%04x", c); + else if (c < 0x80) + fputc(static_cast(c), f); + else + fprintf(f, "\\u%04x", c); + } + } + fputc('"', f); +} + +//----------------------------------------------------------------------------- + +static const char* gameModeToString(GameMode mode) +{ + switch (mode) + { + case GAME_SINGLE_PLAYER: return "SinglePlayer"; + case GAME_LAN: return "LAN"; + case GAME_SKIRMISH: return "Skirmish"; + case GAME_REPLAY: return "Replay"; + case GAME_SHELL: return "Shell"; + case GAME_INTERNET: return "Internet"; + case GAME_NONE: return "None"; + default: return "Unknown"; + } +} + +//----------------------------------------------------------------------------- + +static void writeObjectCountMap(FILE *f, const ScoreKeeper::ObjectCountMap &map, const char *indent) +{ + fprintf(f, "{\n"); + Bool first = TRUE; + for (ScoreKeeper::ObjectCountMap::const_iterator it = map.begin(); it != map.end(); ++it) + { + if (!first) fprintf(f, ",\n"); + first = FALSE; + const ThingTemplate *tmpl = it->first; + fprintf(f, "%s ", indent); + if (tmpl != nullptr) + fprintJsonString(f, tmpl->getName().str()); + else + fprintJsonString(f, "unknown"); + fprintf(f, ": %d", it->second); + } + if (!map.empty()) fprintf(f, "\n%s", indent); + fprintf(f, "}"); +} + +//----------------------------------------------------------------------------- + +static Bool isGamePlayer(Player *player) +{ + if (player == nullptr) return FALSE; + const PlayerTemplate *pt = player->getPlayerTemplate(); + if (pt == nullptr) return FALSE; + const char *name = pt->getName().str(); + if (name == nullptr || name[0] == '\0') return FALSE; + if (strcmp(name, "FactionObserver") == 0) return FALSE; + if (strcmp(name, "FactionCivilian") == 0) return FALSE; + return TRUE; +} + +//----------------------------------------------------------------------------- + +struct PlayerSnapshotData +{ + Int playerIndex; + UnsignedInt money; + Int moneyEarned; + Int moneySpent; + Int energyProduction; + Int energyConsumption; + Int unitsBuilt; + Int unitsLost; + Int buildingsBuilt; + Int buildingsLost; + Int techBuildingsCaptured; + Int factionBuildingsCaptured; + Int rankLevel; + Int skillPoints; + Int sciencePurchasePoints; + Int score; + Int unitsKilled[MAX_PLAYER_COUNT]; + Int buildingsKilled[MAX_PLAYER_COUNT]; +}; + +struct FrameSnapshotData +{ + UnsignedInt frame; + Int playerCount; + PlayerSnapshotData players[MAX_PLAYER_COUNT]; +}; + +static std::vector s_snapshots; +static UnsignedInt s_lastSnapshotFrame = 0; +static Int s_gamePlayerCount = 0; +static Int s_originalToNewIndex[MAX_PLAYER_COUNT]; +static Bool s_mappingInitialized = FALSE; + +//----------------------------------------------------------------------------- + +static void initPlayerMapping() +{ + if (s_mappingInitialized) + return; + + s_gamePlayerCount = 0; + memset(s_originalToNewIndex, 0, sizeof(s_originalToNewIndex)); + + const Int totalPlayers = ThePlayerList->getPlayerCount(); + Int i; + for (i = 0; i < totalPlayers && i < MAX_PLAYER_COUNT; ++i) + { + Player *player = ThePlayerList->getNthPlayer(i); + if (isGamePlayer(player)) + { + ++s_gamePlayerCount; + s_originalToNewIndex[i] = s_gamePlayerCount; + } + } + s_mappingInitialized = TRUE; +} + +//----------------------------------------------------------------------------- + +void StatsExporterCollectSnapshot() +{ + if (ThePlayerList == nullptr || TheGameLogic == nullptr) + return; + + UnsignedInt currentFrame = TheGameLogic->getFrame(); + if (!s_snapshots.empty() && (currentFrame - s_lastSnapshotFrame) < 30) + return; + + s_lastSnapshotFrame = currentFrame; + + initPlayerMapping(); + + const Int totalPlayers = ThePlayerList->getPlayerCount(); + + FrameSnapshotData snap; + memset(&snap, 0, sizeof(snap)); + snap.frame = currentFrame; + snap.playerCount = s_gamePlayerCount; + + Int gameIdx = 0; + Int i; + for (i = 0; i < totalPlayers && i < MAX_PLAYER_COUNT; ++i) + { + if (s_originalToNewIndex[i] == 0) + continue; + + Player *player = ThePlayerList->getNthPlayer(i); + if (player == nullptr) + continue; + + PlayerSnapshotData &pd = snap.players[gameIdx]; + ScoreKeeper *sk = player->getScoreKeeper(); + const Energy *energy = player->getEnergy(); + + pd.playerIndex = s_originalToNewIndex[i]; + pd.money = player->getMoney()->countMoney(); + pd.moneyEarned = sk->getTotalMoneyEarned(); + pd.moneySpent = sk->getTotalMoneySpent(); + pd.energyProduction = energy->getProduction(); + pd.energyConsumption = energy->getConsumption(); + pd.unitsBuilt = sk->getTotalUnitsBuilt(); + pd.unitsLost = sk->getTotalUnitsLost(); + pd.buildingsBuilt = sk->getTotalBuildingsBuilt(); + pd.buildingsLost = sk->getTotalBuildingsLost(); + pd.techBuildingsCaptured = sk->getTotalTechBuildingsCaptured(); + pd.factionBuildingsCaptured = sk->getTotalFactionBuildingsCaptured(); + pd.rankLevel = player->getRankLevel(); + pd.skillPoints = player->getSkillPoints(); + pd.sciencePurchasePoints = player->getSciencePurchasePoints(); + pd.score = sk->calculateScore(); + + Int j; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + pd.unitsKilled[j] = sk->getUnitsDestroyedByPlayer(j); + pd.buildingsKilled[j] = sk->getBuildingsDestroyedByPlayer(j); + } + + ++gameIdx; + } + + s_snapshots.push_back(snap); +} + +//----------------------------------------------------------------------------- + +void StatsExporterClearSnapshots() +{ + s_snapshots.clear(); + s_lastSnapshotFrame = 0; + s_gamePlayerCount = 0; + s_mappingInitialized = FALSE; + memset(s_originalToNewIndex, 0, sizeof(s_originalToNewIndex)); +} + +//----------------------------------------------------------------------------- + +static void writeTimeSeries(FILE *f) +{ + fprintf(f, " \"timeSeries\": {\n"); + + // Frames array + fprintf(f, " \"frames\": ["); + size_t s; + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%u", s_snapshots[s].frame); + } + fprintf(f, "],\n"); + + // Players array + fprintf(f, " \"players\": [\n"); + + Int pi; + for (pi = 0; pi < s_gamePlayerCount; ++pi) + { + if (pi > 0) fprintf(f, ",\n"); + fprintf(f, " {\n"); + + fprintf(f, " \"index\": %d,\n", pi + 1); + + // money (UnsignedInt) + fprintf(f, " \"money\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%u", s_snapshots[s].players[pi].money); + } + fprintf(f, "],\n"); + + // moneyEarned + fprintf(f, " \"moneyEarned\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].moneyEarned); + } + fprintf(f, "],\n"); + + // moneySpent + fprintf(f, " \"moneySpent\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].moneySpent); + } + fprintf(f, "],\n"); + + // energyProduction + fprintf(f, " \"energyProduction\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].energyProduction); + } + fprintf(f, "],\n"); + + // energyConsumption + fprintf(f, " \"energyConsumption\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].energyConsumption); + } + fprintf(f, "],\n"); + + // unitsBuilt + fprintf(f, " \"unitsBuilt\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].unitsBuilt); + } + fprintf(f, "],\n"); + + // unitsLost + fprintf(f, " \"unitsLost\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].unitsLost); + } + fprintf(f, "],\n"); + + // buildingsBuilt + fprintf(f, " \"buildingsBuilt\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].buildingsBuilt); + } + fprintf(f, "],\n"); + + // buildingsLost + fprintf(f, " \"buildingsLost\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].buildingsLost); + } + fprintf(f, "],\n"); + + // techBuildingsCaptured + fprintf(f, " \"techBuildingsCaptured\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].techBuildingsCaptured); + } + fprintf(f, "],\n"); + + // factionBuildingsCaptured + fprintf(f, " \"factionBuildingsCaptured\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].factionBuildingsCaptured); + } + fprintf(f, "],\n"); + + // rankLevel + fprintf(f, " \"rankLevel\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].rankLevel); + } + fprintf(f, "],\n"); + + // skillPoints + fprintf(f, " \"skillPoints\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].skillPoints); + } + fprintf(f, "],\n"); + + // sciencePurchasePoints + fprintf(f, " \"sciencePurchasePoints\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].sciencePurchasePoints); + } + fprintf(f, "],\n"); + + // score + fprintf(f, " \"score\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].score); + } + fprintf(f, "],\n"); + + // unitsKilled - sparse per-opponent + fprintf(f, " \"unitsKilled\": {"); + { + Bool firstOpp = TRUE; + Int j; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + Int remapped = s_originalToNewIndex[j]; + if (remapped == 0) continue; + + // Check if any snapshot has non-zero value + Bool hasNonZero = FALSE; + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s_snapshots[s].players[pi].unitsKilled[j] != 0) + { + hasNonZero = TRUE; + break; + } + } + if (!hasNonZero) continue; + + if (!firstOpp) fprintf(f, ","); + firstOpp = FALSE; + fprintf(f, "\n \"%d\": [", remapped); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].unitsKilled[j]); + } + fprintf(f, "]"); + } + if (!firstOpp) fprintf(f, "\n "); + } + fprintf(f, "},\n"); + + // buildingsKilled - sparse per-opponent + fprintf(f, " \"buildingsKilled\": {"); + { + Bool firstOpp = TRUE; + Int j; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + Int remapped = s_originalToNewIndex[j]; + if (remapped == 0) continue; + + Bool hasNonZero = FALSE; + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s_snapshots[s].players[pi].buildingsKilled[j] != 0) + { + hasNonZero = TRUE; + break; + } + } + if (!hasNonZero) continue; + + if (!firstOpp) fprintf(f, ","); + firstOpp = FALSE; + fprintf(f, "\n \"%d\": [", remapped); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].buildingsKilled[j]); + } + fprintf(f, "]"); + } + if (!firstOpp) fprintf(f, "\n "); + } + fprintf(f, "}\n"); + + fprintf(f, " }"); + } + + fprintf(f, "\n ]\n"); + fprintf(f, " }\n"); +} + +//----------------------------------------------------------------------------- + +void ExportGameStatsJSON(const AsciiString& replayDir, const AsciiString& replayFileName) +{ + if (ThePlayerList == nullptr || TheGameLogic == nullptr || TheGlobalData == nullptr) + return; + + // Build stats file path: replace .rep extension with .gamestats.json + char baseName[_MAX_PATH + 1]; + strlcpy(baseName, replayFileName.str(), ARRAY_SIZE(baseName)); + char *dot = strrchr(baseName, '.'); + if (dot != nullptr) *dot = '\0'; + + AsciiString statsPath; + statsPath.format("%s%s.gamestats.json", replayDir.str(), baseName); + + FILE *f = fopen(statsPath.str(), "w"); + if (f == nullptr) + return; + + initPlayerMapping(); + + const Int playerCount = ThePlayerList->getPlayerCount(); + + fprintf(f, "{\n"); + fprintf(f, " \"version\": 3,\n"); + + // Game info + fprintf(f, " \"game\": {\n"); + fprintf(f, " \"map\": "); fprintJsonString(f, TheGlobalData->m_mapName.str()); fprintf(f, ",\n"); + fprintf(f, " \"mode\": \"%s\",\n", gameModeToString(TheGameLogic->getGameMode())); + fprintf(f, " \"frameCount\": %u,\n", TheGameLogic->getFrame()); + fprintf(f, " \"seed\": %u,\n", GetGameLogicRandomSeed()); + fprintf(f, " \"replayFile\": "); fprintJsonString(f, replayFileName.str()); fprintf(f, ",\n"); + fprintf(f, " \"playerCount\": %d\n", s_gamePlayerCount); + fprintf(f, " },\n"); + + // Players array + fprintf(f, " \"players\": [\n"); + Bool firstPlayer = TRUE; + Int i; + for (i = 0; i < playerCount; ++i) + { + Player *player = ThePlayerList->getNthPlayer(i); + if (player == nullptr || !isGamePlayer(player)) + continue; + + if (!firstPlayer) fprintf(f, ",\n"); + firstPlayer = FALSE; + + ScoreKeeper *sk = player->getScoreKeeper(); + const Energy *energy = player->getEnergy(); + const PlayerTemplate *pt = player->getPlayerTemplate(); + + fprintf(f, " {\n"); + + // Basic info + fprintf(f, " \"index\": %d,\n", s_originalToNewIndex[i]); + fprintf(f, " \"displayName\": "); fprintJsonWideString(f, player->getPlayerDisplayName().str()); fprintf(f, ",\n"); + if (pt != nullptr) + { + fprintf(f, " \"faction\": "); fprintJsonString(f, pt->getName().str()); fprintf(f, ",\n"); + } + fprintf(f, " \"side\": "); fprintJsonString(f, player->getSide().str()); fprintf(f, ",\n"); + fprintf(f, " \"type\": \"%s\",\n", player->getPlayerType() == PLAYER_HUMAN ? "Human" : "Computer"); + fprintf(f, " \"color\": \"#%06X\",\n", static_cast(player->getPlayerColor()) & 0x00FFFFFFu); + fprintf(f, " \"isDead\": %s,\n", player->isPlayerDead() ? "true" : "false"); + + // Economy + fprintf(f, " \"money\": %u,\n", player->getMoney()->countMoney()); + fprintf(f, " \"moneyEarned\": %d,\n", sk->getTotalMoneyEarned()); + fprintf(f, " \"moneySpent\": %d,\n", sk->getTotalMoneySpent()); + + // Energy + fprintf(f, " \"energyProduction\": %d,\n", energy->getProduction()); + fprintf(f, " \"energyConsumption\": %d,\n", energy->getConsumption()); + + // Rank + fprintf(f, " \"rankLevel\": %d,\n", player->getRankLevel()); + fprintf(f, " \"skillPoints\": %d,\n", player->getSkillPoints()); + fprintf(f, " \"sciencePurchasePoints\": %d,\n", player->getSciencePurchasePoints()); + + // Units/Buildings summary + fprintf(f, " \"unitsBuilt\": %d,\n", sk->getTotalUnitsBuilt()); + fprintf(f, " \"unitsLost\": %d,\n", sk->getTotalUnitsLost()); + fprintf(f, " \"buildingsBuilt\": %d,\n", sk->getTotalBuildingsBuilt()); + fprintf(f, " \"buildingsLost\": %d,\n", sk->getTotalBuildingsLost()); + fprintf(f, " \"techBuildingsCaptured\": %d,\n", sk->getTotalTechBuildingsCaptured()); + fprintf(f, " \"factionBuildingsCaptured\": %d,\n", sk->getTotalFactionBuildingsCaptured()); + + // Radar & Battle plans + fprintf(f, " \"hasRadar\": %s,\n", player->hasRadar() ? "true" : "false"); + fprintf(f, " \"battlePlans\": {\n"); + fprintf(f, " \"bombardment\": %d,\n", player->getBattlePlansActiveSpecific(PLANSTATUS_BOMBARDMENT)); + fprintf(f, " \"holdTheLine\": %d,\n", player->getBattlePlansActiveSpecific(PLANSTATUS_HOLDTHELINE)); + fprintf(f, " \"searchAndDestroy\": %d\n", player->getBattlePlansActiveSpecific(PLANSTATUS_SEARCHANDDESTROY)); + fprintf(f, " },\n"); + + // Score + fprintf(f, " \"score\": %d,\n", sk->calculateScore()); + + // Per-player destroy counts (sparse objects with remapped keys) + Int j; + fprintf(f, " \"unitsDestroyedPerPlayer\": {"); + { + Bool firstKill = TRUE; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + if (s_originalToNewIndex[j] == 0) continue; + Int count = sk->getUnitsDestroyedByPlayer(j); + if (count == 0) continue; + if (!firstKill) fprintf(f, ","); + firstKill = FALSE; + fprintf(f, " \"%d\": %d", s_originalToNewIndex[j], count); + } + if (!firstKill) fprintf(f, " "); + } + fprintf(f, "},\n"); + + fprintf(f, " \"buildingsDestroyedPerPlayer\": {"); + { + Bool firstKill = TRUE; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + if (s_originalToNewIndex[j] == 0) continue; + Int count = sk->getBuildingsDestroyedByPlayer(j); + if (count == 0) continue; + if (!firstKill) fprintf(f, ","); + firstKill = FALSE; + fprintf(f, " \"%d\": %d", s_originalToNewIndex[j], count); + } + if (!firstKill) fprintf(f, " "); + } + fprintf(f, "},\n"); + + // Per-object-type maps + fprintf(f, " \"objectsBuilt\": "); writeObjectCountMap(f, sk->getObjectsBuilt(), " "); fprintf(f, ",\n"); + fprintf(f, " \"objectsLost\": "); writeObjectCountMap(f, sk->getObjectsLost(), " "); fprintf(f, ",\n"); + fprintf(f, " \"objectsCaptured\": "); writeObjectCountMap(f, sk->getObjectsCaptured(), " "); fprintf(f, ",\n"); + + // Per-player per-object-type destroyed (sparse object with remapped keys) + fprintf(f, " \"objectsDestroyedPerPlayer\": {"); + { + const ScoreKeeper::ObjectCountMap *destroyedArr = sk->getObjectsDestroyedArray(); + Bool firstOpp = TRUE; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + if (s_originalToNewIndex[j] == 0) continue; + if (destroyedArr[j].empty()) continue; + if (!firstOpp) fprintf(f, ","); + firstOpp = FALSE; + fprintf(f, "\n \"%d\": ", s_originalToNewIndex[j]); + writeObjectCountMap(f, destroyedArr[j], " "); + } + if (!firstOpp) fprintf(f, "\n "); + } + fprintf(f, "}\n"); + + fprintf(f, " }"); + } + fprintf(f, "\n ],\n"); + + writeTimeSeries(f); + + fprintf(f, "}\n"); + + fclose(f); + + StatsExporterClearSnapshots(); +} diff --git a/Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp b/Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp index d14956a9903..d3a24097dcb 100644 --- a/Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp +++ b/Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp @@ -51,6 +51,7 @@ #include "Common/RandomValue.h" #include "Common/Recorder.h" #include "Common/StatsCollector.h" +#include "Common/StatsExporter.h" #include "Common/ThingFactory.h" #include "Common/Team.h" #include "Common/ThingTemplate.h" @@ -1954,6 +1955,8 @@ void GameLogic::startNewGame( Bool saveGame ) TheStatsCollector = NEW StatsCollector; TheStatsCollector->reset(); } + if (TheGlobalData->m_exportStats) + StatsExporterClearSnapshots(); /// ShowControlBar(FALSE); @@ -3170,6 +3173,9 @@ void GameLogic::update() TheStatsCollector->update(); } + if (TheGlobalData->m_exportStats) + StatsExporterCollectSnapshot(); + // Update the Recorder { TheRecorder->UPDATE(); diff --git a/GeneralsMD/Code/GameEngine/CMakeLists.txt b/GeneralsMD/Code/GameEngine/CMakeLists.txt index e594d69ac80..14d8d75a68a 100644 --- a/GeneralsMD/Code/GameEngine/CMakeLists.txt +++ b/GeneralsMD/Code/GameEngine/CMakeLists.txt @@ -110,6 +110,7 @@ set(GAMEENGINE_SRC Include/Common/StackDump.h Include/Common/StateMachine.h Include/Common/StatsCollector.h + Include/Common/StatsExporter.h # Include/Common/STLTypedefs.h # Include/Common/StreamingArchiveFile.h # Include/Common/SubsystemInterface.h @@ -627,6 +628,7 @@ set(GAMEENGINE_SRC Source/Common/SkirmishBattleHonors.cpp Source/Common/StateMachine.cpp Source/Common/StatsCollector.cpp + Source/Common/StatsExporter.cpp # Source/Common/System/ArchiveFile.cpp # Source/Common/System/ArchiveFileSystem.cpp # Source/Common/System/AsciiString.cpp diff --git a/GeneralsMD/Code/GameEngine/Include/Common/AcademyStats.h b/GeneralsMD/Code/GameEngine/Include/Common/AcademyStats.h index 23c8e3357d7..63ad88e3bae 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/AcademyStats.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/AcademyStats.h @@ -126,6 +126,26 @@ class AcademyStats : public Snapshot Bool calculateAcademyAdvice( AcademyAdviceInfo *info ); + // TheSuperHackers @feature bill-rich 10/03/2026 Public accessors for game stats export. + UnsignedInt getSupplyCentersBuilt() const { return m_supplyCentersBuilt; } + UnsignedInt getPeonsBuilt() const { return m_peonsBuilt; } + UnsignedInt getStructuresCaptured() const { return m_structuresCaptured; } + UnsignedInt getGeneralsPointsSpent() const { return m_generalsPointsSpent; } + UnsignedInt getSpecialPowersUsed() const { return m_specialPowersUsed; } + UnsignedInt getStructuresGarrisoned() const { return m_structuresGarrisoned; } + UnsignedInt getUpgradesPurchased() const { return m_upgradesPurchased; } + UnsignedInt getGatherersBuilt() const { return m_gatherersBuilt; } + UnsignedInt getHeroesBuilt() const { return m_heroesBuilt; } + UnsignedInt getControlGroupsUsed() const { return m_controlGroupsUsed; } + UnsignedInt getSecondaryIncomeUnitsBuilt() const { return m_secondaryIncomeUnitsBuilt; } + UnsignedInt getClearedGarrisonedBuildings() const { return m_clearedGarrisonedBuildings; } + UnsignedInt getSalvageCollected() const { return m_salvageCollected; } + UnsignedInt getGuardAbilityUsedCount() const { return m_guardAbilityUsedCount; } + UnsignedInt getDoubleClickAttackMoveOrdersGiven() const { return m_doubleClickAttackMoveOrdersGiven; } + UnsignedInt getMinesCleared() const { return m_minesCleared; } + UnsignedInt getVehiclesDisguised() const { return m_vehiclesDisguised; } + UnsignedInt getFirestormsCreated() const { return m_firestormsCreated; } + protected: // snapshot methods virtual void crc( Xfer *xfer ); diff --git a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h index e826ca7c357..a56d10147ad 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/GlobalData.h @@ -121,6 +121,10 @@ class GlobalData : public SubsystemInterface // Run game without graphics, input or audio. Bool m_headless; + // TheSuperHackers @feature bill-rich 11/03/2026 + // Export game stats as JSON alongside replay file. + Bool m_exportStats; + Bool m_windowed; Int m_xResolution; Int m_yResolution; diff --git a/GeneralsMD/Code/GameEngine/Include/Common/ScoreKeeper.h b/GeneralsMD/Code/GameEngine/Include/Common/ScoreKeeper.h index 6df58b3c5c4..b6d66294e15 100644 --- a/GeneralsMD/Code/GameEngine/Include/Common/ScoreKeeper.h +++ b/GeneralsMD/Code/GameEngine/Include/Common/ScoreKeeper.h @@ -96,6 +96,15 @@ class ScoreKeeper : public Snapshot // for battle honor calculation. done once at the end of each online game Int getTotalUnitsBuilt( KindOfMaskType validMask, KindOfMaskType invalidMask ); + // TheSuperHackers @feature bill-rich 10/03/2026 Public accessors for game stats export. + typedef std::map ObjectCountMap; + Int getUnitsDestroyedByPlayer( Int idx ) const { return m_totalUnitsDestroyed[idx]; } + Int getBuildingsDestroyedByPlayer( Int idx ) const { return m_totalBuildingsDestroyed[idx]; } + const ObjectCountMap& getObjectsBuilt() const { return m_objectsBuilt; } + const ObjectCountMap* getObjectsDestroyedArray() const { return m_objectsDestroyed; } + const ObjectCountMap& getObjectsLost() const { return m_objectsLost; } + const ObjectCountMap& getObjectsCaptured() const { return m_objectsCaptured; } + protected: // snapshot methods @@ -119,7 +128,6 @@ class ScoreKeeper : public Snapshot Int m_myPlayerIdx; ///< We need to not score kills on ourselves... so we need to know who we are - typedef std::map ObjectCountMap; typedef ObjectCountMap::iterator ObjectCountMapIt; ObjectCountMap m_objectsBuilt; ///< How many and what kinds of objects did we build ObjectCountMap m_objectsDestroyed[MAX_PLAYER_COUNT]; ///< How many and what kinds and who's did we kill diff --git a/GeneralsMD/Code/GameEngine/Include/Common/StatsExporter.h b/GeneralsMD/Code/GameEngine/Include/Common/StatsExporter.h new file mode 100644 index 00000000000..f627f25da76 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Include/Common/StatsExporter.h @@ -0,0 +1,35 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2026 TheSuperHackers +** +** This program 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 3 of the License, or +** (at your option) any later version. +** +** This program 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 this program. If not, see . +*/ + +// TheSuperHackers @feature bill-rich 10/03/2026 Game stats JSON exporter. + +#pragma once + +class AsciiString; + +/// Export game statistics as a JSON file alongside the replay file. +/// @param replayDir Directory containing replays (e.g. "[UserDataPath]/Replays/") +/// @param replayFileName Replay filename with extension (e.g. "LastReplay.rep") +void ExportGameStatsJSON(const AsciiString& replayDir, const AsciiString& replayFileName); + +/// Collect a time-series snapshot of all players' stats (called every game logic frame). +/// Snapshots are taken every 30 frames (~1 second) and stored in memory. +void StatsExporterCollectSnapshot(); + +/// Clear all stored time-series snapshots (called at game start/reset). +void StatsExporterClearSnapshots(); diff --git a/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp b/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp index 09c9258d8c7..46f13a536e9 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/CommandLine.cpp @@ -417,6 +417,13 @@ Int parseHeadless(char *args[], int num) return 1; } +// TheSuperHackers @feature bill-rich 11/03/2026 +Int parseExportStats(char *args[], int num) +{ + TheWritableGlobalData->m_exportStats = TRUE; + return 1; +} + Int parseReplay(char *args[], int num) { if (num > 1) @@ -1148,6 +1155,10 @@ static CommandLineParam paramsForStartup[] = // (If you have 4 cores, call it with -jobs 4) // If you do not call this, all replays will be simulated in sequence in the same process. { "-jobs", parseJobs }, + + // TheSuperHackers @feature bill-rich 11/03/2026 + // Export game stats as JSON alongside replay file. + { "-exportStats", parseExportStats }, }; // These Params are parsed during Engine Init before INI data is loaded diff --git a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp index 8527e8a70cd..5bb0f0e9bfc 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/GlobalData.cpp @@ -631,6 +631,7 @@ GlobalData::GlobalData() m_framesPerSecondLimit = 0; m_chipSetType = 0; m_headless = FALSE; + m_exportStats = FALSE; m_windowed = 0; m_xResolution = DEFAULT_DISPLAY_WIDTH; m_yResolution = DEFAULT_DISPLAY_HEIGHT; diff --git a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp index 8150064a028..dbe9c0f9199 100644 --- a/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp +++ b/GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp @@ -47,6 +47,8 @@ #include "Common/CRCDebug.h" #include "Common/OptionPreferences.h" #include "Common/version.h" +// TheSuperHackers @feature bill-rich 10/03/2026 Export game stats as JSON alongside replay file. +#include "Common/StatsExporter.h" constexpr const char s_genrep[] = "GENREP"; constexpr const UnsignedInt replayBufferBytes = 8192; @@ -733,6 +735,9 @@ void RecorderClass::stopRecording() { if (m_archiveReplays) archiveReplay(m_fileName); } + // TheSuperHackers @feature bill-rich 10/03/2026 Export game stats as JSON alongside replay file. + if (TheGlobalData->m_exportStats && !m_fileName.isEmpty()) + ExportGameStatsJSON(getReplayDir(), m_fileName); m_fileName.clear(); } diff --git a/GeneralsMD/Code/GameEngine/Source/Common/StatsExporter.cpp b/GeneralsMD/Code/GameEngine/Source/Common/StatsExporter.cpp new file mode 100644 index 00000000000..1cf7f5bd2d6 --- /dev/null +++ b/GeneralsMD/Code/GameEngine/Source/Common/StatsExporter.cpp @@ -0,0 +1,714 @@ +/* +** Command & Conquer Generals Zero Hour(tm) +** Copyright 2026 TheSuperHackers +** +** This program 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 3 of the License, or +** (at your option) any later version. +** +** This program 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 this program. If not, see . +*/ + +// TheSuperHackers @feature bill-rich 10/03/2026 Game stats JSON exporter. + +#include "PreRTS.h" // This must go first in EVERY cpp file in the GameEngine + +#include "Common/StatsExporter.h" +#include "Common/Player.h" +#include "Common/PlayerList.h" +#include "Common/PlayerTemplate.h" +#include "Common/GlobalData.h" +#include "Common/Energy.h" +#include "Common/ThingTemplate.h" +#include "Common/RandomValue.h" +#include "GameLogic/GameLogic.h" +#include "GameLogic/Module/BattlePlanUpdate.h" + +#include + +//----------------------------------------------------------------------------- + +static void fprintJsonString(FILE *f, const char *s) +{ + fputc('"', f); + if (s != nullptr) + { + for (; *s != '\0'; ++s) + { + switch (*s) + { + case '"': fputs("\\\"", f); break; + case '\\': fputs("\\\\", f); break; + case '\n': fputs("\\n", f); break; + case '\r': fputs("\\r", f); break; + case '\t': fputs("\\t", f); break; + default: + if (static_cast(*s) < 0x20) + fprintf(f, "\\u%04x", static_cast(static_cast(*s))); + else + fputc(*s, f); + break; + } + } + } + fputc('"', f); +} + +//----------------------------------------------------------------------------- + +static void fprintJsonWideString(FILE *f, const WideChar *s) +{ + fputc('"', f); + if (s != nullptr) + { + for (; *s != L'\0'; ++s) + { + unsigned int c = static_cast(*s); + if (c == '"') + fputs("\\\"", f); + else if (c == '\\') + fputs("\\\\", f); + else if (c < 0x20) + fprintf(f, "\\u%04x", c); + else if (c < 0x80) + fputc(static_cast(c), f); + else + fprintf(f, "\\u%04x", c); + } + } + fputc('"', f); +} + +//----------------------------------------------------------------------------- + +static const char* gameModeToString(GameMode mode) +{ + switch (mode) + { + case GAME_SINGLE_PLAYER: return "SinglePlayer"; + case GAME_LAN: return "LAN"; + case GAME_SKIRMISH: return "Skirmish"; + case GAME_REPLAY: return "Replay"; + case GAME_SHELL: return "Shell"; + case GAME_INTERNET: return "Internet"; + case GAME_NONE: return "None"; + default: return "Unknown"; + } +} + +//----------------------------------------------------------------------------- + +static void writeObjectCountMap(FILE *f, const ScoreKeeper::ObjectCountMap &map, const char *indent) +{ + fprintf(f, "{\n"); + Bool first = TRUE; + for (ScoreKeeper::ObjectCountMap::const_iterator it = map.begin(); it != map.end(); ++it) + { + if (!first) fprintf(f, ",\n"); + first = FALSE; + const ThingTemplate *tmpl = it->first; + fprintf(f, "%s ", indent); + if (tmpl != nullptr) + fprintJsonString(f, tmpl->getName().str()); + else + fprintJsonString(f, "unknown"); + fprintf(f, ": %d", it->second); + } + if (!map.empty()) fprintf(f, "\n%s", indent); + fprintf(f, "}"); +} + +//----------------------------------------------------------------------------- + +static Bool isGamePlayer(Player *player) +{ + if (player == nullptr) return FALSE; + const PlayerTemplate *pt = player->getPlayerTemplate(); + if (pt == nullptr) return FALSE; + const char *name = pt->getName().str(); + if (name == nullptr || name[0] == '\0') return FALSE; + if (strcmp(name, "FactionObserver") == 0) return FALSE; + if (strcmp(name, "FactionCivilian") == 0) return FALSE; + return TRUE; +} + +//----------------------------------------------------------------------------- + +struct PlayerSnapshotData +{ + Int playerIndex; + UnsignedInt money; + Int moneyEarned; + Int moneySpent; + Int energyProduction; + Int energyConsumption; + Int unitsBuilt; + Int unitsLost; + Int buildingsBuilt; + Int buildingsLost; + Int techBuildingsCaptured; + Int factionBuildingsCaptured; + Int rankLevel; + Int skillPoints; + Int sciencePurchasePoints; + Int score; + Int unitsKilled[MAX_PLAYER_COUNT]; + Int buildingsKilled[MAX_PLAYER_COUNT]; +}; + +struct FrameSnapshotData +{ + UnsignedInt frame; + Int playerCount; + PlayerSnapshotData players[MAX_PLAYER_COUNT]; +}; + +static std::vector s_snapshots; +static UnsignedInt s_lastSnapshotFrame = 0; +static Int s_gamePlayerCount = 0; +static Int s_originalToNewIndex[MAX_PLAYER_COUNT]; +static Bool s_mappingInitialized = FALSE; + +//----------------------------------------------------------------------------- + +static void initPlayerMapping() +{ + if (s_mappingInitialized) + return; + + s_gamePlayerCount = 0; + memset(s_originalToNewIndex, 0, sizeof(s_originalToNewIndex)); + + const Int totalPlayers = ThePlayerList->getPlayerCount(); + Int i; + for (i = 0; i < totalPlayers && i < MAX_PLAYER_COUNT; ++i) + { + Player *player = ThePlayerList->getNthPlayer(i); + if (isGamePlayer(player)) + { + ++s_gamePlayerCount; + s_originalToNewIndex[i] = s_gamePlayerCount; + } + } + s_mappingInitialized = TRUE; +} + +//----------------------------------------------------------------------------- + +void StatsExporterCollectSnapshot() +{ + if (ThePlayerList == nullptr || TheGameLogic == nullptr) + return; + + UnsignedInt currentFrame = TheGameLogic->getFrame(); + if (!s_snapshots.empty() && (currentFrame - s_lastSnapshotFrame) < 30) + return; + + s_lastSnapshotFrame = currentFrame; + + initPlayerMapping(); + + const Int totalPlayers = ThePlayerList->getPlayerCount(); + + FrameSnapshotData snap; + memset(&snap, 0, sizeof(snap)); + snap.frame = currentFrame; + snap.playerCount = s_gamePlayerCount; + + Int gameIdx = 0; + Int i; + for (i = 0; i < totalPlayers && i < MAX_PLAYER_COUNT; ++i) + { + if (s_originalToNewIndex[i] == 0) + continue; + + Player *player = ThePlayerList->getNthPlayer(i); + if (player == nullptr) + continue; + + PlayerSnapshotData &pd = snap.players[gameIdx]; + ScoreKeeper *sk = player->getScoreKeeper(); + const Energy *energy = player->getEnergy(); + + pd.playerIndex = s_originalToNewIndex[i]; + pd.money = player->getMoney()->countMoney(); + pd.moneyEarned = sk->getTotalMoneyEarned(); + pd.moneySpent = sk->getTotalMoneySpent(); + pd.energyProduction = energy->getProduction(); + pd.energyConsumption = energy->getConsumption(); + pd.unitsBuilt = sk->getTotalUnitsBuilt(); + pd.unitsLost = sk->getTotalUnitsLost(); + pd.buildingsBuilt = sk->getTotalBuildingsBuilt(); + pd.buildingsLost = sk->getTotalBuildingsLost(); + pd.techBuildingsCaptured = sk->getTotalTechBuildingsCaptured(); + pd.factionBuildingsCaptured = sk->getTotalFactionBuildingsCaptured(); + pd.rankLevel = player->getRankLevel(); + pd.skillPoints = player->getSkillPoints(); + pd.sciencePurchasePoints = player->getSciencePurchasePoints(); + pd.score = sk->calculateScore(); + + Int j; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + pd.unitsKilled[j] = sk->getUnitsDestroyedByPlayer(j); + pd.buildingsKilled[j] = sk->getBuildingsDestroyedByPlayer(j); + } + + ++gameIdx; + } + + s_snapshots.push_back(snap); +} + +//----------------------------------------------------------------------------- + +void StatsExporterClearSnapshots() +{ + s_snapshots.clear(); + s_lastSnapshotFrame = 0; + s_gamePlayerCount = 0; + s_mappingInitialized = FALSE; + memset(s_originalToNewIndex, 0, sizeof(s_originalToNewIndex)); +} + +//----------------------------------------------------------------------------- + +static void writeTimeSeries(FILE *f) +{ + fprintf(f, " \"timeSeries\": {\n"); + + // Frames array + fprintf(f, " \"frames\": ["); + size_t s; + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%u", s_snapshots[s].frame); + } + fprintf(f, "],\n"); + + // Players array + fprintf(f, " \"players\": [\n"); + + Int pi; + for (pi = 0; pi < s_gamePlayerCount; ++pi) + { + if (pi > 0) fprintf(f, ",\n"); + fprintf(f, " {\n"); + + fprintf(f, " \"index\": %d,\n", pi + 1); + + // money (UnsignedInt) + fprintf(f, " \"money\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%u", s_snapshots[s].players[pi].money); + } + fprintf(f, "],\n"); + + // moneyEarned + fprintf(f, " \"moneyEarned\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].moneyEarned); + } + fprintf(f, "],\n"); + + // moneySpent + fprintf(f, " \"moneySpent\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].moneySpent); + } + fprintf(f, "],\n"); + + // energyProduction + fprintf(f, " \"energyProduction\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].energyProduction); + } + fprintf(f, "],\n"); + + // energyConsumption + fprintf(f, " \"energyConsumption\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].energyConsumption); + } + fprintf(f, "],\n"); + + // unitsBuilt + fprintf(f, " \"unitsBuilt\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].unitsBuilt); + } + fprintf(f, "],\n"); + + // unitsLost + fprintf(f, " \"unitsLost\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].unitsLost); + } + fprintf(f, "],\n"); + + // buildingsBuilt + fprintf(f, " \"buildingsBuilt\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].buildingsBuilt); + } + fprintf(f, "],\n"); + + // buildingsLost + fprintf(f, " \"buildingsLost\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].buildingsLost); + } + fprintf(f, "],\n"); + + // techBuildingsCaptured + fprintf(f, " \"techBuildingsCaptured\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].techBuildingsCaptured); + } + fprintf(f, "],\n"); + + // factionBuildingsCaptured + fprintf(f, " \"factionBuildingsCaptured\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].factionBuildingsCaptured); + } + fprintf(f, "],\n"); + + // rankLevel + fprintf(f, " \"rankLevel\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].rankLevel); + } + fprintf(f, "],\n"); + + // skillPoints + fprintf(f, " \"skillPoints\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].skillPoints); + } + fprintf(f, "],\n"); + + // sciencePurchasePoints + fprintf(f, " \"sciencePurchasePoints\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].sciencePurchasePoints); + } + fprintf(f, "],\n"); + + // score + fprintf(f, " \"score\": ["); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].score); + } + fprintf(f, "],\n"); + + // unitsKilled - sparse per-opponent + fprintf(f, " \"unitsKilled\": {"); + { + Bool firstOpp = TRUE; + Int j; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + Int remapped = s_originalToNewIndex[j]; + if (remapped == 0) continue; + + // Check if any snapshot has non-zero value + Bool hasNonZero = FALSE; + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s_snapshots[s].players[pi].unitsKilled[j] != 0) + { + hasNonZero = TRUE; + break; + } + } + if (!hasNonZero) continue; + + if (!firstOpp) fprintf(f, ","); + firstOpp = FALSE; + fprintf(f, "\n \"%d\": [", remapped); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].unitsKilled[j]); + } + fprintf(f, "]"); + } + if (!firstOpp) fprintf(f, "\n "); + } + fprintf(f, "},\n"); + + // buildingsKilled - sparse per-opponent + fprintf(f, " \"buildingsKilled\": {"); + { + Bool firstOpp = TRUE; + Int j; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + Int remapped = s_originalToNewIndex[j]; + if (remapped == 0) continue; + + Bool hasNonZero = FALSE; + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s_snapshots[s].players[pi].buildingsKilled[j] != 0) + { + hasNonZero = TRUE; + break; + } + } + if (!hasNonZero) continue; + + if (!firstOpp) fprintf(f, ","); + firstOpp = FALSE; + fprintf(f, "\n \"%d\": [", remapped); + for (s = 0; s < s_snapshots.size(); ++s) + { + if (s > 0) fputc(',', f); + fprintf(f, "%d", s_snapshots[s].players[pi].buildingsKilled[j]); + } + fprintf(f, "]"); + } + if (!firstOpp) fprintf(f, "\n "); + } + fprintf(f, "}\n"); + + fprintf(f, " }"); + } + + fprintf(f, "\n ]\n"); + fprintf(f, " }\n"); +} + +//----------------------------------------------------------------------------- + +void ExportGameStatsJSON(const AsciiString& replayDir, const AsciiString& replayFileName) +{ + if (ThePlayerList == nullptr || TheGameLogic == nullptr || TheGlobalData == nullptr) + return; + + // Build stats file path: replace .rep extension with .gamestats.json + char baseName[_MAX_PATH + 1]; + strlcpy(baseName, replayFileName.str(), ARRAY_SIZE(baseName)); + char *dot = strrchr(baseName, '.'); + if (dot != nullptr) *dot = '\0'; + + AsciiString statsPath; + statsPath.format("%s%s.gamestats.json", replayDir.str(), baseName); + + FILE *f = fopen(statsPath.str(), "w"); + if (f == nullptr) + return; + + initPlayerMapping(); + + const Int playerCount = ThePlayerList->getPlayerCount(); + + fprintf(f, "{\n"); + fprintf(f, " \"version\": 3,\n"); + + // Game info + fprintf(f, " \"game\": {\n"); + fprintf(f, " \"map\": "); fprintJsonString(f, TheGlobalData->m_mapName.str()); fprintf(f, ",\n"); + fprintf(f, " \"mode\": \"%s\",\n", gameModeToString(TheGameLogic->getGameMode())); + fprintf(f, " \"frameCount\": %u,\n", TheGameLogic->getFrame()); + fprintf(f, " \"seed\": %u,\n", GetGameLogicRandomSeed()); + fprintf(f, " \"replayFile\": "); fprintJsonString(f, replayFileName.str()); fprintf(f, ",\n"); + fprintf(f, " \"playerCount\": %d\n", s_gamePlayerCount); + fprintf(f, " },\n"); + + // Players array + fprintf(f, " \"players\": [\n"); + Bool firstPlayer = TRUE; + Int i; + for (i = 0; i < playerCount; ++i) + { + Player *player = ThePlayerList->getNthPlayer(i); + if (player == nullptr || !isGamePlayer(player)) + continue; + + if (!firstPlayer) fprintf(f, ",\n"); + firstPlayer = FALSE; + + ScoreKeeper *sk = player->getScoreKeeper(); + const Energy *energy = player->getEnergy(); + const PlayerTemplate *pt = player->getPlayerTemplate(); + const AcademyStats *academy = player->getAcademyStats(); + + fprintf(f, " {\n"); + + // Basic info + fprintf(f, " \"index\": %d,\n", s_originalToNewIndex[i]); + fprintf(f, " \"displayName\": "); fprintJsonWideString(f, player->getPlayerDisplayName().str()); fprintf(f, ",\n"); + if (pt != nullptr) + { + fprintf(f, " \"faction\": "); fprintJsonString(f, pt->getName().str()); fprintf(f, ",\n"); + } + fprintf(f, " \"side\": "); fprintJsonString(f, player->getSide().str()); fprintf(f, ",\n"); + fprintf(f, " \"baseSide\": "); fprintJsonString(f, player->getBaseSide().str()); fprintf(f, ",\n"); + fprintf(f, " \"type\": \"%s\",\n", player->getPlayerType() == PLAYER_HUMAN ? "Human" : "Computer"); + fprintf(f, " \"color\": \"#%06X\",\n", static_cast(player->getPlayerColor()) & 0x00FFFFFFu); + fprintf(f, " \"isDead\": %s,\n", player->isPlayerDead() ? "true" : "false"); + + // Economy + fprintf(f, " \"money\": %u,\n", player->getMoney()->countMoney()); + fprintf(f, " \"moneyEarned\": %d,\n", sk->getTotalMoneyEarned()); + fprintf(f, " \"moneySpent\": %d,\n", sk->getTotalMoneySpent()); + + // Energy + fprintf(f, " \"energyProduction\": %d,\n", energy->getProduction()); + fprintf(f, " \"energyConsumption\": %d,\n", energy->getConsumption()); + + // Rank + fprintf(f, " \"rankLevel\": %d,\n", player->getRankLevel()); + fprintf(f, " \"skillPoints\": %d,\n", player->getSkillPoints()); + fprintf(f, " \"sciencePurchasePoints\": %d,\n", player->getSciencePurchasePoints()); + + // Units/Buildings summary + fprintf(f, " \"unitsBuilt\": %d,\n", sk->getTotalUnitsBuilt()); + fprintf(f, " \"unitsLost\": %d,\n", sk->getTotalUnitsLost()); + fprintf(f, " \"buildingsBuilt\": %d,\n", sk->getTotalBuildingsBuilt()); + fprintf(f, " \"buildingsLost\": %d,\n", sk->getTotalBuildingsLost()); + fprintf(f, " \"techBuildingsCaptured\": %d,\n", sk->getTotalTechBuildingsCaptured()); + fprintf(f, " \"factionBuildingsCaptured\": %d,\n", sk->getTotalFactionBuildingsCaptured()); + + // Radar & Battle plans + fprintf(f, " \"hasRadar\": %s,\n", player->hasRadar() ? "true" : "false"); + fprintf(f, " \"battlePlans\": {\n"); + fprintf(f, " \"bombardment\": %d,\n", player->getBattlePlansActiveSpecific(PLANSTATUS_BOMBARDMENT)); + fprintf(f, " \"holdTheLine\": %d,\n", player->getBattlePlansActiveSpecific(PLANSTATUS_HOLDTHELINE)); + fprintf(f, " \"searchAndDestroy\": %d\n", player->getBattlePlansActiveSpecific(PLANSTATUS_SEARCHANDDESTROY)); + fprintf(f, " },\n"); + + // Score + fprintf(f, " \"score\": %d,\n", sk->calculateScore()); + + // Per-player destroy counts (sparse objects with remapped keys) + Int j; + fprintf(f, " \"unitsDestroyedPerPlayer\": {"); + { + Bool firstKill = TRUE; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + if (s_originalToNewIndex[j] == 0) continue; + Int count = sk->getUnitsDestroyedByPlayer(j); + if (count == 0) continue; + if (!firstKill) fprintf(f, ","); + firstKill = FALSE; + fprintf(f, " \"%d\": %d", s_originalToNewIndex[j], count); + } + if (!firstKill) fprintf(f, " "); + } + fprintf(f, "},\n"); + + fprintf(f, " \"buildingsDestroyedPerPlayer\": {"); + { + Bool firstKill = TRUE; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + if (s_originalToNewIndex[j] == 0) continue; + Int count = sk->getBuildingsDestroyedByPlayer(j); + if (count == 0) continue; + if (!firstKill) fprintf(f, ","); + firstKill = FALSE; + fprintf(f, " \"%d\": %d", s_originalToNewIndex[j], count); + } + if (!firstKill) fprintf(f, " "); + } + fprintf(f, "},\n"); + + // Per-object-type maps + fprintf(f, " \"objectsBuilt\": "); writeObjectCountMap(f, sk->getObjectsBuilt(), " "); fprintf(f, ",\n"); + fprintf(f, " \"objectsLost\": "); writeObjectCountMap(f, sk->getObjectsLost(), " "); fprintf(f, ",\n"); + fprintf(f, " \"objectsCaptured\": "); writeObjectCountMap(f, sk->getObjectsCaptured(), " "); fprintf(f, ",\n"); + + // Per-player per-object-type destroyed (sparse object with remapped keys) + fprintf(f, " \"objectsDestroyedPerPlayer\": {"); + { + const ScoreKeeper::ObjectCountMap *destroyedArr = sk->getObjectsDestroyedArray(); + Bool firstOpp = TRUE; + for (j = 0; j < MAX_PLAYER_COUNT; ++j) + { + if (s_originalToNewIndex[j] == 0) continue; + if (destroyedArr[j].empty()) continue; + if (!firstOpp) fprintf(f, ","); + firstOpp = FALSE; + fprintf(f, "\n \"%d\": ", s_originalToNewIndex[j]); + writeObjectCountMap(f, destroyedArr[j], " "); + } + if (!firstOpp) fprintf(f, "\n "); + } + fprintf(f, "},\n"); + + // AcademyStats (Zero Hour only) + fprintf(f, " \"academy\": {\n"); + fprintf(f, " \"supplyCentersBuilt\": %u,\n", academy->getSupplyCentersBuilt()); + fprintf(f, " \"peonsBuilt\": %u,\n", academy->getPeonsBuilt()); + fprintf(f, " \"structuresCaptured\": %u,\n", academy->getStructuresCaptured()); + fprintf(f, " \"generalsPointsSpent\": %u,\n", academy->getGeneralsPointsSpent()); + fprintf(f, " \"specialPowersUsed\": %u,\n", academy->getSpecialPowersUsed()); + fprintf(f, " \"structuresGarrisoned\": %u,\n", academy->getStructuresGarrisoned()); + fprintf(f, " \"upgradesPurchased\": %u,\n", academy->getUpgradesPurchased()); + fprintf(f, " \"gatherersBuilt\": %u,\n", academy->getGatherersBuilt()); + fprintf(f, " \"heroesBuilt\": %u,\n", academy->getHeroesBuilt()); + fprintf(f, " \"controlGroupsUsed\": %u,\n", academy->getControlGroupsUsed()); + fprintf(f, " \"secondaryIncomeUnitsBuilt\": %u,\n", academy->getSecondaryIncomeUnitsBuilt()); + fprintf(f, " \"clearedGarrisonedBuildings\": %u,\n", academy->getClearedGarrisonedBuildings()); + fprintf(f, " \"salvageCollected\": %u,\n", academy->getSalvageCollected()); + fprintf(f, " \"guardAbilityUsedCount\": %u,\n", academy->getGuardAbilityUsedCount()); + fprintf(f, " \"doubleClickAttackMoveOrdersGiven\": %u,\n", academy->getDoubleClickAttackMoveOrdersGiven()); + fprintf(f, " \"minesCleared\": %u,\n", academy->getMinesCleared()); + fprintf(f, " \"vehiclesDisguised\": %u,\n", academy->getVehiclesDisguised()); + fprintf(f, " \"firestormsCreated\": %u\n", academy->getFirestormsCreated()); + fprintf(f, " }\n"); + + fprintf(f, " }"); + } + fprintf(f, "\n ],\n"); + + writeTimeSeries(f); + + fprintf(f, "}\n"); + + fclose(f); + + StatsExporterClearSnapshots(); +} diff --git a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp index 1d671f180f5..e40772ff5c9 100644 --- a/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp +++ b/GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp @@ -52,6 +52,7 @@ #include "Common/RandomValue.h" #include "Common/Recorder.h" #include "Common/StatsCollector.h" +#include "Common/StatsExporter.h" #include "Common/ThingFactory.h" #include "Common/Team.h" #include "Common/ThingTemplate.h" @@ -2243,6 +2244,8 @@ void GameLogic::startNewGame( Bool loadingSaveGame ) TheStatsCollector = NEW StatsCollector; TheStatsCollector->reset(); } + if (TheGlobalData->m_exportStats) + StatsExporterClearSnapshots(); /// ShowControlBar(FALSE); @@ -3700,6 +3703,9 @@ void GameLogic::update() TheStatsCollector->update(); } + if (TheGlobalData->m_exportStats) + StatsExporterCollectSnapshot(); + // Update the Recorder { TheRecorder->UPDATE();