Doors run on the mission clock instead of being replicated

A door's position was an integrated countdown owned by whichever machine
the map-entity round-robin happened to deal it to. That left doors one
one-way-latency behind on every other machine, re-acquired at each state
change; drifting permanently on any frame hitch over a second, which the
old code dropped outright rather than clamping; and frozen mid-cycle,
collision volumes included, when their owning peer left, since ownership
transfer is not implemented.

Doors are clockwork with no inputs, and door/VTV physics is already local
pointer access - VTV::ProcessCollision reads door->currentVelocity off
the local object and the crush test is local VTV state - so a door does
not need an owner at all. Door::SlideDoor is now a phase function of
Application::GetMissionElapsed(), anchored so phase zero reproduces the
original DefaultState entry: fully open, starting to close. Every host
builds its own doorframe out of the map stream as a HermitInstance, the
instance kind DynamicEntityCreation does not broadcast, so nothing is
sent, nothing is received, and a peer leaving takes no doors with it.

Verified against a copy of the old integrator at 25fps with the real 10s
travel / 3s dead timings: identical 26s cycle, a constant one-frame
offset, and no drift across a 3s stall that leaves the old code
permanently 3 seconds out of phase.

Also fixes a latent bug found on the way: UpdateManager iterates the
dynamic master socket, which holds Independant and Hermit instances as
well as masters, and handed all of them to EntityUpdateReplicants, which
asserts MasterInstance.

