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

@@ -27,6 +27,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ArenaInspectRequestedEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/BeamFiredEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/DebugDrawToggledEvent.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandRequestedEvent.h
PARENT_SCOPE
)

View File

@@ -0,0 +1,23 @@
#pragma once
#include <memory>
#include "Event.h"
struct Command;
// UI fan-in for the command path: widgets emit this with a built command, and a
// single subscriber (GameWorldView) enqueues it onto the CommandManager. This
// keeps widgets decoupled (consistent with the rest of the UI), while the
// sim-mutating command itself is routed through the dedicated CommandManager
// queue rather than the EventManager bus (see docs/replay_design.md).
class CommandRequestedEvent : public Event
{
public:
explicit CommandRequestedEvent(std::shared_ptr<const Command> command)
: command(std::move(command))
{
}
const std::shared_ptr<const Command> command;
};

View File

@@ -2,6 +2,7 @@
#include <algorithm>
#include "StateChecksum.h"
#include "Tick.h"
#include "tracing.h"
@@ -970,4 +971,91 @@ void BeltSystem::forEachVisualItem(QRect viewportTiles,
}
}
void BeltSystem::appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun)
{
hasher.append(slotRun.size());
for (const BeltItemSlot& slot : slotRun)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
void BeltSystem::appendChecksum(Hasher& hasher) const
{
// std::map iterates in sorted key order, so all tile loops are deterministic.
hasher.append(m_belts.size());
for (const std::pair<const std::pair<int, int>, BeltTile>& entry : m_belts)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second.direction);
appendItemSlots(hasher, entry.second.itemSlots);
}
hasher.append(m_splitters.size());
for (const std::pair<const std::pair<int, int>, SplitterTile>& entry : m_splitters)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
const SplitterTile& s = entry.second;
hasher.append(s.outputA);
hasher.append(s.outputB);
hasher.append(s.filterA.size());
for (const ItemType& type : s.filterA) { hasher.append(type.id); }
hasher.append(s.filterB.size());
for (const ItemType& type : s.filterB) { hasher.append(type.id); }
hasher.append(s.nextOutputIsA);
appendItemSlots(hasher, s.back);
hasher.append(s.backDir.size());
for (Rotation dir : s.backDir) { hasher.append(dir); }
hasher.append(s.frontA.has_value());
if (s.frontA.has_value())
{
hasher.append(s.frontA->item.type.id);
hasher.append(s.frontA->progress);
}
hasher.append(s.frontB.has_value());
if (s.frontB.has_value())
{
hasher.append(s.frontB->item.type.id);
hasher.append(s.frontB->progress);
}
}
hasher.append(m_tunnelEntries.size());
for (const std::pair<const std::pair<int, int>, TunnelEntryTile>& entry : m_tunnelEntries)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second.direction);
hasher.append(entry.second.maxDistance);
appendItemSlots(hasher, entry.second.itemSlots);
}
hasher.append(m_tunnelExits.size());
for (const std::pair<const std::pair<int, int>, TunnelExitTile>& entry : m_tunnelExits)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second.direction);
appendItemSlots(hasher, entry.second.itemSlots);
}
// m_tunnelLinks preserves insertion order, which is itself deterministic.
hasher.append(m_tunnelLinks.size());
for (const TunnelLink& link : m_tunnelLinks)
{
hasher.append(link.entryTile);
hasher.append(link.exitTile);
hasher.append(link.length);
hasher.append(link.items.size());
for (const TunnelTransitItem& item : link.items)
{
hasher.append(item.item.type.id);
hasher.append(item.progress);
}
}
}

View File

@@ -15,6 +15,8 @@
#include "Port.h"
#include "Rotation.h"
class Hasher;
// Carries item type and fractional world position for the renderer.
// worldPos is in tile units (1 tile = 1.0 unit); origin matches tile coords.
struct VisualItem
@@ -92,6 +94,11 @@ public:
void forEachVisualItem(QRect viewportTiles,
std::function<void(VisualItem)> visit) const;
// -- Determinism ---------------------------------------------------------
// Folds all transport state (belt/splitter/tunnel tiles and their items)
// into the hasher in deterministic order (see docs/replay_design.md).
void appendChecksum(Hasher& hasher) const;
private:
void advanceProgress();
void advanceTunnelProgress();
@@ -170,6 +177,9 @@ private:
std::vector<TunnelTransitItem> items; // front (highest progress) to back
};
// Folds a run of item slots (front-to-back order is canonical) into the hasher.
static void appendItemSlots(Hasher& hasher, const std::vector<BeltItemSlot>& slotRun);
double m_progressPerTick_tpt; // beltSpeed_tps / kTickRateHz
std::map<std::pair<int, int>, BeltTile> m_belts;

View File

