Browse Source

Add an 8-bit timestamp to client controls, which gets incremented on each sent update. Echo this timestamp back in server updates sent to client. Allow to intercept network attribute updates into an event instead of applying them immediately, to facilitate implementing application-level client prediction. Add simulated latency & packet loss parameters to Network. Note that these changes break protocol compatibility with earlier Urho3D releases.

Lasse Öörni 10 years ago
parent
commit
99e49ac3b8

+ 18 - 3
Docs/Reference.dox

@@ -2267,8 +2267,6 @@ There are some things to watch out for:
 
 - Nodes have the concept of the \ref Node::SetOwner "owner connection" (for example the player that is controlling a specific game object), which can be set in server code. This property is not replicated to the client. Messages or remote events can be used instead to tell the players what object they control.
 
-- At least for now, there is no built-in client-side prediction.
-
 \section Network_InterestManagement Interest management
 
 %Scene replication includes a simple, distance-based interest management mechanism for reducing bandwidth use. To use, create the NetworkPriority component to a Node you wish to apply interest management to. The component can be created as local, as it is not important to the clients.
@@ -2290,7 +2288,20 @@ The Controls structure is used to send controls information from the client to t
 
 It is up to the client code to ensure they are kept up-to-date, by calling \ref Connection::SetControls "SetControls()" on the server connection. The event E_NETWORKUPDDATE will be sent to remind of the impending update, and the event E_NETWORKUPDATESENT will be sent after the update. The controls can then be inspected on the server side by calling \ref Connection::GetControls "GetControls()".
 
-The controls update message also includes the client's observer position for interest management.
+The controls update message also includes a running 8-bit timestamp, see \ref Connection::GetTimeStamp "GetTimeStamp()", and the client's observer position / rotation for interest management. To conserve bandwidth, the position and rotation values are left out if they have never been assigned.
+
+\section Network_ClientPrediction Client-side prediction
+
+Urho3D does not implement built-in client-side prediction for player controllable objects due to the difficulty of rewinding and resimulating a generic physics simulation. However, when not using
+physics for player movement, it is possible to intercept the authoritative network updates from the server and build a prediction system on the application level.
+
+By calling \ref Serializable::SetInterceptNetworkUpdate "SetInterceptNetworkUpdate()" the update of an individual networked attribute is redirected to send an event (E_INTERCEPTNETWORKUPDATE) instead of applying the attribute value directly. This should be called on the client for the node or component that is to be predicted. For example to redirect a Node's position update:
+
+\code
+node->SetInterceptNetworkUpdate("Network Position", true);
+\endcode
+
+The event includes the attribute name, index, new value as a Variant, and the latest 8-bit controls timestamp that the server has seen from the client. Typically, the event handler would store the value that arrived from the server and set an internal "update arrived" flag, which the application logic update code could use later on the same frame, by taking the server-sent value and replaying any user input on top of it. The timestamp value can be used to estimate how many client controls packets have been sent during the roundtrip time, and how much input needs to be replayed.
 
 \section Network_Messages Raw network messages
 
@@ -2329,6 +2340,10 @@ Connection@ remoteSender = eventData["Connection"].GetPtr();
 
 In addition to UDP messaging, the network subsystem allows to make HTTP requests. Use the \ref Network::MakeHttpRequest "MakeHttpRequest()" function for this. You can specify the URL, the verb to use (default GET if empty), optional headers and optional post data. The HttpRequest object that is returned acts like a Deserializer, and you can read the response data in suitably sized chunks. After the whole response is read, the connection closes. The connection can also be closed early by allowing the request object to expire.
 
+\section Network_Simulation Network conditions simulation
+
+The Network subsystem is able to add send delay to packets and to simulate packet loss. See \ref Network::SetSimulatedLatency "SetSimulatedLatency()" and \ref Network::SetSimulatedPacketLoss "SetSimultedPacketLoss".
+
 \page Multithreading Multithreading
 
 Urho3D uses a task-based multithreading model. The WorkQueue subsystem can be supplied with tasks described by the WorkItem structure, by calling \ref WorkQueue::AddWorkItem "AddWorkItem()". These will be executed in background worker threads. The function \ref WorkQueue::Complete "Complete()" will complete all currently pending tasks, and execute them also in the main thread to make them finish faster.

+ 4 - 2
Source/Urho3D/LuaScript/pkgs/Network/Connection.pkg

@@ -28,10 +28,11 @@ class Connection : public Object
     void SetLogStatistics(bool enable);
     void Disconnect(int waitMSec = 0);
     void SendPackageToClient(PackageFile* package);
-    
+
     VariantMap& GetIdentity();
     Scene* GetScene() const;
     const Controls& GetControls() const;
+    unsigned char GetTimeStamp() const;
     const Vector3& GetPosition() const;
     const Quaternion& GetRotation() const;
     bool IsClient() const;
