replay: play back recorded runs via --replay (Phase 3)

Adds view-only playback: re-simulate from the recorded seed + commands and
verify the RNG checksums.

- ReplayReader (lib): parses a replay file into header + an ordered stream of
  command/checksum entries. CommandSerializer gains the inverse parseCommand
  (round-trips every verb; rejects malformed input).
- ReplayPlayer (lib): the playback driver. Applies each command at its exact
  recorded tick and verifies checksums in file order (start() handles tick 0;
  advanceTo(tick) handles each tick after sim.tick()). Independent of
  replay-time speed/pause; reports the first desync tick.
- CommandManager replay mode: enqueue() becomes a no-op so live input is
  ignored while the recorded stream drives application.
- main.cpp: --replay <file> reads + validates (warns on version/config-hash
  mismatch), seeds the sim from the header, and threads the replay through
  MainWindow to GameWorldView.
- GameWorldView: drives the player in onFrame (manual speed/pause kept,
  forward-only), gates the schematic-choices and game-over polls, and draws a
  "REPLAY" tag plus a passive "Replay ended" / "Desync at tick N" overlay.
- computeReplayConfigHash factored out of ReplayRecorder for reuse by main.

ReplayPlaybackTest records a scripted run, reads it back, replays it, and
asserts no desync + byte-identical final state -- including the
periodic-checksum-then-command ordering at a shared tick. Full suite green
(350 cases / 3396 assertions); app, tests, and balancing all build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DUsFgd2Ga6pmLz8giS8WUn
This commit is contained in:
2026-06-30 21:06:59 +02:00
parent 97b6f0d8fd
commit 26e108f3e1
19 changed files with 848 additions and 67 deletions

View File

@@ -406,23 +406,32 @@ Reshape mutations to flow through one path; behaviour unchanged.
- **Exit criteria met:** recorder + serializer + drain-integration tests pass; the format is - **Exit criteria met:** recorder + serializer + drain-integration tests pass; the format is
well-formed and flushed per line. (Live GUI recording is wired but not auto-tested here.) well-formed and flushed per line. (Live GUI recording is wired but not auto-tested here.)
### Phase 3 — Playback ### Phase 3 — Playback — DONE
- Implement the **reader/parser** for the format (header + commands + checksums). - `ReplayReader` (lib) parses the file into `{ header, entries }`, where each entry is a command
- Add the `--replay <file>` CLI path in `main`: validate config hash + version (warn on (with its tick) or a checksum (with its tick), kept in **file order**. `CommandSerializer`
mismatch), construct `Simulation` from seed+config, construct `CommandManager` in **replay gained the inverse `parseCommand` (round-tripping every verb).
mode** (pre-filled, `addCommand` is a no-op). - `--replay <file>` CLI path in `main`: reads the file, **warns** on version / config-hash
- Replay driver: each frame, drain commands due at the reached tick (same drain path), step mismatch (proceeds anyway), constructs the `Simulation` from the header seed, and threads the
ticks, **keep manual speed/pause**, playback only moves forward. parsed replay through `MainWindow` to `GameWorldView`.
- **Gate the two `onFrame` polls** (schematic-choices, game-over) off in replay mode; everything - `ReplayPlayer` (lib) is the playback driver. Rather than reproduce frame batching, it applies
else (recipe/layout dialogs, escape menu) is input-driven and falls away automatically. each command at its **exact recorded tick** and verifies checksums **in file order**:
- **Desync detection:** recompute the RNG checksum at each checkpoint, compare to the file, `start()` processes the tick-0 entries, then after every `sim.tick()` `advanceTo(tick)`
report "desync at tick N" on mismatch. consumes that tick's entries (periodic checksum first, then command + its checksum — the order
- **Passive end:** when the command stream is exhausted / recorded game-over is reached, stop the file already has). This makes playback independent of replay-time speed/pause.
with a "replay ended" overlay instead of the restart dialog. - `GameWorldView` runs the player in `onFrame` when in replay mode (manual speed/pause kept,
- **Files:** new reader in `lib`; `main.cpp`; `CommandManager` (replay mode); `GameWorldView.cpp` forward-only); `CommandManager` is put in **replay mode** so live input is a no-op. The two
(poll gating, end overlay). sim-state polls (schematic-choices, game-over) are **gated off**; dialog/escape paths are
- **Exit criteria:** a recorded file plays back identically; checksums match throughout. input-driven and fall away. A **"REPLAY"** tag plus a passive **"Replay ended"** /
**"Desync at tick N"** overlay replaces the restart dialog.
- **Files:** new `lib/sim/ReplayReader.{h,cpp}`, `ReplayPlayer.{h,cpp}`; `CommandSerializer`
(`parseCommand`); `ReplayRecorder` (shared `computeReplayConfigHash`); `CommandManager`
(replay mode); `main.cpp`; `MainWindow.{h,cpp}`; `GameWorldView.{h,cpp}`; new
`ReplayPlaybackTest.cpp`.
- **Exit criteria met:** the headless `ReplayPlaybackTest` records a scripted run, reads it back,
replays it, and asserts **no desync** and a **byte-identical final state checksum** — including
the periodic-checksum-then-command ordering at a shared tick. (Live GUI playback is wired but
not auto-tested here.)
### Phase 4 — Closing tests & polish ### Phase 4 — Closing tests & polish

