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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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')
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user