@@ -45,10 +46,11 @@ class Connection : public Object
     unsigned GetNumDownloads() const;
     const String GetDownloadName() const;
     float GetDownloadProgress() const;
-    
+
     tolua_property__get_set VariantMap& identity;
     tolua_property__get_set Scene* scene;
     tolua_property__get_set Controls& controls;
+    tolua_readonly tolua_property__get_set unsigned char timeStamp;
     tolua_property__get_set Vector3& position;
     tolua_property__get_set Quaternion& rotation;
     tolua_readonly tolua_property__is_set bool client;

+ 6 - 0
Source/Urho3D/LuaScript/pkgs/Network/Network.pkg

@@ -20,6 +20,8 @@ class Network
     void BroadcastRemoteEvent(Node* node, const String eventType, bool inOrder, const VariantMap& eventData = Variant::emptyVariantMap);
     
     void SetUpdateFps(int fps);
+    void SetSimulatedLatency(int ms);
+    void SetSimulatedPacketLoss(float loss);
     
     void RegisterRemoteEvent(StringHash eventType);
     void RegisterRemoteEvent(const String eventType);
@@ -35,6 +37,8 @@ class Network
     tolua_outside HttpRequest* NetworkMakeHttpRequest @ MakeHttpRequest(const String url, const String verb = String::EMPTY, const Vector<String>& headers = Vector<String>(), const String postData = String::EMPTY);
     
     int GetUpdateFps() const;
+    int GetSimulatedLatency() const;
+    float GetSimulatedPacketLoss() const;
     Connection* GetServerConnection() const;
     
     bool IsServerRunning() const;
@@ -43,6 +47,8 @@ class Network
     const String GetPackageCacheDir() const;
     
     tolua_property__get_set int updateFps;
+    tolua_property__get_set int simulatedLatency;
+    tolua_property__get_set float simulatedPacketLoss;
     tolua_readonly tolua_property__get_set Connection* serverConnection;
     tolua_readonly tolua_property__is_set bool serverRunning;
     tolua_property__get_set String packageCacheDir;

+ 3 - 1
Source/Urho3D/LuaScript/pkgs/Scene/Serializable.pkg

@@ -4,6 +4,8 @@ class Serializable : public Object
 {
     void SetTemporary(bool enable);
     bool IsTemporary() const;
-    
+    void SetInterceptNetworkUpdate(const String attributeName, bool enable);
+    bool GetInterceptNetworkUpdate(const String attributeName);
+
     tolua_property__is_set bool temporary;
 };

+ 53 - 36
Source/Urho3D/Network/Connection.cpp

@@ -61,6 +61,7 @@ PackageUpload::PackageUpload() :
 
 Connection::Connection(Context* context, bool isClient, kNet::SharedPtr<kNet::MessageConnection> connection) :
     Object(context),
+    timeStamp_(0),
     connection_(connection),
     sendMode_(OPSM_NONE),
     isClient_(isClient),
@@ -271,11 +272,14 @@ void Connection::SendClientUpdate()
     msg_.WriteFloat(controls_.yaw_);
     msg_.WriteFloat(controls_.pitch_);
     msg_.WriteVariantMap(controls_.extraData_);
+    msg_.WriteUByte(timeStamp_);
     if (sendMode_ >= OPSM_POSITION)
         msg_.WriteVector3(position_);
     if (sendMode_ >= OPSM_POSITION_ROTATION)
         msg_.WritePackedQuaternion(rotation_);
     SendMessage(MSG_CONTROLS, false, false, msg_, CONTROLS_CONTENT_ID);
+
+    ++timeStamp_;
 }
 
 void Connection::SendRemoteEvents()
@@ -373,8 +377,8 @@ void Connection::ProcessPendingLatestData()
         {
             MemoryBuffer msg(current->second_);
             msg.ReadNetID(); // Skip the component ID
-            component->ReadLatestDataUpdate(msg);
-            component->ApplyAttributes();
+            if (component->ReadLatestDataUpdate(msg))
+                component->ApplyAttributes();
             componentLatestData_.Erase(current);
         }
     }
@@ -680,8 +684,8 @@ void Connection::ProcessSceneUpdate(int msgID, MemoryBuffer& msg)
             Component* component = scene_->GetComponent(componentID);
             if (component)
             {
-                component->ReadLatestDataUpdate(msg);
-                component->ApplyAttributes();
+                if (component->ReadLatestDataUpdate(msg))
+                    component->ApplyAttributes();
             }
             else
             {
@@ -879,6 +883,8 @@ void Connection::ProcessControls(int msgID, MemoryBuffer& msg)
     newControls.extraData_ = msg.ReadVariantMap();
     
     SetControls(newControls);
+    timeStamp_ = msg.ReadUByte();
+
     // Client may or may not send observer position & rotation for interest management
     if (!msg.IsEof())
         position_ = msg.ReadVector3();
@@ -1011,6 +1017,42 @@ float Connection::GetDownloadProgress() const
     return 1.0f;
 }
 