View File

@@ -1,5 +1,7 @@
#include <memory> #include <memory>
#include <optional>
#include <random> #include <random>
#include <string>
#include <QApplication> #include <QApplication>
#include <QDir> #include <QDir>
@@ -9,6 +11,8 @@
#include "logging.h" #include "logging.h"
#include "LogManager.h" #include "LogManager.h"
#include "MainWindow.h" #include "MainWindow.h"
#include "ReplayReader.h"
#include "ReplayRecorder.h"
#include "Simulation.h" #include "Simulation.h"
int main(int argc, char *argv[]) int main(int argc, char *argv[])
@@ -32,15 +36,54 @@ int main(int argc, char *argv[])
QDir().mkdir(dataDir.dirName()); QDir().mkdir(dataDir.dirName());
} }
// Optional "--replay <file>" launches view-only playback of a recorded run.
std::optional<std::string> replayPath;
for (int i = 1; i + 1 < argc; ++i)
{
if (std::string(argv[i]) == "--replay")
{
replayPath = argv[i + 1];
break;
}
}
GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR); GameConfig config = ConfigLoader::loadFromDirectory(CONFIG_DIR);
unsigned int seed = 0;
std::shared_ptr<ParsedReplay> replay;
if (replayPath.has_value())
{
std::optional<ParsedReplay> parsed = readReplayFile(*replayPath);
if (!parsed.has_value())
{
LOG_ERROR("Failed to read replay file: " + *replayPath);
return 1;
}
// Warn (but proceed) on identity mismatches: a different config or build
// can desync playback (see docs/replay_design.md).
if (parsed->header.version != 1)
{
LOG_WARNING_STREAM(<< "Replay format version " << parsed->header.version
<< " differs from 1; playback may fail");
}
if (computeReplayConfigHash(CONFIG_DIR) != parsed->header.configHash)
{
LOG_WARNING("Replay config hash mismatch; playback may desync");
}
seed = parsed->header.seed;
replay = std::make_shared<ParsedReplay>(std::move(*parsed));
}
else
{
// Random seed generated outside the sim so the Simulation stays a pure // Random seed generated outside the sim so the Simulation stays a pure
// function of (seed, config, commands); the seed is written to the replay // function of (seed, config, commands); written to the replay header
// header (see docs/replay_design.md "Seed and config"). // (see docs/replay_design.md "Seed and config").
const unsigned int seed = std::random_device{}(); seed = std::random_device{}();
}
std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config), seed); std::unique_ptr<Simulation> sim = std::make_unique<Simulation>(std::move(config), seed);
MainWindow window(sim.get(), std::string(CONFIG_DIR)); MainWindow window(sim.get(), std::string(CONFIG_DIR), replay);
window.show(); window.show();
const int ret = application.exec(); const int ret = application.exec();

View File

