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
+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')
{