+void Connection::SendPackageToClient(PackageFile* package)
+{
+    if (!scene_)
+        return;
+
+    if (!IsClient())
+    {
+        LOGERROR("SendPackageToClient can be called on the server only");
+        return;
+    }
+    if (!package)
+    {
+        LOGERROR("Null package specified for SendPackageToClient");
+        return;
+    }
+
+    msg_.Clear();
+
+    String filename = GetFileNameAndExtension(package->GetName());
+    msg_.WriteString(filename);
+    msg_.WriteUInt(package->GetTotalSize());
+    msg_.WriteUInt(package->GetChecksum());
+    SendMessage(MSG_PACKAGEINFO, true, true, msg_);
+}
+
+void Connection::ConfigureNetworkSimulator(int latencyMs, float packetLoss)
+{
+    if (connection_)
+    {
+        kNet::NetworkSimulator& simulator = connection_->NetworkSendSimulator();
+        simulator.enabled = latencyMs > 0 || packetLoss > 0.0f;
+        simulator.constantPacketSendDelay = (float)latencyMs;
+        simulator.packetLossRate = packetLoss;
+    }
+}
+
 void Connection::HandleAsyncLoadFinished(StringHash eventType, VariantMap& eventData)
 {
     sceneLoaded_ = true;
@@ -1081,7 +1123,7 @@ void Connection::ProcessNewNode(Node* node)
     node->AddReplicationState(&nodeState);
     
     // Write node's attributes
-    node->WriteInitialDeltaUpdate(msg_);
+    node->WriteInitialDeltaUpdate(msg_, timeStamp_);
     
     // Write node's user variables
     const VariantMap& vars = node->GetVars();
@@ -1110,7 +1152,7 @@ void Connection::ProcessNewNode(Node* node)
         
         msg_.WriteStringHash(component->GetType());
         msg_.WriteNetID(component->GetID());
-        component->WriteInitialDeltaUpdate(msg_);
+        component->WriteInitialDeltaUpdate(msg_, timeStamp_);
     }
     
     SendMessage(MSG_CREATENODE, true, true, msg_);