@@ -5,6 +5,8 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h ${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.h ${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.h
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.h ${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.h
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.h
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.h
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.h
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h ${CMAKE_CURRENT_SOURCE_DIR}/Building.h
@@ -25,6 +27,8 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp ${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.cpp ${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ReplayRecorder.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ReplayReader.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ReplayPlayer.cpp
${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp ${CMAKE_CURRENT_SOURCE_DIR}/TickDriver.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp

View File

@@ -22,6 +22,10 @@ CommandManager::~CommandManager() = default;
void CommandManager::enqueue(std::shared_ptr<const Command> command) void CommandManager::enqueue(std::shared_ptr<const Command> command)
{ {
if (m_replayMode)
{
return; // playback is driven by the recorded stream; ignore live input
}
if (command) if (command)
{ {
m_queue.push_back(std::move(command)); m_queue.push_back(std::move(command));
@@ -74,6 +78,11 @@ void CommandManager::setRecorder(std::unique_ptr<ReplayRecorder> recorder)
} }
} }
void CommandManager::setReplayMode(bool replayMode)
{
m_replayMode = replayMode;
}
void CommandManager::recordTickCheckpoint() void CommandManager::recordTickCheckpoint()
{ {
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0)) if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))

View File

@@ -39,6 +39,10 @@ public:
// Passing nullptr detaches/stops recording. // Passing nullptr detaches/stops recording.
void setRecorder(std::unique_ptr<ReplayRecorder> recorder); void setRecorder(std::unique_ptr<ReplayRecorder> recorder);
// In replay mode, enqueue() is a no-op: the queue is driven by the recorded
// stream (ReplayPlayer), so stray live input produces nothing.
void setReplayMode(bool replayMode);
// Record a periodic RNG checksum if the current tick is on the checksum // Record a periodic RNG checksum if the current tick is on the checksum
// cadence. Call once per simulated tick (from the tick loop). // cadence. Call once per simulated tick (from the tick loop).
void recordTickCheckpoint(); void recordTickCheckpoint();
@@ -47,4 +51,5 @@ private:
Simulation& m_simulation; Simulation& m_simulation;
std::vector<std::shared_ptr<const Command>> m_queue; std::vector<std::shared_ptr<const Command>> m_queue;
std::unique_ptr<ReplayRecorder> m_recorder; std::unique_ptr<ReplayRecorder> m_recorder;
bool m_replayMode = false;
}; };

View File

@@ -1,6 +1,7 @@
#include "CommandSerializer.h" #include "CommandSerializer.h"
#include <sstream> #include <sstream>
#include <vector>
#include "BuildingType.h" #include "BuildingType.h"
#include "Command.h" #include "Command.h"
@@ -21,6 +22,59 @@ char rotationToChar(Rotation rotation)
return 'E'; return 'E';
} }
Rotation rotationFromString(const std::string& token)
{
if (token == "N") { return Rotation::North; }
if (token == "S") { return Rotation::South; }
if (token == "W") { return Rotation::West; }
return Rotation::East;
}
// Reads "<count> (<moduleId> <x> <y> <rot>)*" from the stream. Sets ok=false on
// a stream failure.
ShipLayoutConfig parseLayout(std::istringstream& in, bool& ok)
{
ShipLayoutConfig layout;
int count = 0;
if (!(in >> count) || count < 0) { ok = false; return layout; }
for (int i = 0; i < count; ++i)
{
PlacedModule placed;
std::string rotToken;
int x = 0;
int y = 0;
if (!(in >> placed.moduleId >> x >> y >> rotToken)) { ok = false; return layout; }
placed.position = QPoint(x, y);
placed.rotation = rotationFromString(rotToken);
layout.placedModules.push_back(placed);
}
return layout;
}
// Reads "<countA> (<itemId>)* <countB> (<itemId>)*" from the stream.
void parseFilters(std::istringstream& in,
std::vector<ItemType>& filterA,
std::vector<ItemType>& filterB,
bool& ok)
{
int countA = 0;
if (!(in >> countA) || countA < 0) { ok = false; return; }
for (int i = 0; i < countA; ++i)
{
std::string id;
if (!(in >> id)) { ok = false; return; }
filterA.push_back(ItemType{id});
}
int countB = 0;
if (!(in >> countB) || countB < 0) { ok = false; return; }
for (int i = 0; i < countB; ++i)
{
std::string id;
if (!(in >> id)) { ok = false; return; }
filterB.push_back(ItemType{id});
}
}
// "<count> (<moduleId> <x> <y> <rot>)*" // "<count> (<moduleId> <x> <y> <rot>)*"
void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout) void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout)
{ {
@@ -132,3 +186,129 @@ std::string serializeCommand(const Command& command)
return out.str(); return out.str();
} }
std::shared_ptr<Command> parseCommand(const std::string& tokens)
{
std::istringstream in(tokens);
std::string verb;
if (!(in >> verb))
{
return nullptr;
}
bool ok = true;
if (verb == "place")
{
std::shared_ptr<PlaceBuildingCommand> c = std::make_shared<PlaceBuildingCommand>();
std::string typeToken;
std::string rotToken;
int x = 0;
int y = 0;
if (!(in >> typeToken >> x >> y >> rotToken)) { return nullptr; }
const std::optional<BuildingType> type = parseBuildingType(typeToken);
if (!type.has_value()) { return nullptr; }
c->type = *type;
c->anchor = QPoint(x, y);
c->rotation = rotationFromString(rotToken);
std::string segment;
while (in >> segment)
{
if (segment == "recipe")
{
std::string id;
if (!(in >> id)) { return nullptr; }
c->recipeId = id;
}
else if (segment == "layout")
{
c->shipLayout = parseLayout(in, ok);
if (!ok) { return nullptr; }
}
else if (segment == "filters")
{
parseFilters(in, c->splitterFilterA, c->splitterFilterB, ok);
if (!ok) { return nullptr; }
c->hasSplitterFilters = true;
}
else
{
return nullptr;
}
}
return c;
}
if (verb == "demolish")
{
std::shared_ptr<DemolishCommand> c = std::make_shared<DemolishCommand>();
if (!(in >> c->id)) { return nullptr; }
return c;
}
if (verb == "rotate")
{
std::shared_ptr<RotateInPlaceCommand> c = std::make_shared<RotateInPlaceCommand>();
std::string rotToken;
if (!(in >> c->id >> rotToken)) { return nullptr; }
c->newRotation = rotationFromString(rotToken);
return c;
}
if (verb == "setrecipe")
{
std::shared_ptr<SetRecipeCommand> c = std::make_shared<SetRecipeCommand>();
if (!(in >> c->id >> c->recipeId)) { return nullptr; }
return c;
}
if (verb == "setlayout")
{
std::shared_ptr<SetShipLayoutCommand> c = std::make_shared<SetShipLayoutCommand>();
if (!(in >> c->id)) { return nullptr; }
c->layout = parseLayout(in, ok);
if (!ok) { return nullptr; }
return c;
}
if (verb == "sitefilters")
{
std::shared_ptr<SetSiteSplitterFiltersCommand> c =
std::make_shared<SetSiteSplitterFiltersCommand>();
if (!(in >> c->id)) { return nullptr; }
parseFilters(in, c->filterA, c->filterB, ok);
if (!ok) { return nullptr; }
return c;
}
if (verb == "splitterfilters")
{
std::shared_ptr<SetSplitterFiltersCommand> c =
std::make_shared<SetSplitterFiltersCommand>();
int x = 0;
int y = 0;
if (!(in >> x >> y)) { return nullptr; }
c->tile = QPoint(x, y);
parseFilters(in, c->filterA, c->filterB, ok);
if (!ok) { return nullptr; }
return c;
}
if (verb == "clearbelt")
{
std::shared_ptr<ClearBeltTilesCommand> c = std::make_shared<ClearBeltTilesCommand>();
int count = 0;
if (!(in >> count) || count < 0) { return nullptr; }
for (int i = 0; i < count; ++i)
{
int x = 0;
int y = 0;
if (!(in >> x >> y)) { return nullptr; }
c->tiles.push_back(QPoint(x, y));
}
return c;
}
if (verb == "schematic")
{
std::shared_ptr<ApplySchematicChoiceCommand> c =
std::make_shared<ApplySchematicChoiceCommand>();
if (!(in >> c->choiceIndex)) { return nullptr; }
return c;
}
return nullptr;
}

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <memory>
#include <string> #include <string>
struct Command; struct Command;
@@ -13,3 +14,8 @@ struct Command;
// Reset is a file boundary (it rolls the replay file), not a stream entry, so it // Reset is a file boundary (it rolls the replay file), not a stream entry, so it
// is never serialized here. // is never serialized here.
std::string serializeCommand(const Command& command); std::string serializeCommand(const Command& command);
// Inverse of serializeCommand: parses the command token sequence (the part of a
// replay line after the leading tick) back into a Command. Returns nullptr if the
// tokens are malformed or reference an unknown building type.
std::shared_ptr<Command> parseCommand(const std::string& tokens);

