RP412PHYSICSHZ names a rate and the simulation advances in whole steps of exactly that size on every machine, whatever the display does. 0 - the default, and the shipped behaviour until the play testers have spoken - is the game as it has always run: the step is however long the last frame took, which makes the frame rate part of the physics. Measured over two seconds of free fall, a 30 fps machine's pod fell three times further than a 144 fps machine's. Two players on the same track were not in the same gravity. With a rate set, the same race is bit-identical across frame rates: 30, 60 and 144 fps produce the same trajectory to the last printed digit, and identical runs reproduce exactly - which was never true of this engine before, at any frame rate. It took three pieces, and every one was found by measuring, not by reading: - Simulation::PerformTo turns lastPerformance into the accumulator it always secretly was: whole steps while time remains, the remainder carried to the next frame. Watchers and update records stay once per frame - stepping is physics, watching is I/O. - Entity::PerformAndWatch interleaves subsystems and entity per STEP. The frame loop ran all subsystems to the frame boundary and then the entity, indistinguishable from correct at one step per frame - which is why thirty years of code never noticed - and wrong at two: the thrusters raycast twice from a vehicle that had not moved, and the hover spring fired twice on one stale height sample. The subsystems are also snapped onto their entity's step grid; each Simulation anchors its grid at its own creation time, a per-run phase no seed could pin. - Mover::BeginStep clears the force accumulator per step. It was cleared once per frame while the thrusters ADD per step, so step two of a frame integrated step one's thrust again - and how many steps a frame holds rides on wall-clock jitter, which is why identical configs measured a quarter-metre apart. The quaternion renormalise counts steps now too, for the same reason. The catch-up clamp is a quarter second of simulation whatever the rate, so a machine that cannot keep up slows down rather than seizing, and does so identically everywhere. The engine's clock counts milliseconds, so rates that do not divide 1000 - 60 among them - quietly run at the neighbouring millisecond step; the log now says so and names the exact ones. 25, 50 and 100 are exact, and all three are verified bit-identical across frame rates and across runs. Verified for a single vehicle settling under gravity and hover. Driving, collisions and the network are the next frontiers, in that order: the collision path writes the victim's state with wall-clock stamps and a hard-coded 0.1 s bounce, which single-player survives and lockstep will not. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
456 lines
12 KiB
C++
456 lines
12 KiB
C++
#pragma once
|
|
|
|
#include "state.h"
|
|
#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;
|
|
struct Simulation__IndexEntry;
|
|
class Simulation__AttributeIndexSet;
|
|
class MemoryStream;
|
|
|
|
//##########################################################################
|
|
//################# Simulation::UpdateRecord #########################
|
|
//##########################################################################
|
|
|
|
struct Simulation__UpdateRecord
|
|
{
|
|
public:
|
|
size_t recordLength;
|
|
Word subsystemID;
|
|
Word recordID;
|
|
Time timeStamp;
|
|
Enumeration simulationState;
|
|
};
|
|
|
|
//##########################################################################
|
|
//####################### Simulation #################################
|
|
//##########################################################################
|
|
|
|
class Simulation:
|
|
public Receiver
|
|
{
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Shared Data Support
|
|
//
|
|
public:
|
|
typedef Simulation__SharedData SharedData;
|
|
SharedData*
|
|
GetSharedData();
|
|
|
|
static Derivation *GetClassDerivations();
|
|
|
|
static SharedData DefaultData;
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Construction and Destruction Support
|
|
//
|
|
protected:
|
|
Simulation(
|
|
ClassID class_ID,
|
|
SharedData &shared_data
|
|
);
|
|
|
|
public:
|
|
~Simulation();
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Attribute Support
|
|
//
|
|
public:
|
|
typedef Enumeration AttributeID;
|
|
typedef Simulation__IndexData IndexData;
|
|
typedef Simulation__IndexEntry IndexEntry;
|
|
typedef Simulation__AttributeIndexSet AttributeIndexSet;
|
|
typedef int Simulation::*AttributePointer;
|
|
|
|
enum {
|
|
AnyAttributeID = 0,
|
|
SimulationStateAttributeID,
|
|
NextAttributeID
|
|
};
|
|
|
|
static const AttributePointer NullAttribute;
|
|
|
|
void*
|
|
GetAttributePointer(AttributeID attribute);
|
|
void*
|
|
GetAttributePointer(const char* attribute_name);
|
|
|
|
private:
|
|
static const IndexEntry AttributePointers[];
|
|
|
|
protected:
|
|
//static AttributeIndexSet AttributeIndex
|
|
static AttributeIndexSet& GetAttributeIndex();
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Simulation Support
|
|
//
|
|
public:
|
|
typedef void
|
|
(Simulation::*Performance)(Scalar time_slice);
|
|
typedef void
|
|
(Simulation::*Encore)();
|
|
typedef Simulation__UpdateRecord UpdateRecord;
|
|
|
|
void
|
|
SetPerformance(Performance performance)
|
|
{Check(this); activePerformance = performance;}
|
|
void
|
|
Perform(Scalar time_slice)
|
|
{
|
|
Check(this);
|
|
(this->*activePerformance)(time_slice);
|
|
}
|
|
|
|
virtual void
|
|
PerformAndWatch(
|
|
const Time& till,
|
|
MemoryStream *update_stream
|
|
);
|
|
|
|
//
|
|
// The two halves of PerformAndWatch, so an ENTITY can interleave its
|
|
// subsystems' physics with its own, step by step, and still run the
|
|
// watchers and the update stream once per frame. PerformTo advances
|
|
// the simulation to the given time - in fixed steps when
|
|
// RP412PHYSICSHZ names a rate, in one variable slice otherwise.
|
|
//
|
|
void
|
|
PerformTo(const Time& till);
|
|
void
|
|
WatchAndWrite(MemoryStream *update_stream);
|
|
|
|
//
|
|
// Called by the entity interleave at the TOP of every fixed step,
|
|
// before any subsystem adds its forces for that step. Per-frame set-up
|
|
// work - clearing a force accumulator, deriving local velocity from
|
|
// world state - belongs here when the fixed step is on, because "once
|
|
// per frame" is a wall-clock cadence and the whole point is that wall
|
|
// clock no longer reaches the physics. Default: nothing.
|
|
//
|
|
virtual void
|
|
BeginStep();
|
|
|
|
//
|
|
// The fixed step in seconds, 0 when frame-coupled. Global on purpose:
|
|
// a mixed-rate simulation would be a worse bug than either mode.
|
|
//
|
|
static Scalar
|
|
FixedStep();
|
|
|
|
void
|
|
DoNothingOnce(Scalar time_slice);
|
|
void
|
|
DoNothing(Scalar time_slice);
|
|
|
|
void
|
|
SetLastPerformance(const Time& when)
|
|
{Check(this); Check(&when); lastPerformance = when;}
|
|
const Time&
|
|
GetLastPerformance() const
|
|
{Check(this); return lastPerformance;}
|
|
void
|
|
RequestEncore(Encore encore);
|
|
|
|
virtual void
|
|
ReadUpdateRecord(UpdateRecord *message);
|
|
virtual void
|
|
WriteUpdateRecord(
|
|
UpdateRecord *message,
|
|
int update_model
|
|
);
|
|
void
|
|
WriteSimulationUpdate(MemoryStream *update_stream);
|
|
|
|
enum {
|
|
DefaultUpdateModelBit=0,
|
|
NextUpdateModelBit
|
|
};
|
|
|
|
enum {
|
|
DefaultUpdateModelFlag = 1<<DefaultUpdateModelBit
|
|
};
|
|
|
|
void
|
|
ForceUpdate(Word model=DefaultUpdateModelFlag)
|
|
{Check(this); updateModel |= model;}
|
|
|
|
protected:
|
|
Time
|
|
lastPerformance;
|
|
Time
|
|
lastUpdate;
|
|
Word
|
|
updateModel;
|
|
|
|
Performance
|
|
activePerformance;
|
|
|
|
//##########################################################################
|
|
// Flag Support
|
|
//
|
|
public:
|
|
enum {
|
|
DelayWatchersBit,
|
|
DontExecuteBit,
|
|
NextBit
|
|
};
|
|
enum {
|
|
DelayWatchersFlag = 1<<DelayWatchersBit,
|
|
DontExecuteFlag = 1<<DontExecuteBit
|
|
};
|
|
|
|
LWord simulationFlags;
|
|
|
|
void
|
|
SetWatcherDelay()
|
|
{Check(this); simulationFlags |= DelayWatchersFlag;}
|
|
void
|
|
ClearWatcherDelay()
|
|
{Check(this); simulationFlags &= ~DelayWatchersFlag;}
|
|
Logical
|
|
AreWatchersDelayed()
|
|
{Check(this); return (simulationFlags & DelayWatchersFlag) != 0;}
|
|
|
|
void
|
|
NeverExecute()
|
|
{Check(this); simulationFlags |= DontExecuteFlag;}
|
|
void
|
|
ExecuteOnUpdate()
|
|
{Check(this); simulationFlags |= DontExecuteFlag;}
|
|
void
|
|
AlwaysExecute()
|
|
{Check(this); simulationFlags &= ~DontExecuteFlag;}
|
|
Logical
|
|
IsReplicantExecutable()
|
|
{
|
|
Check(this);
|
|
return
|
|
(simulationFlags&DontExecuteFlag) == 0
|
|
|| lastUpdate >= lastPerformance;
|
|
}
|
|
Logical
|
|
IsNonReplicantExecutable()
|
|
{Check(this); return (simulationFlags&DontExecuteFlag) == 0;}
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// State support
|
|
//
|
|
public:
|
|
enum {
|
|
DefaultState = 0,
|
|
StateCount
|
|
};
|
|
|
|
unsigned
|
|
GetSimulationState()
|
|
{Check(this); return simulationState.GetState();}
|
|
unsigned
|
|
GetOldSimulationState()
|
|
{Check(this); return simulationState.GetOldState();}
|
|
void
|
|
SetSimulationState(unsigned new_state)
|
|
{Check(this); simulationState.SetState(new_state);}
|
|
|
|
StateIndicator
|
|
simulationState;
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Watcher Support
|
|
//
|
|
public:
|
|
void
|
|
AddAudioWatcher(Component *watcher)
|
|
{Check(&audioWatcherSocket);audioWatcherSocket.Add(watcher);}
|
|
void
|
|
AddVideoWatcher(Component *watcher)
|
|
{Check(&videoWatcherSocket);videoWatcherSocket.Add(watcher);}
|
|
void
|
|
AddGaugeWatcher(Component *watcher)
|
|
{Check(&gaugeWatcherSocket);gaugeWatcherSocket.Add(watcher);}
|
|
|
|
void
|
|
AddEffectWatcher(Component *watcher)
|
|
{Check(&effectWatcherSocket); effectWatcherSocket.Add(watcher);}
|
|
|
|
void
|
|
ExecuteWatchers();
|
|
|
|
private:
|
|
SChainOf<Component*>
|
|
audioWatcherSocket;
|
|
SChainOf<Component*>
|
|
videoWatcherSocket;
|
|
SChainOf<Component*>
|
|
gaugeWatcherSocket;
|
|
SChainOf<Component*>
|
|
effectWatcherSocket;
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Test Support
|
|
//
|
|
public:
|
|
Logical
|
|
TestInstance() const;
|
|
static Logical
|
|
TestClass();
|
|
};
|
|
|
|
//##########################################################################
|
|
//################### Simulation::IndexEntry #########################
|
|
//##########################################################################
|
|
|
|
struct Simulation__IndexEntry
|
|
{
|
|
Enumeration
|
|
entryID;
|
|
const char *
|
|
entryName;
|
|
Simulation::AttributePointer
|
|
entryAddress;
|
|
};
|
|
|
|
#define ATTRIBUTE_ENTRY(class,name,attribute)\
|
|
{\
|
|
class::name##AttributeID,\
|
|
#name,\
|
|
(Simulation::AttributePointer) &class::attribute\
|
|
}
|
|
|
|
//##########################################################################
|
|
//################# Simulation::AttributeIndexSet ####################
|
|
//##########################################################################
|
|
|
|
class Simulation__AttributeIndexSet:
|
|
public Receiver::InheritanceSet
|
|
{
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Construction and Destruction
|
|
//
|
|
public:
|
|
Simulation__AttributeIndexSet(
|
|
Simulation::AttributeID count,
|
|
const Simulation::IndexEntry index_table[],
|
|
const Simulation::AttributeIndexSet &inheritance
|
|
)
|
|
{Build(count, index_table, &inheritance);}
|
|
|
|
Simulation__AttributeIndexSet(
|
|
Simulation::AttributeID count,
|
|
const Simulation::IndexEntry index_table[]
|
|
)
|
|
{Build(count, index_table, NULL);}
|
|
|
|
Simulation__AttributeIndexSet()
|
|
{attributeIndex = NULL; entryCount = 0;}
|
|
|
|
~Simulation__AttributeIndexSet();
|
|
|
|
protected:
|
|
void
|
|
Build(
|
|
Simulation::AttributeID count,
|
|
const Simulation::IndexEntry index_table[],
|
|
const Simulation::AttributeIndexSet *inheritance
|
|
);
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// AttributeIndexSet Functionality
|
|
//
|
|
protected:
|
|
Simulation::IndexEntry
|
|
*attributeIndex;
|
|
|
|
public:
|
|
Simulation::AttributePointer
|
|
Find(Simulation::AttributeID attribute) const
|
|
{
|
|
Check(this);
|
|
Verify(attribute > 0);
|
|
if (attribute<=entryCount)
|
|
return attributeIndex[attribute-1].entryAddress;
|
|
else
|
|
return Simulation::NullAttribute;
|
|
}
|
|
Simulation::AttributePointer
|
|
Find(const char* attribute_name) const;
|
|
const Simulation::IndexEntry*
|
|
FindEntry(const char* attribute_name) const;
|
|
|
|
static const Simulation::AttributeIndexSet
|
|
NullSet;
|
|
|
|
//~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
|
// Test Support
|
|
//
|
|
public:
|
|
static Logical
|
|
TestClass();
|
|
};
|
|
|
|
//##########################################################################
|
|
//################### Simulation::SharedData #########################
|
|
//##########################################################################
|
|
|
|
class Simulation__SharedData:
|
|
public Receiver::SharedData
|
|
{
|
|
public:
|
|
Simulation__SharedData(
|
|
Derivation* derivation,
|
|
Receiver::MessageHandlerSet &message_handlers,
|
|
Simulation::AttributeIndexSet &attribute_index,
|
|
int state_count
|
|
):
|
|
Receiver::SharedData(derivation, message_handlers),
|
|
activeAttributeIndex(&attribute_index),
|
|
stateCount(state_count)
|
|
{}
|
|
|
|
Simulation::AttributeIndexSet* activeAttributeIndex;
|
|
int stateCount;
|
|
};
|
|
|
|
inline Simulation::SharedData*
|
|
Simulation::GetSharedData()
|
|
{return Cast_Object(SharedData*,sharedData);}
|