@@ -1161,7 +1203,7 @@ void Connection::ProcessExistingNode(Node* node, NodeReplicationState& nodeState
         {
             msg_.Clear();
             msg_.WriteNetID(node->GetID());
-            node->WriteLatestDataUpdate(msg_);
+            node->WriteLatestDataUpdate(msg_, timeStamp_);
             
             SendMessage(MSG_NODELATESTDATA, true, false, msg_, node->GetID());
         }
@@ -1171,7 +1213,7 @@ void Connection::ProcessExistingNode(Node* node, NodeReplicationState& nodeState
         {
             msg_.Clear();
             msg_.WriteNetID(node->GetID());
-            node->WriteDeltaUpdate(msg_, nodeState.dirtyAttributes_);
+            node->WriteDeltaUpdate(msg_, nodeState.dirtyAttributes_, timeStamp_);
             
             // Write changed variables
             msg_.WriteVLE(nodeState.dirtyVars_.Size());
@@ -1239,7 +1281,7 @@ void Connection::ProcessExistingNode(Node* node, NodeReplicationState& nodeState
                 {
                     msg_.Clear();
                     msg_.WriteNetID(component->GetID());
-                    component->WriteLatestDataUpdate(msg_);
+                    component->WriteLatestDataUpdate(msg_, timeStamp_);
                     
                     SendMessage(MSG_COMPONENTLATESTDATA, true, false, msg_, component->GetID());
                 }
@@ -1249,7 +1291,7 @@ void Connection::ProcessExistingNode(Node* node, NodeReplicationState& nodeState
                 {
                     msg_.Clear();
                     msg_.WriteNetID(component->GetID());
-                    component->WriteDeltaUpdate(msg_, componentState.dirtyAttributes_);
+                    component->WriteDeltaUpdate(msg_, componentState.dirtyAttributes_, timeStamp_);
                     
                     SendMessage(MSG_COMPONENTDELTAUPDATE, true, true, msg_);
                     
@@ -1284,7 +1326,7 @@ void Connection::ProcessExistingNode(Node* node, NodeReplicationState& nodeState
                 msg_.WriteNetID(node->GetID());
                 msg_.WriteStringHash(component->GetType());
                 msg_.WriteNetID(component->GetID());
-                component->WriteInitialDeltaUpdate(msg_);
+                component->WriteInitialDeltaUpdate(msg_, timeStamp_);
                 
                 SendMessage(MSG_CREATECOMPONENT, true, true, msg_);
             }
@@ -1451,31 +1493,6 @@ void Connection::OnPackagesReady()
     }
 }
 
-void Connection::SendPackageToClient(PackageFile* package)
-{
-    if (!scene_)
-        return;
-
-    if (!IsClient())
-    {
-        LOGERROR("SendPackageToClient can be called on the server only");
-        return;
-    }
-    if (!package)
-    {
-        LOGERROR("Null package specified for SendPackageToClient");
-        return;
-    }
-    
-    msg_.Clear();
-
-    String filename = GetFileNameAndExtension(package->GetName());
-    msg_.WriteString(filename);
-    msg_.WriteUInt(package->GetTotalSize());
-    msg_.WriteUInt(package->GetChecksum());
-    SendMessage(MSG_PACKAGEINFO, true, true, msg_);
-}
-
 void Connection::ProcessPackageInfo(int msgID, MemoryBuffer& msg)
 {
     if (!scene_)

+ 7 - 0
Source/Urho3D/Network/Connection.h

@@ -157,6 +157,8 @@ public:
     Scene* GetScene() const;
     /// Return the client controls of this connection.
     const Controls& GetControls() const { return controls_; }
+    /// Return the controls timestamp, sent from client to server along each control update.
+    unsigned char GetTimeStamp() const { return timeStamp_; }
     /// Return the observer position sent by the client for interest management.
     const Vector3& GetPosition() const { return position_; }
     /// Return the observer rotation sent by the client for interest management.
@@ -186,8 +188,13 @@ public:
     /// Trigger client connection to download a package file from the server. Can be used to download additional resource packages when client is already joined in a scene. The package must have been added as a requirement to the scene the client is joined in, or else the eventual download will fail.
     void SendPackageToClient(PackageFile* package);
 
+    /// Set network simulation parameters. Called by Network.
+    void ConfigureNetworkSimulator(int latencyMs, float packetLoss);
+
     /// Current controls.
     Controls controls_;
+    /// Controls timestamp. Incremented after each sent update.
+    unsigned char timeStamp_;
     /// Identity map.
     VariantMap identity_;
     

+ 27 - 0
Source/Urho3D/Network/Network.cpp

@@ -48,6 +48,8 @@ static const int DEFAULT_UPDATE_FPS = 30;
 Network::Network(Context* context) :
     Object(context),
     updateFps_(DEFAULT_UPDATE_FPS),
+    simulatedLatency_(0),
+    simulatedPacketLoss_(0.0f),
     updateInterval_(1.0f / (float)DEFAULT_UPDATE_FPS),
     updateAcc_(0.0f)
 {
@@ -166,6 +168,7 @@ void Network::NewConnectionEstablished(kNet::MessageConnection* connection)
     
     // Create a new client connection corresponding to this MessageConnection
     SharedPtr<Connection> newConnection(new Connection(context_, true, kNet::SharedPtr<kNet::MessageConnection>(connection)));
+    newConnection->ConfigureNetworkSimulator(simulatedLatency_, simulatedPacketLoss_);
     clientConnections_[connection] = newConnection;
     LOGINFO("Client " + newConnection->ToString() + " connected");
     
@@ -215,6 +218,8 @@ bool Network::Connect(const String& address, unsigned short port, Scene* scene,
         serverConnection_->SetScene(scene);
         serverConnection_->SetIdentity(identity);
         serverConnection_->SetConnectPending(true);
+        serverConnection_->ConfigureNetworkSimulator(simulatedLatency_, simulatedPacketLoss_);
+
         LOGINFO("Connecting to server " + serverConnection_->ToString());
         return true;
     }
@@ -334,6 +339,18 @@ void Network::SetUpdateFps(int fps)
     updateAcc_ = 0.0f;
 }
 
+void Network::SetSimulatedLatency(int ms)
+{
+    simulatedLatency_ = Max(ms, 0);
+    ConfigureNetworkSimulator();
+}
+
+void Network::SetSimulatedPacketLoss(float loss)
+{
+    simulatedPacketLoss_ = Clamp(loss, 0.0f, 1.0f);
+    ConfigureNetworkSimulator();
+}
+
 void Network::RegisterRemoteEvent(StringHash eventType)
 {
     if (blacklistedRemoteEvents_.Find(eventType) != blacklistedRemoteEvents_.End())
@@ -565,6 +582,16 @@ void Network::OnServerDisconnected()
     }
 }
 
+void Network::ConfigureNetworkSimulator()
+{
+    if (serverConnection_)
+        serverConnection_->ConfigureNetworkSimulator(simulatedLatency_, simulatedPacketLoss_);
+
+    for (HashMap<kNet::MessageConnection*, SharedPtr<Connection> >::Iterator i = clientConnections_.Begin();
+        i != clientConnections_.End(); ++i)
+        i->second_->ConfigureNetworkSimulator(simulatedLatency_, simulatedPacketLoss_);
+}
+
 void RegisterNetworkLibrary(Context* context)
 {
     NetworkPriority::RegisterObject(context);

+ 14 - 0
Source/Urho3D/Network/Network.h

@@ -83,6 +83,10 @@ public:
     void BroadcastRemoteEvent(Node* node, StringHash eventType, bool inOrder, const VariantMap& eventData = Variant::emptyVariantMap);
     /// Set network update FPS.
     void SetUpdateFps(int fps);
+    /// Set simulated latency in milliseconds. This adds a delay to sent packets (both client and server.)
+    void SetSimulatedLatency(int ms);
+    /// Set simulated packet loss probability between 0.0 - 1.0.
+    void SetSimulatedPacketLoss(float probability);
     /// Register a remote event as allowed to be received. There is also a fixed blacklist of events that can not be allowed in any case, such as ConsoleCommand.
     void RegisterRemoteEvent(StringHash eventType);
     /// Unregister a remote event as allowed to received.
@@ -98,6 +102,10 @@ public:
 
     /// Return network update FPS.
     int GetUpdateFps() const { return updateFps_; }
+    /// Return simulated latency in milliseconds.
+    int GetSimulatedLatency() const { return simulatedLatency_; }
+    /// Return simulated packet loss probability.
+    float GetSimulatedPacketLoss() const { return simulatedPacketLoss_; }
     /// Return a client or server connection by kNet MessageConnection, or null if none exist.
     Connection* GetConnection(kNet::MessageConnection* connection) const;
     /// Return the connection to the server. Null if not connected.
@@ -125,6 +133,8 @@ private:
     void OnServerConnected();
     /// Handle server disconnection.
     void OnServerDisconnected();
+    /// Reconfigure network simulator parameters on all existing connections.
+    void ConfigureNetworkSimulator();
     
     /// kNet instance.
     kNet::Network* network_;
@@ -140,6 +150,10 @@ private:
     HashSet<Scene*> networkScenes_;
     /// Update FPS.
     int updateFps_;
+    /// Simulated latency (send delay) in milliseconds.
+    int simulatedLatency_;
+    /// Simulated packet loss probability between 0.0 - 1.0.
+    float simulatedPacketLoss_;
     /// Update time interval.
     float updateInterval_;
     /// Update time accumulator.

+ 8 - 0
Source/Urho3D/Scene/ReplicationState.h

@@ -124,6 +124,12 @@ struct URHO3D_API DirtyBits
 /// Per-object attribute state for network replication, allocated on demand.
 struct URHO3D_API NetworkState
 {
+    /// Construct with defaults.
+    NetworkState() :
+        interceptMask_(0)
+    {
+    }
+
     /// Cached network attribute infos.
     const Vector<AttributeInfo>* attributes_;
     /// Current network attribute values.
@@ -134,6 +140,8 @@ struct URHO3D_API NetworkState
     PODVector<ReplicationState*> replicationStates_;
     /// Previous user variables.
     VariantMap previousVars_;
+    /// Bitmask for intercepting network messages. Used on the client only.
+    unsigned long long interceptMask_;
 };
 
 /// Base class for per-user network replication states.

+ 10 - 0
Source/Urho3D/Scene/SceneEvents.h

@@ -170,4 +170,14 @@ EVENT(E_TEMPORARYCHANGED, TemporaryChanged)
     PARAM(P_SERIALIZABLE, Serializable);    // Serializable pointer
 }
 
+/// A network attribute update from the server has been intercepted.
+EVENT(E_INTERCEPTNETWORKUPDATE, InterceptNetworkUpdate)
+{
+    PARAM(P_SERIALIZABLE, Serializable);    // Serializable pointer
+    PARAM(P_TIMESTAMP, TimeStamp);          // unsigned (0-255)
+    PARAM(P_INDEX, Index);                  // unsigned
+    PARAM(P_NAME, Name);                    // String
+    PARAM(P_VALUE, Value);                  // Variant
+}
+
 }

+ 116 - 9
Source/Urho3D/Scene/Serializable.cpp

@@ -34,6 +34,24 @@
 namespace Urho3D
 {
 
+static unsigned RemapAttributeIndex(const Vector<AttributeInfo>* attributes, const AttributeInfo& netAttr, unsigned netAttrIndex)
+{
+    if (!attributes)
+        return netAttrIndex; // Could not remap
+
+    for (unsigned i = 0; i < attributes->Size(); ++i)
+    {
+        const AttributeInfo& attr = attributes->At(i);
+        // Compare either the accessor or offset to avoid name string compare
+        if (attr.accessor_.Get() && attr.accessor_.Get() == netAttr.accessor_.Get())
+            return i;
+        else if (!attr.accessor_.Get() && attr.offset_ == netAttr.offset_)
+            return i;
+    }
+
+    return netAttrIndex; // Could not remap
+}
+
 Serializable::Serializable(Context* context) :
     Object(context),
     networkState_(0),
@@ -519,6 +537,28 @@ void Serializable::SetTemporary(bool enable)
     }
 }
 
+void Serializable::SetInterceptNetworkUpdate(const String& attributeName, bool enable)
+{
+    const Vector<AttributeInfo>* attributes = GetNetworkAttributes();
+    if (!attributes)
+        return;
+
+    AllocateNetworkState();
+
+    for (unsigned i = 0; i < attributes->Size(); ++i)
+    {
+        const AttributeInfo& attr = attributes->At(i);
+        if (!attr.name_.Compare(attributeName, true))
+        {
+            if (enable)
+                networkState_->interceptMask_ |= 1ULL << i;
+            else
+                networkState_->interceptMask_ &= ~(1ULL << i);
+            break;
+        }
+    }
+}
+
 void Serializable::AllocateNetworkState()
 {
     if (!networkState_)
@@ -529,7 +569,7 @@ void Serializable::AllocateNetworkState()
     }
 }
 
-void Serializable::WriteInitialDeltaUpdate(Serializer& dest)
+void Serializable::WriteInitialDeltaUpdate(Serializer& dest, unsigned char timeStamp)
 {
     if (!networkState_)
     {
@@ -553,6 +593,7 @@ void Serializable::WriteInitialDeltaUpdate(Serializer& dest)
     }
 
     // First write the change bitfield, then attribute data for non-default attributes
+    dest.WriteUByte(timeStamp);
     dest.Write(attributeBits.data_, (numAttributes + 7) >> 3);
 
     for (unsigned i = 0; i < numAttributes; ++i)
@@ -562,7 +603,7 @@ void Serializable::WriteInitialDeltaUpdate(Serializer& dest)
     }
 }
 
