Replay: deterministic record & playback (#4)

Add deterministic record/playback for a run.

Recording captures `(seed, config hash, ordered tick-tagged commands)` and re-simulates on playback — no state snapshots. `DotaFactory.exe --replay <file>` re-plays a recorded run view-only with manual speed/pause.

Reviewed-on: #4
Co-authored-by: Malte Langkabel <malte.langkabel@gmail.com>
Co-committed-by: Malte Langkabel <malte.langkabel@gmail.com>
This commit was merged in pull request #4.
This commit is contained in:
2026-07-01 19:20:08 +00:00
committed by mlangkabel
parent cf68ac2862
commit d74ba5bfad
40 changed files with 3385 additions and 129 deletions

View File

@@ -6,10 +6,12 @@
#include <cmath>
#include <functional>
#include <map>
#include <memory>
#include <string>
#include <QColor>
#include <QCursor>
#include <QDir>
#include <QFont>
#include <QKeyEvent>
#include <QMessageBox>
@@ -24,6 +26,10 @@
#include "BeltSystem.h"
#include "Building.h"
#include "BuildingSystem.h"
#include "Command.h"
#include "ReplayPlayer.h"
#include "ReplayReader.h"
#include "ReplayRecorder.h"
#include "DemolishModeChangedEvent.h"
#include "EntityHitTest.h"
#include "EntitySelectedEvent.h"
@@ -112,11 +118,13 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, QWidget* parent)
const VisualsConfig* visuals, const std::string& configDir,
const ParsedReplay* replay, QWidget* parent)
: QOpenGLWidget(parent)
, m_sim(sim)
, m_config(config)
, m_visuals(visuals)
, m_commandManager(*sim)
, m_gameSpeedMultiplier(1.0)
, m_prevNonZeroSpeed(1.0)
, m_scrollXTiles(0.0f)
@@ -143,6 +151,25 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
m_frameTimer.start();
registerForEvents();
if (replay)
{
// View-only playback: ignore live input and drive ticks from the recorded
// stream. No recorder (we are not creating a new run).
m_commandManager.setReplayMode(true);
m_replayPlayer = std::make_unique<ReplayPlayer>(*sim, replay->entries);
m_replayPlayer->start(); // process tick-0 entries before the first tick
}
else
{
// Record every run to disk. Replays live under <data>/replays, alongside
// the config dir that was loaded. Attaching the recorder opens the first
// file and writes the header for the initial run.
QDir replayDir(QString::fromStdString(configDir));
replayDir.cdUp();
m_commandManager.setRecorder(std::make_unique<ReplayRecorder>(
configDir, replayDir.filePath("replays").toStdString()));
}
}
GameWorldView::~GameWorldView()
@@ -159,13 +186,40 @@ void GameWorldView::onFrame()
{
const qint64 elapsed = m_frameTimer.restart();
// Advance simulation
if (m_replayPlayer)
{
// Playback: apply recorded commands at their ticks and verify checksums.
// Manual speed/pause still works; playback only moves forward.
const int ticks = m_tickDriver.advance(
static_cast<double>(elapsed), m_gameSpeedMultiplier);
for (int i = 0; i < ticks; ++i)
{
if (m_replayPlayer->isFinished()) { break; }
m_sim->tick();
m_replayPlayer->advanceTo(m_sim->currentTick());
}
}
else
{
// Drain queued player commands once per frame, before the tick batch. This
// runs even at 0x so a paused player sees placed construction sites
// immediately, while staying deterministic (see docs/replay_design.md).
m_commandManager.drain();
// A drained Reset reinitialized the simulation; reset the view to match.
if (m_viewResetPending)
{
m_viewResetPending = false;
resetForNewGame();
}
const int ticks = m_tickDriver.advance(
static_cast<double>(elapsed), m_gameSpeedMultiplier);
for (int i = 0; i < ticks; ++i)
{
m_sim->tick();
// Periodic checksum (every 30 ticks) for replay desync detection.
m_commandManager.recordTickCheckpoint();
}
}
@@ -239,25 +293,31 @@ void GameWorldView::onFrame()
}
}
// Schematic choice available
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
// Sim-state polls are input sources for the live game; in replay these are
// gated off (the recorded ApplySchematicChoice resolves the choice with no UI,
// and game-over becomes the passive "replay ended" state below).
if (!m_replayPlayer)
{
m_schematicChoiceShown = true;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
}
if (!m_sim->hasSchematicChoicesPending())
{
m_schematicChoiceShown = false;
}
// Schematic choice available
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
{
m_schematicChoiceShown = true;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<SchematicChoicesAvailableEvent>(m_sim->getPendingSchematicChoices()));
}
if (!m_sim->hasSchematicChoicesPending())
{
m_schematicChoiceShown = false;
}
// Game over check
if (m_sim->isGameOver() && !m_gameOverShown)
{
m_gameOverShown = true;
m_gameSpeedMultiplier = 0.0;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GameOverEvent>());
// Game over check
if (m_sim->isGameOver() && !m_gameOverShown)
{
m_gameOverShown = true;
m_gameSpeedMultiplier = 0.0;
EventManager::getInstance()->sendEventImmediately(
std::make_shared<GameOverEvent>());
}
}
update();
@@ -283,6 +343,7 @@ void GameWorldView::paintGL()
drawBeams(painter);
drawOverlays(painter);
drawScreenSpace(painter);
drawReplayOverlay(painter);
}
// ---------------------------------------------------------------------------
@@ -515,12 +576,22 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
m_sim->buildings().findRotateInPlaceTarget(bb.type, anchor, bb.rotation);
if (rotateTarget.has_value())
{
m_sim->buildings().rotateInPlace(*rotateTarget, bb.rotation);
std::shared_ptr<RotateInPlaceCommand> rotateCommand =
std::make_shared<RotateInPlaceCommand>();
rotateCommand->id = *rotateTarget;
rotateCommand->newRotation = bb.rotation;
enqueueCommand(rotateCommand);
continue;
}
const BuildingId id = m_sim->tryPlaceBuilding(bb.type, anchor, bb.rotation);
if (id == kInvalidBuildingId) { continue; }
// Place-and-configure is one atomic command: commands apply at a deferred
// tick boundary, so the caller never sees the new BuildingId. Unlock
// gating stays here (UI-side pre-filter); only fields that should apply
// are set on the command.
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
command->type = bb.type;
command->anchor = anchor;
command->rotation = bb.rotation;
if (!bb.recipeId.empty())
{
@@ -528,7 +599,7 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
{
if (m_sim->isSchematicUnlocked(bb.recipeId))
{
m_sim->buildings().setRecipe(id, bb.recipeId);
command->recipeId = bb.recipeId;
}
}
else
@@ -537,15 +608,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
|| bb.type == BuildingType::Assembler;
if (!needsUnlockCheck || m_sim->isRecipeUnlocked(bb.recipeId))
{
m_sim->buildings().setRecipe(id, bb.recipeId);
command->recipeId = bb.recipeId;
}
}
}
if (bb.shipLayout.has_value())
{
m_sim->buildings().setShipLayout(id, *bb.shipLayout);
}
command->shipLayout = bb.shipLayout;
if (bb.type == BuildingType::Splitter
&& (!bb.splitterFilterA.empty() || !bb.splitterFilterB.empty()))
@@ -553,11 +621,12 @@ void GameWorldView::placeBlueprintAtTile(QPoint center)
// The splitter is still a construction site, so the filters carry
// over when it finishes building (REQ-UI-BLUEPRINT-PLACE). Locked
// item types are dropped per REQ-LOCK-UI-BLUEPRINT.
m_sim->buildings().setSiteSplitterFilters(
id,
filterUnlockedItems(bb.splitterFilterA, *m_sim),
filterUnlockedItems(bb.splitterFilterB, *m_sim));
command->hasSplitterFilters = true;
command->splitterFilterA = filterUnlockedItems(bb.splitterFilterA, *m_sim);
command->splitterFilterB = filterUnlockedItems(bb.splitterFilterB, *m_sim);
}
enqueueCommand(command);
}
}
@@ -578,49 +647,50 @@ void GameWorldView::placeAtTile(QPoint tile)
m_sim->buildings().findRotateInPlaceTarget(type, tile, m_ghostRotation);
if (rotateTarget.has_value())
{
m_sim->buildings().rotateInPlace(*rotateTarget, m_ghostRotation);
std::shared_ptr<RotateInPlaceCommand> command =
std::make_shared<RotateInPlaceCommand>();
command->id = *rotateTarget;
command->newRotation = m_ghostRotation;
enqueueCommand(command);
return;
}
// For placements whose UI follow-up depends on success (belt-drag bookkeeping,
// tunnel entry/exit toggle), pre-validate occupancy + affordability so the
// optimistic UI update matches what the deferred command will do — isValidPlacement
// (above) already covered terrain/bounds.
if (type == BuildingType::Belt)
{
if (m_beltDragTiles.count(tile) > 0)
{
{
return;
}
if (!m_sim->buildings().isTileOccupied(tile))
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
{
const BuildingId id = m_sim->tryPlaceBuilding(
type, tile, m_ghostRotation);
if (id != kInvalidBuildingId)
{
m_beltDragTiles.insert(tile);
}
enqueuePlaceBuilding(type, tile, m_ghostRotation);
m_beltDragTiles.insert(tile);
}
}
else if (type == BuildingType::Splitter
|| type == BuildingType::TunnelEntry
|| type == BuildingType::TunnelExit)
{
if (!m_sim->buildings().isTileOccupied(tile))
if (!m_sim->buildings().isTileOccupied(tile) && canAfford(type))
{
const BuildingId id = m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
if (id != kInvalidBuildingId)
enqueuePlaceBuilding(type, tile, m_ghostRotation);
if (type == BuildingType::TunnelEntry)
{
if (type == BuildingType::TunnelEntry)
{
m_builderType = BuildingType::TunnelExit;
}
else if (type == BuildingType::TunnelExit)
{
m_builderType = BuildingType::TunnelEntry;
}
m_builderType = BuildingType::TunnelExit;
}
else if (type == BuildingType::TunnelExit)
{
m_builderType = BuildingType::TunnelEntry;
}
}
}
else
{
m_sim->tryPlaceBuilding(type, tile, m_ghostRotation);
enqueuePlaceBuilding(type, tile, m_ghostRotation);
}
}
@@ -1209,6 +1279,45 @@ void GameWorldView::drawScreenSpace(QPainter& /*painter*/)
{
}
void GameWorldView::drawReplayOverlay(QPainter& painter)
{
if (!m_replayPlayer) { return; }
painter.save();
QFont tag = painter.font();
tag.setPixelSize(16);
tag.setBold(true);
painter.setFont(tag);
painter.setPen(QColor(255, 220, 80));
painter.drawText(QRect(0, 8, width(), 24), Qt::AlignHCenter | Qt::AlignTop, tr("REPLAY"));
if (m_replayPlayer->isFinished())
{
// Dim the world so the end message reads clearly over it.
painter.fillRect(rect(), QColor(0, 0, 0, 140));
const std::optional<Tick> desync = m_replayPlayer->getDesyncTick();
QString message;
if (desync.has_value())
{
message = tr("Desync at tick %1").arg(static_cast<qlonglong>(*desync));
painter.setPen(QColor(255, 90, 90));
}
else
{
message = tr("Replay ended");
painter.setPen(QColor(255, 255, 255));
}
QFont big = painter.font();
big.setPixelSize(28);
painter.setFont(big);
painter.drawText(rect(), Qt::AlignCenter, message);
}
painter.restore();
}
// ---------------------------------------------------------------------------
// Input
// ---------------------------------------------------------------------------
@@ -1369,7 +1478,10 @@ void GameWorldView::mousePressEvent(QMouseEvent* event)
const bool isProtected = b && b->type == BuildingType::Hq;
if (!isProtected)
{
m_sim->demolish(hovered);
std::shared_ptr<DemolishCommand> command =
std::make_shared<DemolishCommand>();
command->id = hovered;
enqueueCommand(command);
m_demolishHoverBuildingId = kInvalidBuildingId;
}
}
@@ -1743,3 +1855,35 @@ void GameWorldView::handleEvent(std::shared_ptr<const SpeedChangeRequestedEvent>
{
setGameSpeed(event->multiplier);
}
void GameWorldView::handleEvent(std::shared_ptr<const CommandRequestedEvent> event)
{
// Other widgets (MainWindow, SelectedBuildingPanel) request commands via this
// event; GameWorldView owns the CommandManager and enqueues them.
if (event->command && event->command->kind == CommandKind::Reset)
{
m_viewResetPending = true;
}
enqueueCommand(event->command);
}
void GameWorldView::enqueueCommand(std::shared_ptr<const Command> command)
{
m_commandManager.enqueue(std::move(command));
}
void GameWorldView::enqueuePlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation)
{
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
command->type = type;
command->anchor = anchor;
command->rotation = rotation;
enqueueCommand(command);
}
bool GameWorldView::canAfford(BuildingType type) const
{
const BuildingDef* def = findBuildingDef(type);
if (!def) { return false; }
return m_sim->buildingBlocksStock() >= def->cost;
}