fingerprint the factory without going through a system

BuildingSystem::appendChecksum took a const FactoryState& and touched no member
of the system it hung off -- the same case as the two item visitors, and the
last const method on the class. Fingerprinting the factory needs the data and
nothing else: no config, no belts, no RNG.

It becomes a free function in FactoryChecksum.h/.cpp, taking its three private
helpers with it, and Simulation calls it directly. BuildingSystem loses its last
non-mutating method and its Hasher dependency; the header is down to placement,
configuration, the tick hooks and the topology mutators.

The fold order is untouched -- the block moved verbatim and the call still sits
between the unlock state and the belts in computeStateChecksum -- so recorded
replays keep verifying. Note that no test would have caught a reordering here:
DeterminismTest compares two runs of the same build, and replays carry no golden
checksum, so the guarantee comes from the move being verbatim. The new header
says so, for whoever adds a FactoryState field next.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ne3mejABZoLWKLh8fgpM3x
This commit is contained in:
2026-08-19 21:48:00 +02:00
parent e7bfd91054
commit 04a52698e8
6 changed files with 142 additions and 119 deletions

View File

@@ -10,7 +10,6 @@
#include "PlacementRules.h" #include "PlacementRules.h"
#include "ProductionRules.h" #include "ProductionRules.h"
#include "PortGeometry.h" #include "PortGeometry.h"
#include "StateChecksum.h"
#include "SurfaceMask.h" #include "SurfaceMask.h"
#include "tracing.h" #include "tracing.h"
@@ -912,113 +911,3 @@ void BuildingSystem::unregisterTileOccupancy(FactoryState& state, const std::vec
{ {
state.grid.release(cells); state.grid.release(cells);
} }
namespace
{
void appendItems(Hasher& hasher, const std::vector<Item>& items)
{
hasher.append(items.size());
for (const Item& item : items)
{
hasher.append(item.type.id);
}
}
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<), so both
// buffer sides hash the same way in every run.
void appendItemCounts(Hasher& hasher, const std::map<ItemType, int>& counts)
{
hasher.append(counts.size());
for (const std::pair<const ItemType, int>& entry : counts)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
}
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
{
appendItemCounts(hasher, buffer.counts);
appendItemCounts(hasher, buffer.caps);
}
} // namespace
void BuildingSystem::appendChecksum(const FactoryState& state, Hasher& hasher) const
{
// state.buildings keeps a stable, deterministic order (append on build, swap-free
// erase aside — both runs perform identical operations, so order matches).
hasher.append(state.buildings.size());
for (const Building& b : state.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);
appendItemCounts(hasher, b.outputBuffer.caps);
hasher.append(b.emergingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{
hasher.append(lane.size());
for (const BeltItemSlot& slot : lane)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
hasher.append(b.incomingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.incomingItems)
{
hasher.append(lane.size());
for (const BeltItemSlot& slot : lane)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
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(b.queuedForDeconstruction);
}
hasher.append(state.constructionQueue.size());
for (const ConstructionSite& s : state.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); }
}
hasher.append(state.deconstructionQueue.size());
for (const DeconstructionEntry& e : state.deconstructionQueue)
{
hasher.append(e.id);
hasher.append(e.completesAt);
hasher.append(e.splitterFilterA.size());
for (const ItemType& type : e.splitterFilterA) { hasher.append(type.id); }
hasher.append(e.splitterFilterB.size());
for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); }
}
state.grid.appendChecksum(hasher);
}

View File

