From 82e733c1a6ecc7586cc211598a8bdab5616707c0 Mon Sep 17 00:00:00 2001 From: Cyd Date: Wed, 5 Aug 2026 15:50:28 -0500 Subject: [PATCH] Replicants reckon from when an update was sent Simulation::ReadUpdateRecord threw away the sender's timestamp and stamped lastUpdate with its own arrival time. The line carried the original authors' own note: "HACK - should be based upon message->timeStamp". The dead reckoner extrapolates a replicant over (lastPerformance - lastUpdate), so starting that clock at ARRIVAL rather than at SEND leaves every remote vehicle exactly one network latency behind where it should be. On the 1 ms LAN inside an arcade that is nothing. Over Steam Datagram Relay it is 50-150 ms of positional lag on every other player - a constant bias, not jitter, and the information needed to remove it was already in the packet. The timestamp cannot be used as it stands: both machines run QueryPerformanceCounter since their own boot, so the two clocks share no epoch. The offset is estimated per peer instead. Each record gives sample = ourNow - theirStamp = trueOffset + oneWayLatency and latency is never negative, so the smallest sample seen is the closest to the truth. A rolling minimum over 128 samples follows crystal drift and re-adapts when a route gets slower, rather than being pinned forever by one lucky packet; a shorter path is believed immediately. Applied with two clamps: never ahead of our own clock, and never further back than 500 ms. Past that the packet is stale or the estimate is wrong, and throwing a vehicle half a second forward does more damage than the lag being corrected. Entity::UpdateMessageHandler is the only point on the receive path that knows whose update this is - records carry a timestamp but not an owner - so it publishes the sender around the loop, and only for entities somebody else owns. Offsets are forgotten in CreateMission: the hosts in the next race are not the hosts in the last one and a HostID gets reused. RP412NETCLOCK=0 restores the arrival-time behaviour, documented in environ.ini, so a test machine can compare the two without a rebuild. The estimate is logged per host when it first settles and whenever it moves more than 50 ms, which is what a three-machine session should be read against. WHAT IS AND IS NOT VERIFIED. A full single-player race runs unchanged - the path is never entered without replicants, which is the regression risk that reaches everybody. The behaviour this exists for needs real latency between real machines and is therefore untested: a two-instance loopback race would only have exercised the zero-latency case, where the correction is a no-op by construction. Expect remote vehicles to sit further forward than before, and watch for overshoot when somebody changes direction sharply - that is the tradeoff this makes, and the clamp above is what bounds it. Co-Authored-By: Claude Opus 5 (1M context) --- MUNGA/APP.cpp | 6 ++ MUNGA/ENTITY.cpp | 25 ++++++ MUNGA/SIMULATE.cpp | 182 +++++++++++++++++++++++++++++++++++++++++- MUNGA/SIMULATE.h | 35 ++++++++ RP_L4/RPL4ENVIRON.cpp | 8 ++ 5 files changed, 255 insertions(+), 1 deletion(-) diff --git a/MUNGA/APP.cpp b/MUNGA/APP.cpp index 6fab4f7..d372c97 100644 --- a/MUNGA/APP.cpp +++ b/MUNGA/APP.cpp @@ -1140,6 +1140,12 @@ void Check(this); Check(egg_notation_file); + // + // Forget every peer's clock offset: the hosts in the next race are not + // the hosts in the last one, and a HostID gets reused. + // + NetClock_Reset(); + // //-------------------------------------------------------------------------- // Create mission from egg notation file diff --git a/MUNGA/ENTITY.cpp b/MUNGA/ENTITY.cpp index 2fa4fe4..a6d977b 100644 --- a/MUNGA/ENTITY.cpp +++ b/MUNGA/ENTITY.cpp @@ -377,8 +377,28 @@ void //------------------------------------------------------------------------ // Step through each block until there are no more remaining, and send the // update out the the simulation indicated by the subsystemID + // + // This is the only point on the receive path that knows WHOSE update + // this is - the records themselves carry a timestamp but not an owner - + // so the sender is published here for the net clock to align against. + // Every record in the message, and the damage zones nested inside them, + // came from the same machine in the same frame. //------------------------------------------------------------------------ // + // + // Only for an entity somebody else owns. Our own clock needs no + // aligning, and an update we somehow handed ourselves would otherwise + // drag lastUpdate back by a frame for no reason. + // + Check(application); + Check(application->GetHostManager()); + Logical remote_owner = + GetOwnerID() != application->GetHostManager()->GetLocalHostID(); + if (remote_owner) + { + NetClock_BeginUpdate(GetOwnerID()); + } + while (stream.GetBytesRemaining()) { Simulation::UpdateRecord *update = @@ -389,6 +409,11 @@ void simulation->ReadUpdateRecord(update); stream.AdvancePointer(update->recordLength); } + + if (remote_owner) + { + NetClock_EndUpdate(); + } Check_Fpu(); } diff --git a/MUNGA/SIMULATE.cpp b/MUNGA/SIMULATE.cpp index 33fe5c7..1e7747a 100644 --- a/MUNGA/SIMULATE.cpp +++ b/MUNGA/SIMULATE.cpp @@ -264,6 +264,109 @@ Simulation::SharedData // Model support // +//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +// +//########################################################################## +// Net clock - see SIMULATE.h for why the sender's timestamp is estimated +// rather than used as it stands. +//########################################################################## + +namespace +{ + enum + { + netClockMaxPeers = 16, + + // Samples per rolling minimum. A peer sends one record per + // simulation per frame, so at eight vehicles and 60 fps this is + // well under a second - fast enough to follow a route change, + // long enough that the minimum means something. + netClockWindow = 128, + + // The furthest back we will believe a timestamp. Beyond this the + // packet is stale or the estimate is wrong, and extrapolating a + // vehicle half a second forward does more harm than the lag we + // are correcting. + netClockMaxLagTicks = 500 + }; + + struct PeerClock + { + HostID host; + Logical inUse; + Logical settled; + long offsetTicks; // our clock - their clock + long windowMinTicks; + int windowCount; + }; + + PeerClock gPeerClocks[netClockMaxPeers]; + HostID gUpdateSender = 0; + Logical gUpdateSenderValid = False; + + Logical NetClockEnabled() + { + static int enabled = -1; + if (enabled < 0) + { + const char *setting = getenv("RP412NETCLOCK"); + enabled = (setting != NULL && atoi(setting) == 0) ? 0 : 1; + if (!enabled) + { + DEBUG_STREAM << "NetClock: disabled by RP412NETCLOCK=0 - " + << "replicants dead-reckon from arrival time\n" << std::flush; + } + } + return enabled ? True : False; + } + + PeerClock *FindPeer(HostID host) + { + PeerClock *free_slot = NULL; + for (int i = 0; i < netClockMaxPeers; ++i) + { + if (gPeerClocks[i].inUse) + { + if (gPeerClocks[i].host == host) + { + return &gPeerClocks[i]; + } + } + else if (free_slot == NULL) + { + free_slot = &gPeerClocks[i]; + } + } + if (free_slot != NULL) + { + free_slot->inUse = True; + free_slot->host = host; + free_slot->settled = False; + free_slot->offsetTicks = 0; + free_slot->windowMinTicks = 0; + free_slot->windowCount = 0; + } + return free_slot; + } +} + +void NetClock_BeginUpdate(HostID sender) +{ + gUpdateSender = sender; + gUpdateSenderValid = True; +} + +void NetClock_EndUpdate() +{ + gUpdateSenderValid = False; +} + +void NetClock_Reset() +{ + memset(gPeerClocks, 0, sizeof(gPeerClocks)); + gUpdateSenderValid = False; +} + //~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ // void @@ -272,7 +375,84 @@ void Check(this); Check_Pointer(message); - lastUpdate = Now(); // HACK - should be based upon message->timeStamp + // + //------------------------------------------------------------------ + // When this update arrived is not when it was taken. Put lastUpdate + // at the sender's sampling moment, expressed in our clock, so the + // dead reckoner extrapolates over the network latency instead of + // starting from scratch once it has already elapsed. + //------------------------------------------------------------------ + // + long now_ticks = Now().ticks; + long local_ticks = now_ticks; + + PeerClock *peer = gUpdateSenderValid && NetClockEnabled() + ? FindPeer(gUpdateSender) : NULL; + if (peer != NULL) + { + // + // sample = trueOffset + oneWayLatency, so the running minimum + // converges on the offset from above. + // + long sample = now_ticks - message->timeStamp.ticks; + + if (!peer->settled) + { + peer->settled = True; + peer->offsetTicks = sample; + peer->windowMinTicks = sample; + peer->windowCount = 0; + DEBUG_STREAM << "NetClock: host " << peer->host + << " first sample, offset " << sample << " ms\n" << std::flush; + } + else + { + if (sample < peer->windowMinTicks) + { + peer->windowMinTicks = sample; + } + if (sample < peer->offsetTicks) + { + peer->offsetTicks = sample; // a shorter path: believe it now + } + if (++peer->windowCount >= netClockWindow) + { + // + // Close the window: adopt its minimum even if it is + // LARGER than the running estimate, which is how the + // figure follows clock drift and a route that got + // slower rather than staying pinned to one old packet. + // + long moved = peer->windowMinTicks - peer->offsetTicks; + if (moved > 50 || moved < -50) + { + DEBUG_STREAM << "NetClock: host " << peer->host + << " offset " << peer->offsetTicks << " -> " + << peer->windowMinTicks << " ms\n" << std::flush; + } + peer->offsetTicks = peer->windowMinTicks; + peer->windowMinTicks = sample; + peer->windowCount = 0; + } + } + + local_ticks = message->timeStamp.ticks + peer->offsetTicks; + + // + // Never ahead of our own clock, and never further back than we + // are willing to extrapolate. + // + if (local_ticks > now_ticks) + { + local_ticks = now_ticks; + } + else if (now_ticks - local_ticks > netClockMaxLagTicks) + { + local_ticks = now_ticks - netClockMaxLagTicks; + } + } + + lastUpdate.ticks = local_ticks; SetSimulationState(message->simulationState); Check_Fpu(); } diff --git a/MUNGA/SIMULATE.h b/MUNGA/SIMULATE.h index ab8acf5..391ddb6 100644 --- a/MUNGA/SIMULATE.h +++ b/MUNGA/SIMULATE.h @@ -4,6 +4,41 @@ #include "receiver.h" #include "time.h" #include "resource.h" +#include "hostid.h" + +//########################################################################## +//########################### Net clock ############################## +//########################################################################## +// +// Aligning a peer's clock with ours, so a replicant is dead-reckoned from +// when its update was SENT rather than when it happened to arrive. +// +// Every update record carries the sender's own timestamp. The receiver +// used to throw it away and stamp lastUpdate with its own Now() - the +// original code says so: "HACK - should be based upon message->timeStamp". +// The dead reckoner then extrapolates over (lastPerformance - lastUpdate), +// so starting that clock at ARRIVAL rather than at SEND leaves every +// remote vehicle exactly one network latency behind where it should be. +// On the 1 ms arcade LAN that was invisible. Over Steam Datagram Relay it +// is a constant 50-150 ms of positional lag - a bias, not jitter. +// +// The timestamp cannot be used raw: two machines' clocks share no epoch, +// both being QueryPerformanceCounter since their own boot. So we estimate +// the offset per peer. Each arriving record gives +// +// sample = ourNow - theirStamp = trueOffset + oneWayLatency +// +// and since latency is never negative, the SMALLEST sample seen is the +// closest to the true offset. Taking a minimum over a short rolling +// window tracks crystal drift and re-adapts when the route changes, +// instead of being pinned forever by one lucky packet. +// +// RP412NETCLOCK=0 turns the whole thing off and restores the arrival-time +// behaviour, so a test machine can A/B it without a rebuild. +// +void NetClock_BeginUpdate(HostID sender); // around one message's records +void NetClock_EndUpdate(); +void NetClock_Reset(); // forget every peer (new mission) class Simulation__SharedData; class Simulation__IndexData; diff --git a/RP_L4/RPL4ENVIRON.cpp b/RP_L4/RPL4ENVIRON.cpp index 7e17514..24b6ee6 100644 --- a/RP_L4/RPL4ENVIRON.cpp +++ b/RP_L4/RPL4ENVIRON.cpp @@ -190,6 +190,14 @@ namespace "# logs the reason and falls back to plain TCP. 0 = TCP only.\n" "RP412STEAM=1\n" "\n" +"# Line up each remote player's clock with ours, so their vehicle is\n" +"# extrapolated from when its update was SENT rather than when it\n" +"# arrived. Without it every remote pod sits one network latency behind\n" +"# where it should be - invisible on the 1ms arcade LAN the engine was\n" +"# written for, a constant 50-150ms of lag over the internet. 0 restores\n" +"# the old arrival-time behaviour if you want to compare.\n" +"#RP412NETCLOCK=0\n" +"\n" "# ---- Optional ---------------------------------------------------------------\n" "\n" "# RGB keyboard lamp mirror (Windows Dynamic Lighting): keys bound to\n"