View File

@@ -0,0 +1,56 @@
#include "ReplayPlayer.h"
#include <utility>
#include "Command.h"
#include "Simulation.h"
ReplayPlayer::ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries)
: m_simulation(simulation)
, m_entries(std::move(entries))
{
}
void ReplayPlayer::start()
{
processEntriesAt(0);
}
void ReplayPlayer::advanceTo(Tick tick)
{
processEntriesAt(tick);
}
bool ReplayPlayer::isFinished() const
{
return m_desyncTick.has_value() || m_cursor >= m_entries.size();
}
std::optional<Tick> ReplayPlayer::getDesyncTick() const
{
return m_desyncTick;
}
void ReplayPlayer::processEntriesAt(Tick tick)
{
// Consume entries for this tick in file order. The recording writes, at a
// given tick: the periodic checksum (if any) first, then command lines each
// followed by their post-apply checksum — so applying/verifying in file order
// reproduces the original sequence exactly.
while (!m_desyncTick.has_value()
&& m_cursor < m_entries.size()
&& m_entries[m_cursor].tick == tick)
{
const ReplayEntry& entry = m_entries[m_cursor];
++m_cursor;
if (entry.isCommand)
{
m_simulation.apply(*entry.command);
}
else if (m_simulation.rngFingerprint() != entry.fingerprint)
{
m_desyncTick = tick;
}
}
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include <cstddef>
#include <optional>
#include <vector>
#include "ReplayReader.h"
#include "Tick.h"
class Simulation;
// Drives playback of a parsed replay against a Simulation: applies each recorded
// command at its recorded tick (through Simulation::apply) and verifies the RNG
// checksums, reporting the first desync. Entries are consumed in file order,
// which reproduces the exact command/tick interleaving of the original run.
//
// Cadence (mirrors how the run was recorded so checksums line up):
// player.start(); // process tick-0 entries before the first tick
// each frame, for each tick to run:
// if (player.isFinished()) break;
// sim.tick();
// player.advanceTo(sim.currentTick());
class ReplayPlayer
{
public:
ReplayPlayer(Simulation& simulation, std::vector<ReplayEntry> entries);
// Process all entries recorded at tick 0 (the initial checksum and any
// commands issued before the first tick). Call once, before the first tick.
void start();
// Process all entries recorded at `tick`. Call once after each sim.tick().
void advanceTo(Tick tick);
// True once all entries are consumed or a desync was detected.
bool isFinished() const;
// The tick at which the recomputed checksum first diverged, if any.
std::optional<Tick> getDesyncTick() const;
private:
void processEntriesAt(Tick tick);
Simulation& m_simulation;
std::vector<ReplayEntry> m_entries;
std::size_t m_cursor = 0;
std::optional<Tick> m_desyncTick;
};

View File

@@ -0,0 +1,145 @@
#include "ReplayReader.h"
#include <fstream>
#include <sstream>
#include <string>
#include "Command.h"
#include "CommandSerializer.h"
namespace
{
void stripCarriageReturn(std::string& line)
{
if (!line.empty() && line.back() == '\r')
{
line.pop_back();
}
}
// Splits "key value with spaces" into (key, remainder). Remainder may be empty.
void splitKeyValue(const std::string& line, std::string& key, std::string& value)
{
const std::string::size_type space = line.find(' ');
if (space == std::string::npos)
{
key = line;
value.clear();
}
else
{
key = line.substr(0, space);
value = line.substr(space + 1);
}
}
} // namespace
std::optional<ParsedReplay> readReplayFile(const std::string& path)
{
std::ifstream stream(path, std::ios::in);
if (!stream.is_open())
{
return std::nullopt;
}
ParsedReplay replay;
std::string line;
// --- Header (up to the "---" separator) ---
bool sawSeparator = false;
while (std::getline(stream, line))
{
stripCarriageReturn(line);
if (line == "---")
{
sawSeparator = true;
break;
}
if (line.empty() || line[0] == '#')
{
continue; // banner / blank
}
std::string key;
std::string value;
splitKeyValue(line, key, value);
try
{
if (key == "version") { replay.header.version = std::stoi(value); }
else if (key == "build") { replay.header.build = value; }
else if (key == "seed") { replay.header.seed = static_cast<unsigned int>(std::stoul(value)); }
else if (key == "config_hash") { replay.header.configHash = value; }
else if (key == "timestamp") { replay.header.timestamp = value; }
}
catch (const std::exception&)
{
return std::nullopt;
}
}
if (!sawSeparator)
{
return std::nullopt;
}
// --- Command / checksum stream ---
while (std::getline(stream, line))
{
stripCarriageReturn(line);
if (line.empty())
{
continue;
}
if (line[0] == '#')
{
// "# checksum <tick> <hex>"
std::istringstream in(line);
std::string hash;
std::string keyword;
ReplayEntry entry;
in >> hash >> keyword >> entry.tick >> hash;
if (keyword != "checksum")
{
continue; // unknown comment line — ignore
}
try
{
entry.fingerprint = std::stoull(hash, nullptr, 16);
}
catch (const std::exception&)
{
return std::nullopt;
}
entry.isCommand = false;
replay.entries.push_back(std::move(entry));
continue;
}
// "<tick> <serialized command>"
const std::string::size_type space = line.find(' ');
if (space == std::string::npos)
{
return std::nullopt;
}
ReplayEntry entry;
try
{
entry.tick = std::stoll(line.substr(0, space));
}
catch (const std::exception&)
{
return std::nullopt;
}
std::shared_ptr<Command> command = parseCommand(line.substr(space + 1));
if (!command)
{
return std::nullopt;
}
entry.isCommand = true;
entry.command = std::move(command);
replay.entries.push_back(std::move(entry));
}
return replay;
}

View File

@@ -0,0 +1,41 @@
#pragma once
#include <cstdint>
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include "Tick.h"
struct Command;
struct ReplayHeader
{
int version = 0;
std::string build;
unsigned int seed = 0;
std::string configHash;
std::string timestamp;
};
// One entry of the replay stream: either a command (applied at its tick) or an
// RNG checksum (verified at its tick). Entries are kept in file order, which is
// the canonical order the playback driver reproduces.
struct ReplayEntry
{
Tick tick = 0;
bool isCommand = false;
std::shared_ptr<const Command> command; // set iff isCommand
std::uint64_t fingerprint = 0; // set iff !isCommand
};
struct ParsedReplay
{
ReplayHeader header;
std::vector<ReplayEntry> entries;
};
// Parses a replay file (header + command/checksum stream). Returns nullopt on an
// I/O failure or malformed content (e.g. an unparseable command line).
std::optional<ParsedReplay> readReplayFile(const std::string& path);

View File

@@ -42,10 +42,10 @@ ReplayRecorder::~ReplayRecorder()
close(); close();
} }
std::string ReplayRecorder::computeConfigHash() const std::string computeReplayConfigHash(const std::string& configDir)
{ {
Hasher hasher; Hasher hasher;
QDir dir(QString::fromStdString(m_configDir)); QDir dir(QString::fromStdString(configDir));
const QStringList files = const QStringList files =
dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name); dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name);
for (const QString& name : files) for (const QString& name : files)
@@ -80,7 +80,7 @@ void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFing
m_stream << "version " << kReplayFormatVersion << "\n"; m_stream << "version " << kReplayFormatVersion << "\n";
m_stream << "build " << kBuildTag << "\n"; m_stream << "build " << kBuildTag << "\n";
m_stream << "seed " << seed << "\n"; m_stream << "seed " << seed << "\n";
m_stream << "config_hash " << computeConfigHash() << "\n"; m_stream << "config_hash " << computeReplayConfigHash(m_configDir) << "\n";
m_stream << "timestamp " m_stream << "timestamp "
<< QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n"; << QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n";
m_stream << "---\n"; m_stream << "---\n";