@@ -29,8 +29,6 @@
#include "ShipsConfig.h" #include "ShipsConfig.h"
#include "Tick.h" #include "Tick.h"
class Hasher;
// Manages building placement, construction queuing, and the per-tick // Manages building placement, construction queuing, and the per-tick
// production loop (belt→building pull, production, building→belt push). // production loop (belt→building pull, production, building→belt push).
// All types including Belt and Splitter are stored as Building instances; // All types including Belt and Splitter are stored as Building instances;
@@ -134,11 +132,6 @@ public:
// Mutable iteration over all operational buildings. // Mutable iteration over all operational buildings.
void forEachBuilding(FactoryState& state, std::function<void(Building&)> fn); void forEachBuilding(FactoryState& state, 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(const FactoryState& state, Hasher& hasher) const;
private: private:
// Selects a recipe for an auto-recipe building that has none, from a material being // Selects a recipe for an auto-recipe building that has none, from a material being
// offered to it at one of its input ports (REQ-BLD-AUTO-RECIPE). No-op for every // offered to it at one of its input ports (REQ-BLD-AUTO-RECIPE). No-op for every

View File

@@ -17,6 +17,7 @@ SET(HDRS
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructionSystem.h ${CMAKE_CURRENT_SOURCE_DIR}/DeconstructionSystem.h
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.h ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h ${CMAKE_CURRENT_SOURCE_DIR}/FactoryState.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryChecksum.h
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h ${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.h
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.h ${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.h
${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.h ${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.h
@@ -48,6 +49,7 @@ SET(SRCS
${CMAKE_CURRENT_SOURCE_DIR}/ConstructionSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ConstructionSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/DeconstructionSystem.cpp ${CMAKE_CURRENT_SOURCE_DIR}/DeconstructionSystem.cpp
${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.cpp ${CMAKE_CURRENT_SOURCE_DIR}/BuildingBuffers.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FactoryChecksum.cpp
${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp ${CMAKE_CURRENT_SOURCE_DIR}/FactoryQueries.cpp
${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp ${CMAKE_CURRENT_SOURCE_DIR}/ProductionRules.cpp
${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.cpp ${CMAKE_CURRENT_SOURCE_DIR}/PlacementRules.cpp

View File

@@ -0,0 +1,120 @@
#include "FactoryChecksum.h"
#include <map>
#include <vector>
#include "BuildingBuffers.h"
#include "FactoryState.h"
#include "Item.h"
#include "ItemType.h"
#include "StateChecksum.h"
namespace
{
void appendItems(Hasher& hasher, const std::vector<Item>& items)
{
hasher.append(items.size());
for (const Item& item : items)
{
hasher.append(item.type.id);
}
}
// std::map<ItemType, int> iterates in sorted-id order (ItemType::operator<), so both
// buffer sides hash the same way in every run.
void appendItemCounts(Hasher& hasher, const std::map<ItemType, int>& counts)
{
hasher.append(counts.size());
for (const std::pair<const ItemType, int>& entry : counts)
{
hasher.append(entry.first.id);
hasher.append(entry.second);
}
}
void appendInputBuffer(Hasher& hasher, const InputBuffer& buffer)
{
appendItemCounts(hasher, buffer.counts);
appendItemCounts(hasher, buffer.caps);
}
} // namespace
void appendChecksum(const FactoryState& state, Hasher& hasher)
{
// state.buildings keeps a stable, deterministic order (append on build, swap-free
// erase aside -- both runs perform identical operations, so order matches).
hasher.append(state.buildings.size());
for (const Building& b : state.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);
appendItemCounts(hasher, b.outputBuffer.caps);
hasher.append(b.emergingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.emergingItems)
{
hasher.append(lane.size());
for (const BeltItemSlot& slot : lane)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
hasher.append(b.incomingItems.size());
for (const std::vector<BeltItemSlot>& lane : b.incomingItems)
{
hasher.append(lane.size());
for (const BeltItemSlot& slot : lane)
{
hasher.append(slot.item.type.id);
hasher.append(slot.progress);
}
}
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(b.queuedForDeconstruction);
}
hasher.append(state.constructionQueue.size());
for (const ConstructionSite& s : state.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); }
}
hasher.append(state.deconstructionQueue.size());
for (const DeconstructionEntry& e : state.deconstructionQueue)
{
hasher.append(e.id);
hasher.append(e.completesAt);
hasher.append(e.splitterFilterA.size());
for (const ItemType& type : e.splitterFilterA) { hasher.append(type.id); }
hasher.append(e.splitterFilterB.size());
for (const ItemType& type : e.splitterFilterB) { hasher.append(type.id); }
}
state.grid.appendChecksum(hasher);
}

View File

@@ -0,0 +1,18 @@
#pragma once
struct FactoryState;
class Hasher;
// Folds every building, construction site, deconstruction entry and tile claim into the
// hasher in deterministic order (docs/replay_design.md).
//
// A free function over the state rather than a system method, for the reason the rest of
// the read surface is one (FactoryQueries.h): fingerprinting the factory needs the data
// and nothing else -- no config, no belts, no RNG -- so it should not be reachable only
// through whichever system happens to tick that data.
//
// The order it folds in is load-bearing. Two runs of the same replay must fold identical
// values in an identical sequence, so nothing here may be reordered, and any field added
// to FactoryState that the simulation reads must be appended at the end rather than
// inserted (docs/replay_design.md, DeterminismTest).
void appendChecksum(const FactoryState& state, Hasher& hasher);

View File

@@ -1,5 +1,6 @@
#include "Simulation.h" #include "Simulation.h"
#include "FactoryChecksum.h"
#include "FactoryQueries.h" #include "FactoryQueries.h"
#include "ConstructionSystem.h" #include "ConstructionSystem.h"
#include "DeconstructionSystem.h" #include "DeconstructionSystem.h"
@@ -672,7 +673,7 @@ unsigned long long Simulation::computeStateChecksum() const
m_unlockState.appendChecksum(hasher); m_unlockState.appendChecksum(hasher);
// Subsystems contribute their own state. // Subsystems contribute their own state.
m_buildingSystem->appendChecksum(m_factoryState, hasher); appendChecksum(m_factoryState, hasher);
m_beltSystem.appendChecksum(hasher); m_beltSystem.appendChecksum(hasher);
// ECS component state. View iteration order is a pure function of the // ECS component state. View iteration order is a pure function of the