@@ -5,6 +5,7 @@
#include <random>
#include <set>
#include "StateChecksum.h"
#include "SurfaceMask.h"
#include "tracing.h"
@@ -1288,3 +1289,87 @@ void BuildingSystem::unregisterTileOccupancy(const std::vector<QPoint>& cells)
m_tileOccupancy.erase({cell.x(), cell.y()});
}
}
namespace
{
void appendItems(Hasher& hasher, const std::vector<Item>& items)
{
hasher.append(items.size());
for (const Item& item : items)
{
hasher.append(item.type.id);
}
}
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
{
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<).
hasher.append(buffer.counts.size());
for (const std::pair<const ItemType, int>& entry : buffer.counts)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
hasher.append(buffer.caps.size());
for (const std::pair<const ItemType, int>& entry : buffer.caps)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
}
} // namespace
void BuildingSystem::appendChecksum(Hasher& hasher) const
{
// m_buildings keeps a stable, deterministic order (append on build, swap-free
// erase aside — both runs perform identical operations, so order matches).
hasher.append(m_buildings.size());
for (const Building& b : m_buildings)
{
hasher.append(b.id);
hasher.append(b.anchor);
hasher.append(b.footprint.width());
hasher.append(b.footprint.height());
hasher.append(b.rotation);
hasher.append(b.type);
hasher.append(b.recipeId);
appendInputBuffer(hasher, b.inputBuffer);
appendItems(hasher, b.outputBuffer.items);
hasher.append(b.outputBuffer.capacity);
hasher.append(b.production.has_value());
if (b.production.has_value())
{
hasher.append(b.production->recipeId);
hasher.append(b.production->completesAt);
appendItems(hasher, b.production->chosenOutputs);
}
hasher.append(b.shipLayout.has_value());
}
hasher.append(m_constructionQueue.size());
for (const ConstructionSite& s : m_constructionQueue)
{
hasher.append(s.id);
hasher.append(s.anchor);
hasher.append(s.footprint.width());
hasher.append(s.footprint.height());
hasher.append(s.rotation);
hasher.append(s.type);
hasher.append(s.recipeId);
hasher.append(s.completesAt);
hasher.append(s.shipLayout.has_value());
hasher.append(s.splitterFilterA.size());
for (const ItemType& type : s.splitterFilterA) { hasher.append(type.id); }
hasher.append(s.splitterFilterB.size());
for (const ItemType& type : s.splitterFilterB) { hasher.append(type.id); }
}
// std::map iterates in sorted key order.
hasher.append(m_tileOccupancy.size());
for (const std::pair<const std::pair<int, int>, BuildingId>& entry : m_tileOccupancy)
{
hasher.append(entry.first.first);
hasher.append(entry.first.second);
hasher.append(entry.second);
}
}

View File

@@ -23,6 +23,8 @@
#include "ShipsConfig.h"
#include "Tick.h"
class Hasher;
// Manages building placement, construction queuing, and the per-tick
// production loop (belt→building pull, production, building→belt push).
// All types including Belt and Splitter are stored as Building instances;
@@ -151,6 +153,11 @@ public:
// Mutable iteration over all operational buildings.
void forEachBuilding(std::function<void(Building&)> fn);
// -- Determinism ---------------------------------------------------------
// Folds all building, construction-site, and tile-occupancy state into the
// hasher in deterministic order (see docs/replay_design.md).
void appendChecksum(Hasher& hasher) const;
private:
const BuildingDef* findBuildingDef(BuildingType type) const;
const RecipeDef* findRecipe(const std::string& id, BuildingType type) const;

View File