View File

@@ -8,6 +8,10 @@
struct Command; struct Command;
// 64-bit hash (16-char hex) over the *.toml files in configDir. Stored in the
// replay header and recomputed on playback to detect a config mismatch.
std::string computeReplayConfigHash(const std::string& configDir);
// Writes a replay file as the game runs (see docs/replay_design.md). The format // Writes a replay file as the game runs (see docs/replay_design.md). The format
// is line-oriented append-friendly text: a small keyed header, then one line per // is line-oriented append-friendly text: a small keyed header, then one line per
// command (tick-tagged) interleaved with RNG-state checksum lines for desync // command (tick-tagged) interleaved with RNG-state checksum lines for desync
@@ -43,9 +47,6 @@ public:
const std::string& currentFilePath() const; const std::string& currentFilePath() const;
private: private:
// 64-bit hash over the *.toml files in m_configDir, as a 16-char hex string.
std::string computeConfigHash() const;
std::string m_configDir; std::string m_configDir;
std::string m_outputDir; std::string m_outputDir;
std::string m_filePath; std::string m_filePath;

View File

@@ -24,4 +24,5 @@ add_files(
DeterminismTest.cpp DeterminismTest.cpp
CommandTest.cpp CommandTest.cpp
ReplayRecorderTest.cpp ReplayRecorderTest.cpp
ReplayPlaybackTest.cpp
) )