-void Serializable::WriteDeltaUpdate(Serializer& dest, const DirtyBits& attributeBits)
+void Serializable::WriteDeltaUpdate(Serializer& dest, const DirtyBits& attributeBits, unsigned char timeStamp)
 {
     if (!networkState_)
     {
@@ -578,6 +619,7 @@ void Serializable::WriteDeltaUpdate(Serializer& dest, const DirtyBits& attribute
 
     // First write the change bitfield, then attribute data for changed attributes
     // Note: the attribute bits should not contain LATESTDATA attributes
+    dest.WriteUByte(timeStamp);
     dest.Write(attributeBits.data_, (numAttributes + 7) >> 3);
 
     for (unsigned i = 0; i < numAttributes; ++i)
@@ -587,7 +629,7 @@ void Serializable::WriteDeltaUpdate(Serializer& dest, const DirtyBits& attribute
     }
 }
 
-void Serializable::WriteLatestDataUpdate(Serializer& dest)
+void Serializable::WriteLatestDataUpdate(Serializer& dest, unsigned char timeStamp)
 {
     if (!networkState_)
     {
@@ -601,6 +643,8 @@ void Serializable::WriteLatestDataUpdate(Serializer& dest)
 
     unsigned numAttributes = attributes->Size();
 
+    dest.WriteUByte(timeStamp);
+
     for (unsigned i = 0; i < numAttributes; ++i)
     {
         if (attributes->At(i).mode_ & AM_LATESTDATA)
@@ -608,15 +652,18 @@ void Serializable::WriteLatestDataUpdate(Serializer& dest)
     }
 }
 
-void Serializable::ReadDeltaUpdate(Deserializer& source)
+bool Serializable::ReadDeltaUpdate(Deserializer& source)
 {
     const Vector<AttributeInfo>* attributes = GetNetworkAttributes();
     if (!attributes)
-        return;
+        return false;
 
     unsigned numAttributes = attributes->Size();
     DirtyBits attributeBits;
+    bool changed = false;
 
+    unsigned long long interceptMask = networkState_ ? networkState_->interceptMask_ : 0;
+    unsigned char timeStamp = source.ReadUByte();
     source.Read(attributeBits.data_, (numAttributes + 7) >> 3);
 
     for (unsigned i = 0; i < numAttributes && !source.IsEof(); ++i)
@@ -624,25 +671,67 @@ void Serializable::ReadDeltaUpdate(Deserializer& source)
         if (attributeBits.IsSet(i))
         {
             const AttributeInfo& attr = attributes->At(i);
-            OnSetAttribute(attr, source.ReadVariant(attr.type_));
+            if (!(interceptMask & (1ULL << i)))
+            {
+                OnSetAttribute(attr, source.ReadVariant(attr.type_));
+                changed = true;
+            }
+            else
+            {
+                using namespace InterceptNetworkUpdate;
+
+                VariantMap& eventData = GetEventDataMap();
+                eventData[P_SERIALIZABLE] = this;
+                eventData[P_TIMESTAMP] = (unsigned)timeStamp;
+                eventData[P_INDEX] = RemapAttributeIndex(GetAttributes(), attr, i);
+                eventData[P_NAME] = attr.name_;
+                eventData[P_VALUE] = source.ReadVariant(attr.type_);
+                SendEvent(E_INTERCEPTNETWORKUPDATE, eventData);
+            }
         }
     }
+
+    return changed;
 }
 
-void Serializable::ReadLatestDataUpdate(Deserializer& source)
+bool Serializable::ReadLatestDataUpdate(Deserializer& source)
 {
     const Vector<AttributeInfo>* attributes = GetNetworkAttributes();
     if (!attributes)
-        return;
+        return false;
 
     unsigned numAttributes = attributes->Size();
+    bool changed = false;
+
+    unsigned long long interceptMask = networkState_ ? networkState_->interceptMask_ : 0;
+    unsigned char timeStamp = source.ReadUByte();
 
     for (unsigned i = 0; i < numAttributes && !source.IsEof(); ++i)
     {
         const AttributeInfo& attr = attributes->At(i);
         if (attr.mode_ & AM_LATESTDATA)
-            OnSetAttribute(attr, source.ReadVariant(attr.type_));
+        {
+            if (!(interceptMask & (1ULL << i)))
+            {
+                OnSetAttribute(attr, source.ReadVariant(attr.type_));
+                changed = true;
+            }
+            else
+            {
+                using namespace InterceptNetworkUpdate;
+
+                VariantMap& eventData = GetEventDataMap();
+                eventData[P_SERIALIZABLE] = this;
+                eventData[P_TIMESTAMP] = (unsigned)timeStamp;
+                eventData[P_INDEX] = RemapAttributeIndex(GetAttributes(), attr, i);
+                eventData[P_NAME] = attr.name_;
+                eventData[P_VALUE] = source.ReadVariant(attr.type_);
+                SendEvent(E_INTERCEPTNETWORKUPDATE, eventData);
+            }
+        }
     }
+
+    return changed;
 }
 
 Variant Serializable::GetAttribute(unsigned index) const
@@ -744,6 +833,24 @@ unsigned Serializable::GetNumNetworkAttributes() const
     return attributes ? attributes->Size() : 0;
 }
 
+bool Serializable::GetInterceptNetworkUpdate(const String& attributeName) const
+{
+    const Vector<AttributeInfo>* attributes = GetNetworkAttributes();
+    if (!attributes)
+        return false;
+
+    unsigned long long interceptMask = networkState_ ? networkState_->interceptMask_ : 0;
+
+    for (unsigned i = 0; i < attributes->Size(); ++i)
+    {
+        const AttributeInfo& attr = attributes->At(i);
+        if (!attr.name_.Compare(attributeName, true))
+            return interceptMask & (1ULL << i) ? true : false;
+    }
+
+    return false;
+}
+
 void Serializable::SetInstanceDefault(const String& name, const Variant& defaultValue)
 {
     // Allocate the instance level default value

+ 13 - 7
Source/Urho3D/Scene/Serializable.h

@@ -83,18 +83,20 @@ public:
     void RemoveInstanceDefault();
     /// Set temporary flag. Temporary objects will not be saved.
     void SetTemporary(bool enable);
+    /// Enable interception of an attribute from network updates. Intercepted attributes are sent as events instead of applying directly. This can be used to implement client side prediction.
+    void SetInterceptNetworkUpdate(const String& attributeName, bool enable);
     /// Allocate network attribute state.
     void AllocateNetworkState();
     /// Write initial delta network update.
-    void WriteInitialDeltaUpdate(Serializer& dest);
+    void WriteInitialDeltaUpdate(Serializer& dest, unsigned char timeStamp);
     /// Write a delta network update according to dirty attribute bits.
-    void WriteDeltaUpdate(Serializer& dest, const DirtyBits& attributeBits);
+    void WriteDeltaUpdate(Serializer& dest, const DirtyBits& attributeBits, unsigned char timeStamp);
     /// Write a latest data network update.
-    void WriteLatestDataUpdate(Serializer& dest);
-    /// Read and apply a network delta update.
-    void ReadDeltaUpdate(Deserializer& source);
-    /// Read and apply a network latest data update.
-    void ReadLatestDataUpdate(Deserializer& source);
+    void WriteLatestDataUpdate(Serializer& dest, unsigned char timeStamp);
+    /// Read and apply a network delta update. Return true if attributes were changed.
+    bool ReadDeltaUpdate(Deserializer& source);
+    /// Read and apply a network latest data update. Return true if attributes were changed.
+    bool ReadLatestDataUpdate(Deserializer& source);
 
     /// Return attribute value by index. Return empty if illegal index.
     Variant GetAttribute(unsigned index) const;
@@ -110,6 +112,10 @@ public:
     unsigned GetNumNetworkAttributes() const;
     /// Return whether is temporary.
     bool IsTemporary() const { return temporary_; }
+    /// Return whether an attribute's network updates are being intercepted.
+    bool GetInterceptNetworkUpdate(const String& attributeName) const;
+    /// Return the network attribute state, if allocated.
+    NetworkState* GetNetworkState() const { return networkState_; }
 
 protected:
     /// Network attribute state.

+ 2 - 0
Source/Urho3D/Script/APITemplates.h

@@ -443,6 +443,8 @@ template <class T> void RegisterSerializable(asIScriptEngine* engine, const char
     engine->RegisterObjectMethod(className, "void RemoveInstanceDefault()", asMETHOD(T, RemoveInstanceDefault), asCALL_THISCALL);
     engine->RegisterObjectMethod(className, "Variant GetAttribute(const String&in) const", asMETHODPR(T, GetAttribute, (const String&) const, Variant), asCALL_THISCALL);
     engine->RegisterObjectMethod(className, "Variant GetAttributeDefault(const String&in) const", asMETHODPR(T, GetAttributeDefault, (const String&) const, Variant), asCALL_THISCALL);
+    engine->RegisterObjectMethod(className, "void SetInterceptNetworkUpdate(const String&in, bool)", asMETHODPR(T, SetInterceptNetworkUpdate, (const String&, bool), void), asCALL_THISCALL);
+    engine->RegisterObjectMethod(className, "bool GetInterceptNetworkUpdate(const String&in) const", asMETHODPR(T, GetInterceptNetworkUpdate, (const String&) const, bool), asCALL_THISCALL);
     engine->RegisterObjectMethod(className, "uint get_numAttributes() const", asMETHODPR(T, GetNumAttributes, () const, unsigned), asCALL_THISCALL);
     engine->RegisterObjectMethod(className, "bool set_attributes(uint, const Variant&in) const", asMETHODPR(T, SetAttribute, (unsigned, const Variant&), bool), asCALL_THISCALL);
     engine->RegisterObjectMethod(className, "Variant get_attributes(uint) const", asMETHODPR(T, GetAttribute, (unsigned) const, Variant), asCALL_THISCALL);

+ 5 - 0
Source/Urho3D/Script/NetworkAPI.cpp

@@ -80,6 +80,7 @@ static void RegisterConnection(asIScriptEngine* engine)
     engine->RegisterObjectMethod("Connection", "const Quaternion& get_rotation() const", asMETHOD(Connection, GetRotation), asCALL_THISCALL);
     engine->RegisterObjectMethod("Connection", "void SendPackageToClient(PackageFile@+)", asMETHOD(Connection, SendPackageToClient), asCALL_THISCALL);
     engine->RegisterObjectProperty("Connection", "Controls controls", offsetof(Connection, controls_));
+    engine->RegisterObjectProperty("Connection", "uint8 timeStamp", offsetof(Connection, timeStamp_));
     engine->RegisterObjectProperty("Connection", "VariantMap identity", offsetof(Connection, identity_));
     
     // Register SetOwner/GetOwner now
@@ -175,6 +176,10 @@ void RegisterNetwork(asIScriptEngine* engine)
     engine->RegisterObjectMethod("Network", "void SendPackageToClients(Scene@+, PackageFile@+)", asMETHOD(Network, SendPackageToClients), asCALL_THISCALL);
     engine->RegisterObjectMethod("Network", "void set_updateFps(int)", asMETHOD(Network, SetUpdateFps), asCALL_THISCALL);
     engine->RegisterObjectMethod("Network", "int get_updateFps() const", asMETHOD(Network, GetUpdateFps), asCALL_THISCALL);
+    engine->RegisterObjectMethod("Network", "void set_simulatedLatency(int)", asMETHOD(Network, SetSimulatedLatency), asCALL_THISCALL);
+    engine->RegisterObjectMethod("Network", "int get_simulatedLatency() const", asMETHOD(Network, GetSimulatedLatency), asCALL_THISCALL);
+    engine->RegisterObjectMethod("Network", "void set_simulatedPacketLoss(float)", asMETHOD(Network, SetSimulatedPacketLoss), asCALL_THISCALL);
+    engine->RegisterObjectMethod("Network", "float get_simulatedPacketLoss() const", asMETHOD(Network, GetSimulatedPacketLoss), asCALL_THISCALL);
     engine->RegisterObjectMethod("Network", "void set_packageCacheDir(const String&in)", asMETHOD(Network, SetPackageCacheDir), asCALL_THISCALL);
     engine->RegisterObjectMethod("Network", "const String& get_packageCacheDir() const", asMETHOD(Network, GetPackageCacheDir), asCALL_THISCALL);
     engine->RegisterObjectMethod("Network", "bool get_serverRunning() const", asMETHOD(Network, IsServerRunning), asCALL_THISCALL);