@@ -1,6 +1,12 @@
SET(HDRS
${HDRS}
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.h
${CMAKE_CURRENT_SOURCE_DIR}/Command.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.h
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.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}/BeltSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/Building.h
@@ -9,6 +15,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayout.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipLayoutBlueprint.h
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.h
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.h
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.h
PARENT_SCOPE
@@ -17,11 +24,17 @@ SET(HDRS
SET(SRCS
${SRCS}
${CMAKE_CURRENT_SOURCE_DIR}/Simulation.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CommandManager.cpp
${CMAKE_CURRENT_SOURCE_DIR}/CommandSerializer.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}/BeltSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/EntityHitTest.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ShipStatsCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/StateChecksum.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ThreatCostCalculator.cpp
${CMAKE_CURRENT_SOURCE_DIR}/WaveSystem.cpp
PARENT_SCOPE

141
src/lib/sim/Command.h Normal file
View File

@@ -0,0 +1,141 @@
#pragma once
#include <memory>
#include <optional>
#include <string>
#include <vector>
#include <QPoint>
#include "BuildingId.h"
#include "BuildingType.h"
#include "ItemType.h"
#include "Rotation.h"
#include "ShipLayout.h"
class GameConfig;
// Player intent, resolved to domain ids / tile coordinates and serializable, that
// mutates the simulation. Every sim mutation during play flows through a Command
// applied at the single Simulation::apply chokepoint, so it can be recorded and
// replayed (see docs/replay_design.md).
//
// Commands form a closed set dispatched by kind. They reference stable,
// deterministic ids (BuildingId, tile coordinates, choice indices) — never raw
// entt handles — so a recorded command resolves to the same target on replay.
enum class CommandKind
{
PlaceBuilding,
Demolish,
RotateInPlace,
SetRecipe,
SetShipLayout,
SetSiteSplitterFilters,
SetSplitterFilters,
ClearBeltTiles,
ApplySchematicChoice,
Reset
};
struct Command
{
explicit Command(CommandKind kind) : kind(kind) {}
virtual ~Command() = default;
CommandKind kind;
// Source of the command. Always 0 in single-player; lockstep multiplayer
// later merges commands from multiple sources into one ordered stream.
int playerId = 0;
};
// Places a building and, atomically, configures it. The configuration fields are
// bundled here (rather than as follow-up commands) because commands are applied
// at a deferred tick boundary, so the caller never sees the new BuildingId — the
// place-and-configure must happen as one unit inside apply().
struct PlaceBuildingCommand : Command
{
PlaceBuildingCommand() : Command(CommandKind::PlaceBuilding) {}
BuildingType type = BuildingType::Miner;
QPoint anchor;
Rotation rotation = Rotation::East;
// Optional configuration applied to the freshly placed (still-construction)
// building. The caller (UI) is responsible for unlock/validity pre-filtering;
// only fields that should apply are set.
std::optional<std::string> recipeId;
std::optional<ShipLayoutConfig> shipLayout;
bool hasSplitterFilters = false;
std::vector<ItemType> splitterFilterA;
std::vector<ItemType> splitterFilterB;
};
struct DemolishCommand : Command
{
DemolishCommand() : Command(CommandKind::Demolish) {}
BuildingId id = kInvalidBuildingId;
};
struct RotateInPlaceCommand : Command
{
RotateInPlaceCommand() : Command(CommandKind::RotateInPlace) {}
BuildingId id = kInvalidBuildingId;
Rotation newRotation = Rotation::East;
};
struct SetRecipeCommand : Command
{
SetRecipeCommand() : Command(CommandKind::SetRecipe) {}
BuildingId id = kInvalidBuildingId;
std::string recipeId;
};
struct SetShipLayoutCommand : Command
{
SetShipLayoutCommand() : Command(CommandKind::SetShipLayout) {}
BuildingId id = kInvalidBuildingId;
ShipLayoutConfig layout;
};
// Splitter filters for a queued / under-construction Splitter site (configured by
// BuildingSystem before the splitter is registered with BeltSystem).
struct SetSiteSplitterFiltersCommand : Command
{
SetSiteSplitterFiltersCommand() : Command(CommandKind::SetSiteSplitterFilters) {}
BuildingId id = kInvalidBuildingId;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};
// Splitter filters for an operational splitter, configured by tile via BeltSystem.
struct SetSplitterFiltersCommand : Command
{
SetSplitterFiltersCommand() : Command(CommandKind::SetSplitterFilters) {}
QPoint tile;
std::vector<ItemType> filterA;
std::vector<ItemType> filterB;
};
struct ClearBeltTilesCommand : Command
{
ClearBeltTilesCommand() : Command(CommandKind::ClearBeltTiles) {}
std::vector<QPoint> tiles;
};
struct ApplySchematicChoiceCommand : Command
{
ApplySchematicChoiceCommand() : Command(CommandKind::ApplySchematicChoice) {}
int choiceIndex = 0;
};
// Restart boundary: reinitializes the simulation with a fresh seed and, if
// config is set, a reloaded config (GameConfig is move-only, so it is carried by
// shared_ptr and moved into the sim on apply). A null config keeps the current
// config. One replay file = one run between Reset boundaries.
struct ResetCommand : Command
{
ResetCommand() : Command(CommandKind::Reset) {}
std::shared_ptr<GameConfig> config; // null = keep current config
unsigned int seed = 0;
};

View File

@@ -0,0 +1,92 @@
#include "CommandManager.h"
#include <utility>
#include "Command.h"
#include "ReplayRecorder.h"
#include "Simulation.h"
#include "Tick.h"
namespace
{
// Periodic RNG checksum cadence (see docs/replay_design.md "Cadence").
constexpr Tick kChecksumIntervalTicks = 30;
} // namespace
CommandManager::CommandManager(Simulation& simulation)
: m_simulation(simulation)
{
}
CommandManager::~CommandManager() = default;
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)
{
m_queue.push_back(std::move(command));
}
}
void CommandManager::drain()
{
// Apply in FIFO order. A queued ResetCommand reinitializes the simulation in
// place; the reference stays valid and any commands after it apply to the
// fresh state.
for (const std::shared_ptr<const Command>& command : m_queue)
{
if (command->kind == CommandKind::Reset)
{
m_simulation.apply(*command);
if (m_recorder)
{
// Restart is a file boundary: a fresh file with the new seed.
m_recorder->startNewRun(m_simulation.getSeed(),
m_simulation.rngFingerprint());
}
}
else
{
// Commands drain before the tick batch, so currentTick is the count of
// completed ticks the command is pinned to.
const Tick tick = m_simulation.currentTick();
m_simulation.apply(*command);
if (m_recorder)
{
m_recorder->recordCommand(tick, *command, m_simulation.rngFingerprint());
}
}
}
m_queue.clear();
}
bool CommandManager::hasPending() const
{
return !m_queue.empty();
}
void CommandManager::setRecorder(std::unique_ptr<ReplayRecorder> recorder)
{
m_recorder = std::move(recorder);
if (m_recorder)
{
m_recorder->startNewRun(m_simulation.getSeed(), m_simulation.rngFingerprint());
}
}
void CommandManager::setReplayMode(bool replayMode)
{
m_replayMode = replayMode;
}
void CommandManager::recordTickCheckpoint()
{
if (m_recorder && (m_simulation.currentTick() % kChecksumIntervalTicks == 0))
{
m_recorder->recordChecksum(m_simulation.currentTick(), m_simulation.rngFingerprint());
}
}

View File