View File

@@ -0,0 +1,151 @@
#include "catch.hpp"
#include <memory>
#include <string>
#include <QDir>
#include <QFile>
#include "Command.h"
#include "CommandManager.h"
#include "CommandSerializer.h"
#include "ConfigLoader.h"
#include "GameConfig.h"
#include "ReplayPlayer.h"
#include "ReplayReader.h"
#include "ReplayRecorder.h"
#include "Rotation.h"
#include "Simulation.h"
namespace
{
GameConfig loadConfig()
{
return ConfigLoader::loadFromDirectory(CONFIG_DIR);
}
std::string tempOutputDir()
{
return (QDir::tempPath() + "/dota_factory_replay_playback_test").toStdString();
}
std::shared_ptr<PlaceBuildingCommand> place(BuildingType type, QPoint anchor)
{
std::shared_ptr<PlaceBuildingCommand> command = std::make_shared<PlaceBuildingCommand>();
command->type = type;
command->anchor = anchor;
return command;
}
} // namespace
// ---------------------------------------------------------------------------
// Round-trip
// ---------------------------------------------------------------------------
TEST_CASE("parseCommand inverts serializeCommand", "[replay]")
{
PlaceBuildingCommand placeCommand;
placeCommand.type = BuildingType::Shipyard;
placeCommand.anchor = QPoint(-3, 2);
placeCommand.rotation = Rotation::West;
placeCommand.recipeId = "some_ship";
ShipLayoutConfig layout;
layout.placedModules.push_back(PlacedModule{"weapon_basic", QPoint(1, 0), Rotation::North});
placeCommand.shipLayout = layout;
const std::string text = serializeCommand(placeCommand);
const std::shared_ptr<Command> parsed = parseCommand(text);
REQUIRE(parsed != nullptr);
REQUIRE(serializeCommand(*parsed) == text);
}
TEST_CASE("parseCommand round-trips every command verb", "[replay]")
{
SetSplitterFiltersCommand filters;
filters.tile = QPoint(3, 9);
filters.filterA = { ItemType{"iron_ore"} };
filters.filterB = { ItemType{"coal"}, ItemType{"copper_ore"} };
ClearBeltTilesCommand clear;
clear.tiles = { QPoint(0, 0), QPoint(-1, 4) };
DemolishCommand demolish;
demolish.id = 5;
for (const Command* command : { static_cast<const Command*>(&filters),
static_cast<const Command*>(&clear),
static_cast<const Command*>(&demolish) })
{
const std::string text = serializeCommand(*command);
const std::shared_ptr<Command> parsed = parseCommand(text);
REQUIRE(parsed != nullptr);
REQUIRE(serializeCommand(*parsed) == text);
}
}
TEST_CASE("parseCommand rejects malformed input", "[replay]")
{
REQUIRE(parseCommand("") == nullptr);
REQUIRE(parseCommand("place not_a_type 0 0 E") == nullptr);
REQUIRE(parseCommand("place miner 0 0 E bogus") == nullptr);
REQUIRE(parseCommand("nonsense 1 2 3") == nullptr);
}
// ---------------------------------------------------------------------------
// Record -> read -> replay equivalence
// ---------------------------------------------------------------------------
TEST_CASE("a recorded run replays to byte-identical state with no desync", "[replay]")
{
const unsigned int seed = 314159u;
// --- Record a scripted run, mimicking the frame cadence (drain, then ticks). ---
std::string replayPath;
std::uint64_t recordedFinalChecksum = 0;
{
Simulation rec(loadConfig(), seed);
CommandManager manager(rec);
std::unique_ptr<ReplayRecorder> recorder =
std::make_unique<ReplayRecorder>(CONFIG_DIR, tempOutputDir());
ReplayRecorder* recorderPtr = recorder.get();
manager.setRecorder(std::move(recorder));
// Frame at tick 0: place a miner.
manager.enqueue(place(BuildingType::Miner, QPoint(-3, 0)));
manager.drain();
for (int i = 0; i < 90; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
// Frame at tick 90 (a checksum boundary): place a belt — exercises the
// periodic-checksum-then-command ordering at one tick.
manager.enqueue(place(BuildingType::Belt, QPoint(-2, 0)));
manager.drain();
for (int i = 0; i < 60; ++i) { rec.tick(); manager.recordTickCheckpoint(); }
replayPath = recorderPtr->currentFilePath();
recordedFinalChecksum = rec.computeStateChecksum();
manager.setRecorder(nullptr); // close the file
}
// --- Read it back. ---
const std::optional<ParsedReplay> parsed = readReplayFile(replayPath);
REQUIRE(parsed.has_value());
REQUIRE(parsed->header.seed == seed);
REQUIRE(parsed->header.version == 1);
REQUIRE_FALSE(parsed->entries.empty());
// --- Replay it. ---
Simulation play(loadConfig(), parsed->header.seed);
ReplayPlayer player(play, parsed->entries);
player.start();
while (!player.isFinished())
{
play.tick();
player.advanceTo(play.currentTick());
}
REQUIRE_FALSE(player.getDesyncTick().has_value());
REQUIRE(play.currentTick() == 150);
REQUIRE(play.computeStateChecksum() == recordedFinalChecksum);
QFile::remove(QString::fromStdString(replayPath));
}

View File

@@ -27,6 +27,8 @@
#include "Building.h" #include "Building.h"
#include "BuildingSystem.h" #include "BuildingSystem.h"
#include "Command.h" #include "Command.h"
#include "ReplayPlayer.h"
#include "ReplayReader.h"
#include "ReplayRecorder.h" #include "ReplayRecorder.h"
#include "DemolishModeChangedEvent.h" #include "DemolishModeChangedEvent.h"
#include "EntityHitTest.h" #include "EntityHitTest.h"
@@ -117,7 +119,7 @@ QPoint portBodyTile(QPoint portTile, Rotation direction)
GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config, GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, const std::string& configDir, const VisualsConfig* visuals, const std::string& configDir,
QWidget* parent) const ParsedReplay* replay, QWidget* parent)
: QOpenGLWidget(parent) : QOpenGLWidget(parent)
, m_sim(sim) , m_sim(sim)
, m_config(config) , m_config(config)
@@ -150,13 +152,24 @@ GameWorldView::GameWorldView(Simulation* sim, const GameConfig* config,
registerForEvents(); registerForEvents();
// Record every run to disk. Replays live under <data>/replays, alongside the if (replay)
// config dir that was loaded. Attaching the recorder opens the first file and {
// writes the header for the initial run. // 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)); QDir replayDir(QString::fromStdString(configDir));
replayDir.cdUp(); replayDir.cdUp();
m_commandManager.setRecorder(std::make_unique<ReplayRecorder>( m_commandManager.setRecorder(std::make_unique<ReplayRecorder>(
configDir, replayDir.filePath("replays").toStdString())); configDir, replayDir.filePath("replays").toStdString()));
}
} }
GameWorldView::~GameWorldView() GameWorldView::~GameWorldView()
@@ -173,6 +186,21 @@ void GameWorldView::onFrame()
{ {
const qint64 elapsed = m_frameTimer.restart(); const qint64 elapsed = m_frameTimer.restart();
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 // Drain queued player commands once per frame, before the tick batch. This
// runs even at 0x so a paused player sees placed construction sites // runs even at 0x so a paused player sees placed construction sites
// immediately, while staying deterministic (see docs/replay_design.md). // immediately, while staying deterministic (see docs/replay_design.md).
@@ -185,8 +213,6 @@ void GameWorldView::onFrame()
resetForNewGame(); resetForNewGame();
} }
// Advance simulation
{
const int ticks = m_tickDriver.advance( const int ticks = m_tickDriver.advance(
static_cast<double>(elapsed), m_gameSpeedMultiplier); static_cast<double>(elapsed), m_gameSpeedMultiplier);
for (int i = 0; i < ticks; ++i) for (int i = 0; i < ticks; ++i)
@@ -267,6 +293,11 @@ void GameWorldView::onFrame()
} }
} }
// 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)
{
// Schematic choice available // Schematic choice available
if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown) if (m_sim->hasSchematicChoicesPending() && !m_schematicChoiceShown)
{ {
@@ -287,6 +318,7 @@ void GameWorldView::onFrame()
EventManager::getInstance()->sendEventImmediately( EventManager::getInstance()->sendEventImmediately(
std::make_shared<GameOverEvent>()); std::make_shared<GameOverEvent>());
} }
}
update(); update();
} }
@@ -311,6 +343,7 @@ void GameWorldView::paintGL()
drawBeams(painter); drawBeams(painter);
drawOverlays(painter); drawOverlays(painter);
drawScreenSpace(painter); drawScreenSpace(painter);
drawReplayOverlay(painter);
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -1246,6 +1279,42 @@ 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())
{
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 // Input
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -41,6 +41,8 @@
#include "VisualsConfig.h" #include "VisualsConfig.h"
struct Command; struct Command;
struct ParsedReplay;
class ReplayPlayer;
class Simulation; class Simulation;
class QPainter; class QPainter;
@@ -68,7 +70,7 @@ class GameWorldView : public QOpenGLWidget,
public: public:
GameWorldView(Simulation* sim, const GameConfig* config, GameWorldView(Simulation* sim, const GameConfig* config,
const VisualsConfig* visuals, const std::string& configDir, const VisualsConfig* visuals, const std::string& configDir,
QWidget* parent = nullptr); const ParsedReplay* replay, QWidget* parent = nullptr);
~GameWorldView() override; ~GameWorldView() override;
double gameSpeed() const; double gameSpeed() const;
@@ -121,6 +123,7 @@ private:
void drawBeams(QPainter& painter); void drawBeams(QPainter& painter);
void drawOverlays(QPainter& painter); void drawOverlays(QPainter& painter);
void drawScreenSpace(QPainter& painter); void drawScreenSpace(QPainter& painter);
void drawReplayOverlay(QPainter& painter);
float tilePx() const; float tilePx() const;
float viewportWidthTiles() const; float viewportWidthTiles() const;
@@ -178,6 +181,9 @@ private:
CommandManager m_commandManager; CommandManager m_commandManager;
// A Reset command was enqueued; reset the view after the next drain applies it. // A Reset command was enqueued; reset the view after the next drain applies it.
bool m_viewResetPending = false; bool m_viewResetPending = false;
// Non-null => view-only playback: ticks are driven by the recorded stream and
// live input is ignored (the CommandManager is in replay mode).
std::unique_ptr<ReplayPlayer> m_replayPlayer;
TickDriver m_tickDriver; TickDriver m_tickDriver;
QElapsedTimer m_frameTimer; QElapsedTimer m_frameTimer;

View File

@@ -31,18 +31,21 @@
#include "Tick.h" #include "Tick.h"
#include "VisualsLoader.h" #include "VisualsLoader.h"
MainWindow::MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent) MainWindow::MainWindow(Simulation* sim, const std::string& configDir,
std::shared_ptr<ParsedReplay> replay, QWidget* parent)
: QWidget(parent) : QWidget(parent)
, m_configDir(configDir) , m_configDir(configDir)
, m_visuals(VisualsLoader::load(configDir + "/visuals.toml")) , m_visuals(VisualsLoader::load(configDir + "/visuals.toml"))
, m_sim(sim) , m_sim(sim)
, m_replay(std::move(replay))
{ {
setWindowTitle(tr("Dota Factory")); setWindowTitle(tr("Dota Factory"));
resize(1280, 768); resize(1280, 768);
m_headerBar = new HeaderBar(this); m_headerBar = new HeaderBar(this);
m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir, this); m_gameWorldView = new GameWorldView(sim, &sim->config(), &m_visuals, m_configDir,
m_replay.get(), this);
m_sidePanel = new QWidget(this); m_sidePanel = new QWidget(this);
QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel); QVBoxLayout* sideLayout = new QVBoxLayout(m_sidePanel);

View File

@@ -1,5 +1,6 @@
#pragma once #pragma once
#include <memory>
#include <string> #include <string>
#include <vector> #include <vector>
@@ -17,6 +18,7 @@
#include "Tick.h" #include "Tick.h"
#include "VisualsConfig.h" #include "VisualsConfig.h"
struct ParsedReplay;
class Simulation; class Simulation;
class GameWorldView; class GameWorldView;
class HeaderBar; class HeaderBar;
@@ -37,7 +39,8 @@ class MainWindow : public QWidget,
Q_OBJECT Q_OBJECT
public: public:
MainWindow(Simulation* sim, const std::string& configDir, QWidget* parent = nullptr); MainWindow(Simulation* sim, const std::string& configDir,
std::shared_ptr<ParsedReplay> replay = nullptr, QWidget* parent = nullptr);
~MainWindow() override; ~MainWindow() override;
protected: protected:
@@ -65,4 +68,5 @@ private:
QWidget* m_sidePanel; QWidget* m_sidePanel;
std::vector<ShipLayoutBlueprint> m_layoutBlueprints; std::vector<ShipLayoutBlueprint> m_layoutBlueprints;
std::shared_ptr<ParsedReplay> m_replay; // non-null => view-only playback
}; };