Doorframes no longer consume a slot in the map-entity ownership cursor,
which shifts who owns every map entity dealt after them, so this cannot
share a session with an older build. The lobby publishes a simulation
revision and refuses to launch a mixed room.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-08-07 16:25:42 -05:00
co-authored by Claude Opus 5
parent 0e39075a20
commit 6e829f815e
8 changed files with 202 additions and 164 deletions
+29
View File
@@ -273,6 +273,35 @@ Scalar
return mgr->GetFrameRate();
}
//
//#############################################################################
// GetMissionElapsed
//#############################################################################
//
Scalar
Application::GetMissionElapsed()
{
Check(this);
//
//--------------------------------------------------------------------------
// gameStarted is only ever stamped by RunMissionMessageHandler, so before
// the race it is uninitialized - and entities that are pre-runnable do get
// performed before then. Answer zero until the clock actually exists.
//--------------------------------------------------------------------------
//
if (
GetApplicationState() != RunningMission
&& GetApplicationState() != EndingMission
)
{
return 0.0f;
}
Scalar elapsed = Now() - gameStarted;
return (elapsed > 0.0f) ? elapsed : 0.0f;
}
//
//#############################################################################
// Initialize
+9
View File
@@ -318,6 +318,15 @@ public:
Scalar
GetSecondsRemainingInGame()
{return secondsRemainingInGame;}
//
// Seconds since the console's RunMission started the race, counting up.
// Every machine anchors this on the same message, so anything derived
// from it agrees across the mesh without being replicated - see the
// clockwork doors in DOOR.cpp. Reads 0 outside a running mission
// (gameStarted holds garbage until RunMission stamps it).
//
Scalar
GetMissionElapsed();
ApplicationID
GetApplicationID()
{return applicationID;}
+65 -140
View File
@@ -72,172 +72,96 @@ Door::AttributeIndexSet& Door::GetAttributeIndex()
//#############################################################################
// Model Support
//
void
Door::ReadUpdateRecord(Simulation::UpdateRecord *message)
{
Check(this);
Check_Pointer(message);
Subsystem::ReadUpdateRecord(message);
UpdateRecord* record = (UpdateRecord*) message;
percentOpen = record->percentOpen;
switch (GetSimulationState())
{
case Opening:
case Closing:
phaseTimeRemaining = travelTime;
break;
case Opened:
case Closed:
phaseTimeRemaining = deadTime;
break;
}
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door updated to state "
// << GetSimulationState() << " @ "
// << application->GetSecondsRemainingInGame() << endl;
MoveCollisionVolume(percentOpen);
Check_Fpu();
}
// There is no ReadUpdateRecord/WriteUpdateRecord pair here on purpose. Doors
// are Hermit instances built independently on every host, so no door state is
// ever sent or received - the phase function below is the only thing that
// decides where a door is, and it reaches the same answer everywhere.
//
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
Door::WriteUpdateRecord(Simulation::UpdateRecord *record, int update_model)
{
Check(this);
Check_Pointer(record);
Subsystem::WriteUpdateRecord(record, update_model);
UpdateRecord *update = (UpdateRecord*)record;
update->percentOpen = percentOpen;
update->recordLength = sizeof(*update);
Check_Fpu();
}
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
//
void
Door::SlideDoor(Scalar time_slice)
Door::SlideDoor(Scalar)
{
Check(this);
//
//------------------------------------------------------------
// Advance the clock, then branch based upon our current state
//------------------------------------------------------------
//--------------------------------------------------------------------------
// The door is clockwork. Its position is a function of how long the race
// has been running, not of a countdown integrated frame by frame, so:
//
int new_state;
if (time_slice > 1.0f)
// - every machine puts this door in the same place from the same mission
// clock, without a byte crossing the wire,
// - a frame hitch of any length costs nothing, because there is no
// accumulated state left to fall behind (the old code dropped any slice
// over a second outright and never got that time back).
//
// Phase zero is the instant the door starts to close, fully open, which is
// where the original state machine began from DefaultState:
//
// [0, travel) Closing 1 -> 0
// [travel, travel+dead) Closed 0
// [travel+dead, 2travel+dead) Opening 0 -> 1
// [2travel+dead, cycle) Opened 1
//--------------------------------------------------------------------------
//
if (cycleTime <= 0.0f)
{
MoveCollisionVolume(0.0f);
SetSimulationState(Closed);
Check_Fpu();
return;
}
phaseTimeRemaining -= time_slice;
Scalar percent_open;
switch (GetSimulationState())
Check(application);
Scalar phase = fmod(application->GetMissionElapsed() - phaseOffset, cycleTime);
if (phase < 0.0f)
{
phase += cycleTime;
}
//
//------------------------------------------------------------------------
// If the door is not done opening, set its new position, otherwise branch
// to the opened state
//------------------------------------------------------------------------
//--------------------------------------------------------------------------
// Pick the band. Each division below is guarded by the comparison that
// selected the branch, so a door with a zero travelTime or deadTime simply
// loses that band rather than dividing by zero.
//--------------------------------------------------------------------------
//
case Opening:
Door_Opening:
new_state = Opening;
if (phaseTimeRemaining > 0.0f)
{
percent_open = 1.0f - phaseTimeRemaining/travelTime;
}
else
{
phaseTimeRemaining += deadTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door opened @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Opened;
}
currentVelocity.Subtract(
worldExtent,
GetEntity()->localOrigin.linearPosition
);
currentVelocity /= travelTime;
Check_Fpu();
break;
Scalar open_start = travelTime + deadTime;
int new_state;
Scalar percent_open;
//
//-------------------------------------------------------------
// If the door is ready to start closing, jump to closing state
//-------------------------------------------------------------
//
case Opened:
Door_Opened:
new_state = Opened;
if (phaseTimeRemaining <= 0.0f)
{
phaseTimeRemaining += travelTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door closing @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Closing;
}
percent_open = 1.0f;
currentVelocity = Vector3D::Identity;
Check_Fpu();
break;
//
//------------------------------------------------------------------------
// If the door is not done closing, set its new position, otherwise branch
// to the closed state
//------------------------------------------------------------------------
//
case DefaultState:
phaseTimeRemaining = travelTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door default @ "
// << application->GetSecondsRemainingInGame() << endl;
case Closing:
Door_Closing:
if (phase < travelTime)
{
new_state = Closing;
if (phaseTimeRemaining > 0.0f)
{
percent_open = phaseTimeRemaining/travelTime;
}
else
{
phaseTimeRemaining += deadTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door closed @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Closed;
}
percent_open = 1.0f - phase/travelTime;
currentVelocity.Subtract(
GetEntity()->localOrigin.linearPosition,
worldExtent
);
currentVelocity /= travelTime;
Check_Fpu();
break;
//
//-------------------------------------------------------------
// If the door is ready to start opening, jump to opening state
//-------------------------------------------------------------
//
case Closed:
Door_Closed:
}
else if (phase < open_start)
{
new_state = Closed;
if (phaseTimeRemaining <= 0.0f)
{
phaseTimeRemaining += travelTime;
// DEBUG_STREAM << GetEntity()->GetEntityID() << " door opening @ "
// << application->GetSecondsRemainingInGame() << endl;
goto Door_Opening;
}
percent_open = 0.0f;
currentVelocity = Vector3D::Identity;
Check_Fpu();
break;
}
else if (phase < open_start + travelTime)
{
new_state = Opening;
percent_open = (phase - open_start)/travelTime;
currentVelocity.Subtract(
worldExtent,
GetEntity()->localOrigin.linearPosition
);
currentVelocity /= travelTime;
}
else
{
new_state = Opened;
percent_open = 1.0f;
currentVelocity = Vector3D::Identity;
}
//
@@ -344,7 +268,8 @@ Door::Door(
//
// Initialize variables
//
phaseTimeRemaining = 0.0f;
phaseOffset = 0.0f;
cycleTime = 2.0f*(travelTime + deadTime);
currentPosition = Point3D::Identity;
SetPerformance(&Door::SlideDoor);
+16 -19
View File
@@ -20,16 +20,11 @@ struct Door__SubsystemResource:
collisionID;
};
//##########################################################################
//##################### Chute::UpdateRecord #####################
//##########################################################################
struct Door__UpdateRecord :
public Subsystem::UpdateRecord
{
Scalar
percentOpen;
};
//
// A door has no update record. It is Hermit clockwork - every host builds
// its own out of the map stream and derives the position from the mission
// clock, so there is nothing to publish and nothing to receive.
//
//##########################################################################
//######################### CLASS Door ########################
@@ -91,7 +86,6 @@ public:
typedef void
(Door::*Performance)(Scalar time_slice);
typedef Door__UpdateRecord UpdateRecord;
void
SetPerformance(Performance performance)
@@ -109,12 +103,6 @@ public:
GetFirstBoxedSolid()
{Check(this); return collisionVolumes;}
protected:
void
WriteUpdateRecord(Simulation::UpdateRecord *message, int update_model);
void
ReadUpdateRecord(Simulation::UpdateRecord *message);
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
// Construction and Destruction
//
@@ -152,9 +140,18 @@ private:
worldExtent;
Scalar
phaseTimeRemaining,
travelTime,
deadTime;
deadTime,
//
// Where in the cycle this door sits at mission time zero, and the
// length of one full open-close-open cycle. phaseOffset is not in
// the subsystem resource yet: every door in the game is in lockstep,
// and adding a field to Door__SubsystemResource changes its sizeof,
// which invalidates every prebuilt .res. Wire it to a "PhaseOffset"
// notation entry when there is a reason to rebuild resources.
//
phaseOffset,
cycleTime;
int collisionVolumeCount;
+8 -1
View File
@@ -126,8 +126,15 @@ Logical
}
creation_message->classToCreate = RegisteredClass::DoorFrameClassID;
//
// Hermit, not Master: every host builds its own doorframe out of the map
// stream (see the DoorFrameClassID exemption in LoadMapStream) and runs it
// off the mission clock. Hermit is the instance kind DynamicEntityCreation
// does NOT broadcast, which is what stops N machines each announcing the
// same doorframe and producing N-squared of them.
//
creation_message->instanceFlags =
MasterInstance|DynamicFlag|MapFlag|TrappedFlag;
HermitInstance|DynamicFlag|MapFlag|TrappedFlag;
return true;
}
+13 -1
View File
@@ -411,8 +411,20 @@ void
// supposed to
//---------------------------------------------------------------------
//
//
// Doorframes are exempt: they are clockwork, computed identically on
// every machine from the mission clock, so each host builds its own
// Hermit copy instead of one host owning it and replicating. That
// also means they survive a peer dropping, which owned doors do not -
// ownership transfer is not implemented. Note this changes how many
// times the cursor below is advanced, so old and new builds deal the
// remaining map entities differently: they cannot share a session.
//
Logical post_make_message = True;
if (Entity::EntityFlagsIsMap(message->instanceFlags))
if (
Entity::EntityFlagsIsMap(message->instanceFlags)
&& message->classToCreate != DoorFrameClassID
)
{
Check(application);
HostManager *host_manager = application->GetHostManager();
+12 -2
View File
@@ -168,10 +168,20 @@ void
//
//-----------------------------------------------------------------------
// If update message is not null then send the change
// If update message is not null then send the change.
//
// The dynamic master socket holds Independant and Hermit instances as
// well as masters, and neither of those publishes: an Independant runs
// its own simulation on every host, and a Hermit is not replicated at
// all. EntityUpdateReplicants asserts MasterInstance, so the caller is
// the one that has to make that true - the clockwork doorframes are
// Hermits and would otherwise arrive there.
//-----------------------------------------------------------------------
//
if (update_message != NULL)
if (
update_message != NULL
&& entity->GetInstance() == Entity::MasterInstance
)
{
Check(update_message);
+50 -1
View File
@@ -51,6 +51,20 @@ namespace
const char kResultsKey[] = "res";
const char kScenarioKey[] = "sc";
//-------------------------------------------------------------------
// Simulation protocol revision. Bump this whenever a change makes
// two builds simulate the same mission differently - it is not the
// wire format alone. Map entity ownership is dealt by advancing a
// shared cursor once per map entity, so anything that changes which
// entities are dealt at all silently desynchronizes who owns what.
//
// 2 - doorframes became local Hermit clockwork and are no longer
// dealt, which shifts every subsequent map entity's owner
// 1 - the 3-machine verified Steam build
//-------------------------------------------------------------------
const char kNetRevision[] = "2";
const char kNetRevKey[] = "nr";
// the owner's mission setup, shown to everyone in the room
const char kMapKey[] = "mp";
const char kTimeKey[] = "td";
@@ -164,6 +178,9 @@ namespace
SteamMatchmaking()->SetLobbyMemberData(gLobby, "ps",
RPL4FrontEnd_PositionKey(RPL4FrontEnd_GetPositionIndex()));
// what this build simulates like, so a mismatched room cannot launch
SteamMatchmaking()->SetLobbyMemberData(gLobby, kNetRevKey, kNetRevision);
//---------------------------------------------------------------
// Only the owner's menu decides the mission, so the owner also
// publishes what it picked: the scenario (members need it to know
@@ -172,6 +189,8 @@ namespace
//---------------------------------------------------------------
if (IsOwner())
{
// members check this before they act on the owner's go
SteamMatchmaking()->SetLobbyData(gLobby, kNetRevKey, kNetRevision);
SteamMatchmaking()->SetLobbyData(gLobby, kScenarioKey,
RPL4FrontEnd_IsFootballSelected() ? "football" : "race");
SteamMatchmaking()->SetLobbyData(gLobby, kMapKey,
@@ -226,6 +245,7 @@ namespace
char badge[24];
char team[32]; // football pick
char position[16];
char netRev[8]; // simulation protocol revision
Logical published;
};
@@ -266,6 +286,9 @@ namespace
strncpy(member->position,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, "ps"),
sizeof(member->position) - 1);
strncpy(member->netRev,
SteamMatchmaking()->GetLobbyMemberData(gLobby, member->id, kNetRevKey),
sizeof(member->netRev) - 1);
member->published =
member->ip[0] != '\0' && member->consolePort > 0 && member->gamePort > 0;
}
@@ -797,14 +820,22 @@ namespace
room.launchClicked = False;
room.memberCount = CollectMembers(room.members);
Logical all_published = True;
Logical all_same_build = True;
for (int i = 0; i < room.memberCount; ++i)
{
if (!room.members[i].published)
{
all_published = False;
}
if (strcmp(room.members[i].netRev, kNetRevision) != 0)
{
all_same_build = False;
DEBUG_STREAM << "Lobby: " << room.members[i].name
<< " simulates like rev '" << room.members[i].netRev
<< "', we are rev '" << kNetRevision << "'\n" << std::flush;
}
}
if (all_published && room.memberCount >= 1)
if (all_published && all_same_build && room.memberCount >= 1)
{
++gLastGoNonce;
char go[800];
@@ -834,6 +865,24 @@ namespace
//
if (!IsOwner())
{
//
// A room whose owner simulates differently than we do would
// desynchronize silently rather than fail, so sit the race out
// instead of flying into it.
//
const char *owner_rev = LobbyText(kNetRevKey);
if (owner_rev[0] != '\0' &&
strcmp(owner_rev, kNetRevision) != 0)
{
DEBUG_STREAM << "Lobby: owner simulates like rev '"
<< owner_rev << "', we are rev '" << kNetRevision
<< "' - not launching\n" << std::flush;
outcome = LobbyRoomLeft;
SteamMatchmaking()->LeaveLobby(gLobby);
gInLobby = False;
break;
}
const char *go = SteamMatchmaking()->GetLobbyData(gLobby, kGoKey);
if (go != NULL && go[0] != '\0')
{