@@ -0,0 +1,55 @@
#pragma once
#include <memory>
#include <vector>
struct Command;
class ReplayRecorder;
class Simulation;
// Ordered queue that funnels every player command into the single
// Simulation::apply chokepoint (see docs/replay_design.md). This is deliberately
// NOT the EventManager pub/sub bus: sim mutations must apply in a strict,
// tick-pinned, recordable order to a single recipient.
//
// Live play pushes commands via enqueue(); they are applied at the next drain(),
// which runs once per frame before the tick batch. Draining before the tick
// (even at 0x game speed) lets a paused player see placed construction sites
// immediately while staying deterministic — replay applies each command at its
// recorded tick regardless of frame cadence.
class CommandManager
{
public:
explicit CommandManager(Simulation& simulation);
// Defined out-of-line so the unique_ptr<ReplayRecorder> member can be
// destroyed where ReplayRecorder is a complete type.
~CommandManager();
// Append a command for application at the next drain (FIFO order).
void enqueue(std::shared_ptr<const Command> command);
// Apply all queued commands in FIFO order through Simulation::apply, then
// clear the queue. If a recorder is attached, each applied command is recorded
// (a Reset rolls the recorder to a new file).
void drain();
bool hasPending() const;
// Attach a recorder and start recording the current run. Ownership is taken.
// Passing nullptr detaches/stops recording.
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
// cadence. Call once per simulated tick (from the tick loop).
void recordTickCheckpoint();
private:
Simulation& m_simulation;
std::vector<std::shared_ptr<const Command>> m_queue;
std::unique_ptr<ReplayRecorder> m_recorder;
bool m_replayMode = false;
};

View File

@@ -0,0 +1,314 @@
#include "CommandSerializer.h"
#include <sstream>
#include <vector>
#include "BuildingType.h"
#include "Command.h"
#include "Rotation.h"
#include "ShipLayout.h"
namespace
{
char rotationToChar(Rotation rotation)
{
switch (rotation)
{
case Rotation::North: return 'N';
case Rotation::East: return 'E';
case Rotation::South: return 'S';
case Rotation::West: return 'W';
}
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>)*"
void appendLayout(std::ostringstream& out, const ShipLayoutConfig& layout)
{
out << layout.placedModules.size();
for (const PlacedModule& placed : layout.placedModules)
{
out << ' ' << placed.moduleId
<< ' ' << placed.position.x()
<< ' ' << placed.position.y()
<< ' ' << rotationToChar(placed.rotation);
}
}
// "<countA> (<itemId>)* <countB> (<itemId>)*"
void appendFilters(std::ostringstream& out,
const std::vector<ItemType>& filterA,
const std::vector<ItemType>& filterB)
{
out << filterA.size();
for (const ItemType& type : filterA) { out << ' ' << type.id; }
out << ' ' << filterB.size();
for (const ItemType& type : filterB) { out << ' ' << type.id; }
}
} // namespace
std::string serializeCommand(const Command& command)
{
std::ostringstream out;
switch (command.kind)
{
case CommandKind::PlaceBuilding:
{
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
out << "place " << buildingTypeId(c.type)
<< ' ' << c.anchor.x() << ' ' << c.anchor.y()
<< ' ' << rotationToChar(c.rotation);
if (c.recipeId.has_value())
{
out << " recipe " << *c.recipeId;
}
if (c.shipLayout.has_value())
{
out << " layout ";
appendLayout(out, *c.shipLayout);
}
if (c.hasSplitterFilters)
{
out << " filters ";
appendFilters(out, c.splitterFilterA, c.splitterFilterB);
}
break;
}
case CommandKind::Demolish:
out << "demolish " << static_cast<const DemolishCommand&>(command).id;
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
out << "rotate " << c.id << ' ' << rotationToChar(c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
out << "setrecipe " << c.id << ' ' << c.recipeId;
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
out << "setlayout " << c.id << ' ';
appendLayout(out, c.layout);
break;
}
case CommandKind::SetSiteSplitterFilters:
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
out << "sitefilters " << c.id << ' ';
appendFilters(out, c.filterA, c.filterB);
break;
}
case CommandKind::SetSplitterFilters:
{
const SetSplitterFiltersCommand& c =
static_cast<const SetSplitterFiltersCommand&>(command);
out << "splitterfilters " << c.tile.x() << ' ' << c.tile.y() << ' ';
appendFilters(out, c.filterA, c.filterB);
break;
}
case CommandKind::ClearBeltTiles:
{
const ClearBeltTilesCommand& c = static_cast<const ClearBeltTilesCommand&>(command);
out << "clearbelt " << c.tiles.size();
for (const QPoint& tile : c.tiles)
{
out << ' ' << tile.x() << ' ' << tile.y();
}
break;
}
case CommandKind::ApplySchematicChoice:
out << "schematic " << static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex;
break;
case CommandKind::Reset:
// A reset rolls the replay file; it is never written as a stream entry.
break;
}
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

@@ -0,0 +1,21 @@
#pragma once
#include <memory>
#include <string>
struct Command;
// Serializes a command to a single-line, space-delimited token sequence for the
// replay file (see docs/replay_design.md "File format: line-oriented ...").
// Config ids (building types, recipes, items, modules) are whitespace-free
// identifiers, so space delimiting is unambiguous; variable-length parts are
// length-prefixed so the matching parser (added in Phase 3) is unambiguous.
//
// Reset is a file boundary (it rolls the replay file), not a stream entry, so it
// is never serialized here.
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

