feat(stats): Add JSON game stats export alongside replay files Store stats#2436
feat(stats): Add JSON game stats export alongside replay files Store stats#2436bill-rich wants to merge 1 commit intoTheSuperHackers:mainfrom
Conversation
05688b9 to
830353d
Compare
|
| Filename | Overview |
|---|---|
| Generals/Code/GameEngine/Source/Common/StatsExporter.cpp | New 690-line file implementing JSON stats export for Generals. Contains widespread inline if-body violations (rule 16b9b669) and the if (dot != nullptr) *dot = '\0'; one-liner at line 532. |
| GeneralsMD/Code/GameEngine/Source/Common/StatsExporter.cpp | New 714-line Zero Hour variant. In addition to the same inline if-body violations as the Generals version, academy (retrieved via player->getAcademyStats() at line 574) is used unconditionally from line 683 onward with no null check, which will crash if the pointer is null. |
| GeneralsMD/Code/GameEngine/Include/Common/AcademyStats.h | Adds 18 public read accessors to AcademyStats. As noted in a previous review thread, getDragSelectUnits() and getUnitsEnteredTunnelNetwork() accessors are absent from this PR despite being listed in that earlier discussion. The 18 added here are all consumed in the academy JSON block. |
| Generals/Code/GameEngine/Include/Common/ScoreKeeper.h | Moves ObjectCountMap typedef from private to public scope and adds six read-only accessors needed by StatsExporter. Change is clean and backward-compatible. |
| GeneralsMD/Code/GameEngine/Include/Common/ScoreKeeper.h | Same typedef promotion and accessor additions as the Generals version. Clean and backward-compatible. |
| Generals/Code/GameEngine/Source/Common/Recorder.cpp | Adds a call to ExportGameStatsJSON in stopRecording() guarded by m_exportStats and a non-empty filename check. Integration is correct. |
| GeneralsMD/Code/GameEngine/Source/Common/Recorder.cpp | Zero Hour counterpart. Same clean integration as Generals Recorder.cpp. |
| Generals/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp | Adds StatsExporterClearSnapshots() on new game start and StatsExporterCollectSnapshot() in the update loop, both gated behind m_exportStats. Integration is correct. |
| GeneralsMD/Code/GameEngine/Source/GameLogic/System/GameLogic.cpp | Zero Hour counterpart. Same clean integration as Generals GameLogic.cpp. |
Sequence Diagram
sequenceDiagram
participant GL as GameLogic
participant SE as StatsExporter
participant REC as Recorder
participant FS as FileSystem
GL->>SE: startNewGame() → StatsExporterClearSnapshots()
Note over SE: Resets snapshots vector,<br/>frame counter & player mapping
loop Every logic frame (update)
GL->>SE: StatsExporterCollectSnapshot()
Note over SE: Skipped if < 30 frames since last snapshot
SE->>SE: initPlayerMapping() (once)
SE->>SE: Collect per-player stats snapshot
SE->>SE: s_snapshots.push_back(snap)
end
GL->>REC: TheRecorder->UPDATE()
REC->>REC: stopRecording() — saves .rep file
alt m_exportStats && !m_fileName.isEmpty()
REC->>SE: ExportGameStatsJSON(replayDir, replayFileName)
SE->>SE: initPlayerMapping()
SE->>FS: fopen("*.gamestats.json", "w")
SE->>FS: Write game metadata block
SE->>FS: Write players[] array (ScoreKeeper + AcademyStats)
SE->>FS: writeTimeSeries() — columnar per-player arrays
SE->>FS: fclose()
SE->>SE: StatsExporterClearSnapshots()
end
Last reviewed commit: 4b696c0
|
It looks like this is one of your first PR's for this repo. Thank you for your effort and contribution. However, it would have been better to start an issue/discussion first about the need and requirements for such function, before implementing it. Initial feedback:
|
Thanks for taking a look! Happy to add a flag so it is opt-in. Also, yeah. Not happy about the exporter, but got stuck because of VC6. I'm down for switching it out for something else if preferred. I did start a discussion a while back(#1812), but it didn't get much traction. I figured I'd implement the feature and see what the feedback was in the PR. The reason I'm trying to get this added is that there are some pretty cool ways to display the data once you've got it. I'm currently doing it by reading memory addresses and running replays, but having it built in would be way cleaner. Here is an example of what can be done with it: https://www.radarvan.com/ |
|
| 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<unsigned int>(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()); |
There was a problem hiding this comment.
Null pointer dereference if getAcademyStats() returns null
academy is fetched at line 574 but there is no null check before it is first dereferenced at line 683 (academy->getSupplyCentersBuilt()). If player->getAcademyStats() ever returns nullptr (e.g. for an AI or for a player whose academy stats object was not initialised), this will crash at runtime.
The fix is to guard the entire "academy" block behind a null check:
if (academy != nullptr)
{
fprintf(f, " \"academy\": {\n");
fprintf(f, " \"supplyCentersBuilt\": %u,\n", academy->getSupplyCentersBuilt());
// … rest of academy fields …
fprintf(f, " }\n");
}Alternatively, simply assert/return early after retrieving academy if it is expected to always be non-null in this context.
Prompt To Fix With AI
This is a comment left during a code review.
Path: GeneralsMD/Code/GameEngine/Source/Common/StatsExporter.cpp
Line: 574-683
Comment:
**Null pointer dereference if `getAcademyStats()` returns null**
`academy` is fetched at line 574 but there is no null check before it is first dereferenced at line 683 (`academy->getSupplyCentersBuilt()`). If `player->getAcademyStats()` ever returns `nullptr` (e.g. for an AI or for a player whose academy stats object was not initialised), this will crash at runtime.
The fix is to guard the entire `"academy"` block behind a null check:
```cpp
if (academy != nullptr)
{
fprintf(f, " \"academy\": {\n");
fprintf(f, " \"supplyCentersBuilt\": %u,\n", academy->getSupplyCentersBuilt());
// … rest of academy fields …
fprintf(f, " }\n");
}
```
Alternatively, simply assert/return early after retrieving `academy` if it is expected to always be non-null in this context.
How can I resolve this? If you propose a fix, please make it concise.
Summary
Adds a new StatsExporter module that writes game match statistics as a JSON file alongside the last replay file. When a recording stops, the exporter collects end-of-match stats from ScoreKeeper (and AcademyStats in Zero Hour) and writes them to a
.jsonfile in the replay directory.The exporter also collects time-series snapshots every 30 frames (~0.5-1 second) during gameplay, capturing per-player resource and unit statistics over time. Snapshots are cleared on game start and flushed to the JSON output on recording stop.
Integration points
Recorder::stopRecording()callsExportGameStatsJSON()after saving the replayGameLogic::update()callsStatsExporterCollectSnapshot()each frameGameLogic::startNewGame()callsStatsExporterClearSnapshots()to reset stateBoth titles
The implementation is provided for both Zero Hour (GeneralsMD) and Generals, with Zero Hour including additional AcademyStats accessors. The implementations are kept as close as possible across both titles.