@@ -0,0 +1,130 @@
#include "ReplayRecorder.h"
#include <iomanip>
#include <sstream>
#include <utility>
#include <QByteArray>
#include <QDateTime>
#include <QDir>
#include <QFile>
#include <QString>
#include <QStringList>
#include "Command.h"
#include "CommandSerializer.h"
#include "StateChecksum.h"
namespace
{
constexpr const char* kReplayFormatVersion = "1";
// Build fingerprint: even a new local build can desync old replays (float
// reasons), so the header carries a per-build tag to warn on mismatch.
const std::string kBuildTag = std::string(__DATE__) + " " + __TIME__;
std::string toHex(std::uint64_t value)
{
std::ostringstream out;
out << std::hex << std::setw(16) << std::setfill('0') << value;
return out.str();
}
} // namespace
ReplayRecorder::ReplayRecorder(std::string configDir, std::string outputDir)
: m_configDir(std::move(configDir))
, m_outputDir(std::move(outputDir))
{
}
ReplayRecorder::~ReplayRecorder()
{
close();
}
std::string computeReplayConfigHash(const std::string& configDir)
{
Hasher hasher;
QDir dir(QString::fromStdString(configDir));
const QStringList files =
dir.entryList(QStringList() << "*.toml", QDir::Files, QDir::Name);
for (const QString& name : files)
{
hasher.append(name.toStdString());
QFile file(dir.filePath(name));
if (file.open(QIODevice::ReadOnly))
{
const QByteArray bytes = file.readAll();
hasher.appendBytes(bytes.constData(), static_cast<std::size_t>(bytes.size()));
}
}
return toHex(hasher.value());
}
void ReplayRecorder::startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint)
{
close();
QDir().mkpath(QString::fromStdString(m_outputDir));
const QString timestamp = QDateTime::currentDateTime().toString("yyyyMMdd_HHmmss");
const QString fileName = timestamp + "_" + QString::number(seed) + ".replay";
m_filePath = QDir(QString::fromStdString(m_outputDir)).filePath(fileName).toStdString();
m_stream.open(m_filePath, std::ios::out | std::ios::trunc);
if (!m_stream.is_open())
{
return;
}
m_stream << "# dota_factory replay\n";
m_stream << "version " << kReplayFormatVersion << "\n";
m_stream << "build " << kBuildTag << "\n";
m_stream << "seed " << seed << "\n";
m_stream << "config_hash " << computeReplayConfigHash(m_configDir) << "\n";
m_stream << "timestamp "
<< QDateTime::currentDateTime().toString(Qt::ISODate).toStdString() << "\n";
m_stream << "---\n";
m_stream << "# checksum 0 " << toHex(initialRngFingerprint) << "\n";
m_stream.flush();
}
void ReplayRecorder::recordCommand(Tick tick, const Command& command,
std::uint64_t rngFingerprint)
{
if (!m_stream.is_open())
{
return;
}
m_stream << tick << ' ' << serializeCommand(command) << "\n";
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
m_stream.flush();
}
void ReplayRecorder::recordChecksum(Tick tick, std::uint64_t rngFingerprint)
{
if (!m_stream.is_open())
{
return;
}
m_stream << "# checksum " << tick << ' ' << toHex(rngFingerprint) << "\n";
m_stream.flush();
}
void ReplayRecorder::close()
{
if (m_stream.is_open())
{
m_stream.flush();
m_stream.close();
}
}
bool ReplayRecorder::isOpen() const
{
return m_stream.is_open();
}
const std::string& ReplayRecorder::currentFilePath() const
{
return m_filePath;
}

View File

@@ -0,0 +1,54 @@
#pragma once
#include <cstdint>
#include <fstream>
#include <string>
#include "Tick.h"
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
// 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
// detection. Each line is flushed so a crash mid-run still leaves a valid partial
// file.
//
// One file = one run between Reset boundaries; startNewRun() closes the current
// file and opens a fresh one.
class ReplayRecorder
{
public:
// configDir: hashed (its *.toml files) into the header for config-mismatch
// detection on playback. outputDir: where .replay files are written.
ReplayRecorder(std::string configDir, std::string outputDir);
~ReplayRecorder();
ReplayRecorder(const ReplayRecorder&) = delete;
ReplayRecorder& operator=(const ReplayRecorder&) = delete;
// Close any current file, then open a fresh one (named <timestamp>_<seed>),
// write the header, and record an initial tick-0 checksum. A run boundary.
void startNewRun(unsigned int seed, std::uint64_t initialRngFingerprint);
// Append one command line tagged with the tick it was applied at, followed by
// the post-apply RNG checksum.
void recordCommand(Tick tick, const Command& command, std::uint64_t rngFingerprint);
// Append a periodic RNG checksum line.
void recordChecksum(Tick tick, std::uint64_t rngFingerprint);
void close();
bool isOpen() const;
const std::string& currentFilePath() const;
private:
std::string m_configDir;
std::string m_outputDir;
std::string m_filePath;
std::ofstream m_stream;
};

View File

@@ -4,10 +4,13 @@
#include <cassert>
#include "AiSystem.h"
#include "Command.h"
#include "DisplayName.h"
#include "BuildingSystem.h"
#include "CombatSystem.h"
#include "DynamicBodyComponent.h"
#include "DynamicBodySystem.h"
#include "FacingComponent.h"
#include "FactionComponent.h"
#include "EventManager.h"
#include "HealthComponent.h"
@@ -16,9 +19,11 @@
#include "PositionComponent.h"
#include "RepairSystem.h"
#include "SalvagerSystem.h"
#include "ScrapDataComponent.h"
#include "ScrapSystem.h"
#include "ShipIdentityComponent.h"
#include "ShipSystem.h"
#include "StateChecksum.h"
#include "StationBodyComponent.h"
#include "SurfaceMask.h"
#include "tracing.h"
@@ -28,6 +33,7 @@
Simulation::Simulation(GameConfig config, unsigned int seed)
: m_config(std::move(config))
, m_rng(seed)
, m_seed(seed)
, m_currentTick(0)
, m_nextDepartureTick(secondsToTicks(m_config.world.departureIntervalSeconds))
, m_nextBuildingId(1)
@@ -129,6 +135,7 @@ void Simulation::reset(unsigned int seed)
{
EventManager::getInstance()->clearEvents();
m_rng.seed(seed);
m_seed = seed;
m_currentTick = 0;
m_nextDepartureTick = secondsToTicks(m_config.world.departureIntervalSeconds);
m_nextBuildingId = 1;
@@ -215,6 +222,92 @@ void Simulation::reset(unsigned int seed)
// tick
// ---------------------------------------------------------------------------
void Simulation::apply(const Command& command)
{
switch (command.kind)
{
case CommandKind::PlaceBuilding:
{
const PlaceBuildingCommand& c = static_cast<const PlaceBuildingCommand&>(command);
const BuildingId id = tryPlaceBuilding(c.type, c.anchor, c.rotation);
if (id == kInvalidBuildingId)
{
break;
}
if (c.recipeId.has_value())
{
m_buildingSystem->setRecipe(id, *c.recipeId);
}
if (c.shipLayout.has_value())
{
m_buildingSystem->setShipLayout(id, *c.shipLayout);
}
if (c.hasSplitterFilters)
{
m_buildingSystem->setSiteSplitterFilters(id, c.splitterFilterA, c.splitterFilterB);
}
break;
}
case CommandKind::Demolish:
demolish(static_cast<const DemolishCommand&>(command).id);
break;
case CommandKind::RotateInPlace:
{
const RotateInPlaceCommand& c = static_cast<const RotateInPlaceCommand&>(command);
m_buildingSystem->rotateInPlace(c.id, c.newRotation);
break;
}
case CommandKind::SetRecipe:
{
const SetRecipeCommand& c = static_cast<const SetRecipeCommand&>(command);
m_buildingSystem->setRecipe(c.id, c.recipeId);
break;
}
case CommandKind::SetShipLayout:
{
const SetShipLayoutCommand& c = static_cast<const SetShipLayoutCommand&>(command);
m_buildingSystem->setShipLayout(c.id, c.layout);
break;
}
case CommandKind::SetSiteSplitterFilters:
{
const SetSiteSplitterFiltersCommand& c =
static_cast<const SetSiteSplitterFiltersCommand&>(command);
m_buildingSystem->setSiteSplitterFilters(c.id, c.filterA, c.filterB);
break;
}
case CommandKind::SetSplitterFilters:
{
const SetSplitterFiltersCommand& c =
static_cast<const SetSplitterFiltersCommand&>(command);
m_beltSystem.setSplitterFilters(c.tile, c.filterA, c.filterB);
break;
}
case CommandKind::ClearBeltTiles:
m_beltSystem.clearTiles(static_cast<const ClearBeltTilesCommand&>(command).tiles);
break;
case CommandKind::ApplySchematicChoice:
applySchematicChoice(static_cast<const ApplySchematicChoiceCommand&>(command).choiceIndex);
break;
case CommandKind::Reset:
{
const ResetCommand& c = static_cast<const ResetCommand&>(command);
if (c.config)
{
// operator* on a const shared_ptr yields a mutable GameConfig&, so
// the move-only config moves into reset without a copy. The command
// is applied once, so leaving its config moved-from is fine.
reset(std::move(*c.config), c.seed);
}
else
{
reset(c.seed);
}
break;
}
}
}
void Simulation::tick()
{
EventManager::getInstance()->processEvents();
@@ -825,6 +918,116 @@ bool Simulation::isItemUnlocked(const std::string& itemId) const
return m_unlockedItemIds.count(itemId) > 0;
}
// ---------------------------------------------------------------------------
// Determinism (see docs/replay_design.md)
// ---------------------------------------------------------------------------
void Simulation::appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels)
{
hasher.append(levels.size());
for (const std::pair<const std::string, SchematicState>& entry : levels)
{
hasher.append(entry.first);
hasher.append(entry.second.unlocked);
hasher.append(entry.second.level);
}
}
void Simulation::appendStringSet(Hasher& hasher, const std::set<std::string>& ids)
{
hasher.append(ids.size());
for (const std::string& id : ids)
{
hasher.append(id);
}
}
unsigned long long Simulation::rngFingerprint() const
{
return fingerprintRng(m_rng);
}
unsigned long long Simulation::computeStateChecksum() const
{
Hasher hasher;
// RNG stream — the most sensitive signal of divergence.
hasher.append(fingerprintRng(m_rng));
// Top-level scalars.
hasher.append(m_currentTick);
hasher.append(m_nextDepartureTick);
hasher.append(m_nextBuildingId);
hasher.append(m_buildingBlocksStock);
hasher.append(m_gameOver);
// WaveSystem scalar state, reached through existing accessors.
hasher.append(threatLevel());
hasher.append(threatAccumulationRate());
hasher.append(bossWaveCounter());
hasher.append(bossCountdownTicks());
hasher.append(normalGapRemainingTicks());
// Schematic / unlock state (std::map and std::set iterate in sorted order).
appendSchematicMap(hasher, m_schematicLevels);
appendSchematicMap(hasher, m_moduleSchematicLevels);
appendStringSet(hasher, m_unlockedRecipeSchematicIds);
appendStringSet(hasher, m_unlockedRecipeIds);
appendStringSet(hasher, m_unlockedItemIds);
// Subsystems contribute their own state.
m_buildingSystem->appendChecksum(hasher);
m_beltSystem.appendChecksum(hasher);
// ECS component state. View iteration order is a pure function of the
// (identical) operation sequence on a fixed binary; each entity's raw id is
// folded in so the fingerprint is keyed, not merely a sum of fields.
m_admin.forEach<PositionComponent>(
[&hasher](entt::entity entity, const PositionComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.value);
});
m_admin.forEach<HealthComponent>(
[&hasher](entt::entity entity, const HealthComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.hp);
hasher.append(c.maxHp);
});
m_admin.forEach<FacingComponent>(
[&hasher](entt::entity entity, const FacingComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.radians);
});
m_admin.forEach<DynamicBodyComponent>(
[&hasher](entt::entity entity, const DynamicBodyComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.velocity_tpt);
hasher.append(c.angularVelocity_rpt);
hasher.append(c.linearAcceleration_tptt);
hasher.append(c.angularAcceleration_rptt);
});
m_admin.forEach<ScrapDataComponent>(
[&hasher](entt::entity entity, const ScrapDataComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.amount);
});
m_admin.forEach<ShipIdentityComponent>(
[&hasher](entt::entity entity, const ShipIdentityComponent& c)
{
hasher.append(static_cast<std::uint32_t>(entity));
hasher.append(c.level);
hasher.append(c.schematicId);
});
return hasher.value();
}
// ---------------------------------------------------------------------------
// Drains
// ---------------------------------------------------------------------------
@@ -856,6 +1059,11 @@ Tick Simulation::currentTick() const
return m_currentTick;
}
unsigned int Simulation::getSeed() const
{
return m_seed;
}
int Simulation::buildingBlocksStock() const
{
return m_buildingBlocksStock;
@@ -974,7 +1182,7 @@ void Simulation::demolish(BuildingId id)
m_buildingBlocksStock += m_buildingSystem->demolish(id);
}
BuildingSystem& Simulation::buildings()
BuildingSystem& Simulation::buildingsMutable()
{
return *m_buildingSystem;
}
@@ -984,7 +1192,7 @@ const BuildingSystem& Simulation::buildings() const
return *m_buildingSystem;
}
BeltSystem& Simulation::belts()
BeltSystem& Simulation::beltsMutable()
{
return m_beltSystem;
}

View File

@@ -24,6 +24,8 @@
class AiSystem;
class BuildingSystem;
struct Command;
class Hasher;
class CombatSystem;
class DynamicBodySystem;
class MovementIntentSystem;
@@ -50,6 +52,12 @@ public:
// Advances the simulation by one tick. Tick order per architecture.md §Tick Order.
void tick();
// The single command chokepoint: applies one player command by dispatching
// to the underlying mutators. Every sim mutation during play must flow
// through here so it can be recorded and replayed (see docs/replay_design.md
// and CommandManager). Reached via CommandManager::drain.
void apply(const Command& command);
// Returns all fire events accumulated since the last drain, clearing the
// internal queue. Call once per rendered frame (REQ-SHP-FIRING-BEAM).
std::vector<BeamFiredEvent> drainBeamFiredEvents();
@@ -60,12 +68,9 @@ public:
// Returns true if there are pending schematic choices waiting for player input.
bool hasSchematicChoicesPending() const;
// Applies the player's chosen schematic from the pending choices.
// choiceIndex must be in [0, pendingChoices.size()).
// Clears the pending choices after application.
void applySchematicChoice(int choiceIndex);
Tick currentTick() const;
// The seed this run was (re)initialized with; written to the replay header.
unsigned int getSeed() const;
int buildingBlocksStock() const;
bool isGameOver() const;
double threatLevel() const;
@@ -88,16 +93,21 @@ public:
bool isRecipeUnlocked(const std::string& recipeId) const;
bool isItemUnlocked(const std::string& itemId) const;
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// -- Determinism (see docs/replay_design.md) -----------------------------
// 64-bit fingerprint of the RNG stream state. Cheap; written to the replay
// file periodically + after each command for desync detection.
unsigned long long rngFingerprint() const;
// Demolishes the building with the given id and refunds building blocks.
void demolish(BuildingId id);
// 64-bit fingerprint of the full simulation state (RNG, scalars, buildings,
// belts, and ECS component state). Used by the double-run determinism test;
// a superset of rngFingerprint().
unsigned long long computeStateChecksum() const;
BuildingSystem& buildings();
// Const subsystem accessors (queries only). The mutable counterparts are
// private and reachable only through Simulation::apply (the command
// chokepoint) or, in tests, SimulationTestAccess — so production code cannot
// mutate the factory outside the recorded command path (docs/replay_design.md).
const BuildingSystem& buildings() const;
BeltSystem& belts();
const BeltSystem& belts() const;
ShipSystem& ships();
const ShipSystem& ships() const;
@@ -107,6 +117,29 @@ public:
const EntityAdmin& admin() const;
private:
// Grants tests access to the private player-action mutators below without
// opening them to production code (see src/test/SimulationTestAccess.h).
friend struct SimulationTestAccess;
// -- Player-action mutators (command chokepoint only) --------------------
// Reached during play exclusively via apply(); never called by UI/app code.
// Checks affordability, deducts building blocks, and places the building.
// Returns the new entity id, or kInvalidBuildingId if blocks are insufficient.
BuildingId tryPlaceBuilding(BuildingType type, QPoint anchor, Rotation rotation);
// Demolishes the building with the given id and refunds building blocks.
void demolish(BuildingId id);
// Applies the player's chosen schematic from the pending choices.
// choiceIndex must be in [0, pendingChoices.size()).
// Clears the pending choices after application.
void applySchematicChoice(int choiceIndex);
// Mutable subsystem accessors; same chokepoint rule as the mutators above.
BuildingSystem& buildingsMutable();
BeltSystem& beltsMutable();
void handleEvent(std::shared_ptr<const TracePrintRequestedEvent> event) override;
BuildingId allocateBuildingId(); // Strictly increasing; never returns kInvalidBuildingId.
@@ -126,6 +159,7 @@ private:
GameConfig m_config;
std::mt19937 m_rng;
unsigned int m_seed;
Tick m_currentTick;
Tick m_nextDepartureTick;
@@ -149,6 +183,11 @@ private:
std::map<std::string, SchematicState> m_schematicLevels;
std::map<std::string, SchematicState> m_moduleSchematicLevels;
// Determinism helpers — fold sub-state into the hasher in deterministic order.
static void appendSchematicMap(Hasher& hasher,
const std::map<std::string, SchematicState>& levels);
static void appendStringSet(Hasher& hasher, const std::set<std::string>& ids);
// Explicitly unlocked assembler recipe schematics (REQ-LOCK-EXPLICIT).
std::set<std::string> m_unlockedRecipeSchematicIds;

View File

@@ -0,0 +1,61 @@
#include "StateChecksum.h"
#include <sstream>
void Hasher::appendBytes(const void* data, std::size_t byteCount)
{
const unsigned char* bytes = static_cast<const unsigned char*>(data);
for (std::size_t i = 0; i < byteCount; ++i)
{
m_state ^= bytes[i];
m_state *= 1099511628211ull; // FNV-1a 64-bit prime
}
}
void Hasher::append(float value)
{
// Normalize -0.0f to +0.0f so the two equal values share a fingerprint.
if (value == 0.0f) { value = 0.0f; }
appendBytes(&value, sizeof(value));
}
void Hasher::append(double value)
{
if (value == 0.0) { value = 0.0; }
appendBytes(&value, sizeof(value));
}
void Hasher::append(const QPoint& point)
{
const int coords[2] = { point.x(), point.y() };
appendBytes(coords, sizeof(coords));
}
void Hasher::append(const QPointF& point)
{
append(point.x());
append(point.y());
}
void Hasher::append(const QVector2D& vector)
{
append(vector.x());
append(vector.y());
}
void Hasher::append(const std::string& text)
{
appendBytes(text.data(), text.size());
// Length terminator so "ab"+"c" and "a"+"bc" do not collide.
const std::size_t length = text.size();
appendBytes(&length, sizeof(length));
}
std::uint64_t fingerprintRng(const std::mt19937& rng)
{
std::ostringstream stream;
stream << rng; // full internal state as space-separated integers
Hasher hasher;
hasher.append(stream.str());
return hasher.value();
}

View File

@@ -0,0 +1,55 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <random>
#include <string>
#include <type_traits>
#include <QPoint>
#include <QPointF>
#include <QVector2D>
// FNV-1a 64-bit accumulator used to fingerprint simulation state for
// determinism verification (see docs/replay_design.md "Determinism").
//
// Subsystems contribute their own state through appendChecksum(Hasher&) so the
// hash stays close to the data it covers and no state knowledge is duplicated.
// The accumulator is order-sensitive; callers fold state in a deterministic
// order (sorted containers, fixed view iteration).
class Hasher
{
public:
// Folds raw bytes into the running hash.
void appendBytes(const void* data, std::size_t byteCount);
// Trivially-copyable scalars (ints, enums) are hashed by object representation.
// Floating-point and Qt types have dedicated overloads below and bypass this.
template <typename T>
void append(const T& value)
{
static_assert(std::is_trivially_copyable<T>::value,
"Hasher::append requires a trivially copyable type "
"(add a dedicated overload otherwise)");
appendBytes(&value, sizeof(T));
}
// Floats are hashed by bit pattern so equal values always hash equally;
// negative zero is normalized so -0.0 and +0.0 collapse to one value.
void append(float value);
void append(double value);
void append(const QPoint& point);
void append(const QPointF& point);
void append(const QVector2D& vector);
void append(const std::string& text);
std::uint64_t value() const { return m_state; }
private:
std::uint64_t m_state = 14695981039346656037ull; // FNV-1a 64-bit offset basis
};
// Folds the full mt19937 internal state into a 64-bit fingerprint. mt19937 has a
// portable, bit-identical text serialization, so this fingerprint is stable
// across platforms (see docs/replay_design.md "Cross-platform").
std::uint64_t fingerprintRng(const std::mt19937& rng);