diff --git a/code/_event.cpp b/code/_event.cpp new file mode 100644 index 00000000..6bbe8a13 --- /dev/null +++ b/code/_event.cpp @@ -0,0 +1,98 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2025 Electronic Arts Inc. + * Copyright 2026 OpenTS contributors + * + * Contains material derived from Electronic Arts source code. + * Modified by OpenTS contributors, 2026. + * EA's GPLv3 Section 7 additional terms and supplemental warranty + * disclaimers apply; see LICENSE.md. + ******************************************************************************/ + +#include "always.h" + +#include "event.h" + + +// This table is the compressed wire size of each event's data field. +unsigned char EventClass::EventLength[EventClass::LAST_EVENT] = { + 0, // EMPTY + size_of(EventClass, Data.Target), // POWERON + size_of(EventClass, Data.Target), // POWEROFF + size_of(EventClass, Data.General), // ALLY + size_of(EventClass, Data.MegaMission), // MEGAMISSION + size_of(EventClass, Data.MegaMission_F), // MEGAMISSION_F + size_of(EventClass, Data.Target), // IDLE + size_of(EventClass, Data.Target), // SCATTER + 0, // DESTRUCT + size_of(EventClass, Data.Target), // DEPLOY + size_of(EventClass, Data.Place), // PLACE + 0, // OPTIONS + size_of(EventClass, Data.General), // GAMESPEED + size_of(EventClass, Data.Specific), // PRODUCE + size_of(EventClass, Data.Specific), // SUSPEND + size_of(EventClass, Data.Specific), // ABANDON + size_of(EventClass, Data.Target), // PRIMARY + size_of(EventClass, Data.Special), // SPECIAL_PLACE + 0, // EXIT + size_of(EventClass, Data.Anim), // ANIMATION + size_of(EventClass, Data.Target), // REPAIR + size_of(EventClass, Data.Target), // SELL + size_of(EventClass, Data.SellCell), // SELLCELL + size_of(EventClass, Data.Options), // SPECIAL + 0, // FRAMESYNC + 0, // MESSAGE + size_of(EventClass, Data.FrameInfo.Delay), // RESPONSE_TIME + size_of(EventClass, Data.FrameInfo), // FRAMEINFO + 0, // SAVEGAME + size_of(EventClass, Data.NavCom), // ARCHIVE + size_of(EventClass, Data.Variable.Size), // ADDPLAYER + size_of(EventClass, Data.Timing), // TIMING + size_of(EventClass, Data.ProcessTime), // PROCESS_TIME + 0, // PAGEUSER + size_of(EventClass, Data.General), // REMOVEPLAYER + size_of(EventClass, Data.General), // LATENCYFUDGE + size_of(EventClass, Data.NetworkReport), // NETWORK_REPORT +}; + +char const * EventClass::EventNames[EventClass::LAST_EVENT] = { + "EMPTY", + "POWERON", + "POWEROFF", + "ALLY", + "MEGAMISSION", + "MEGAMISSION_F", + "IDLE", + "SCATTER", + "DESTRUCT", + "DEPLOY", + "PLACE", + "OPTIONS", + "GAMESPEED", + "PRODUCE", + "SUSPEND", + "ABANDON", + "PRIMARY", + "SPECIAL_PLACE", + "EXIT", + "ANIMATION", + "REPAIR", + "SELL", + "SELLCELL", + "SPECIAL", + "FRAMESYNC", + "MESSAGE", + "RESPONSE_TIME", + "FRAMEINFO", + "SAVEGAME", + "ARCHIVE", + "ADDPLAYER", + "TIMING", + "PROCESS_TIME", + "PAGEUSER", + "REMOVEPLAYER", + "LATENCYFUDGE", + "NETWORK_REPORT", +}; diff --git a/code/combuf.h b/code/combuf.h index 54b9b73f..4f02f0c6 100644 --- a/code/combuf.h +++ b/code/combuf.h @@ -59,6 +59,9 @@ struct SendQueueType { unsigned int IsUndeliverable : 1; /// 1 = gave up on it (retries or timeout) unsigned int FirstTime; // time this packet was first sent unsigned int LastTime; // time this packet was last sent + unsigned int FirstTimeMilliseconds = 0; // monotonic time of the first transmission + unsigned int LastTimeMilliseconds = 0; // monotonic time of the latest transmission + unsigned int RetransmitTimeoutMilliseconds = 0; // base RTO captured for this packet unsigned int SendCount; // # of times this packet has been sent int BufLen; // size of the packet stored in this entry char *Buffer; // the data packet diff --git a/code/connect.cpp b/code/connect.cpp index 028634bf..b6a20b16 100644 --- a/code/connect.cpp +++ b/code/connect.cpp @@ -47,8 +47,11 @@ #include "_timer.h" #include "dbgprint.h" +#include +#include #include #include +#include #include @@ -61,6 +64,27 @@ char const * ConnectionClass::Commands[PACKET_COUNT] = { "ACK" }; +namespace { + +/// Converts engine ticks to milliseconds. +NetTiming::Milliseconds Ticks_To_Milliseconds(unsigned int ticks) +{ + std::uint64_t const milliseconds = (static_cast(ticks) * 1000 + TIMER_SECOND - 1) / TIMER_SECOND; + if (milliseconds > std::numeric_limits::max()) { + return(std::numeric_limits::max()); + } + return(static_cast(milliseconds)); +} + + +/// Converts and bounds a legacy connection timeout. +NetTiming::Milliseconds Legacy_Connection_Timeout(unsigned int ticks) +{ + return(std::clamp(Ticks_To_Milliseconds(ticks), NetTiming::MINIMUM_CONNECTION_TIMEOUT, NetTiming::MAXIMUM_CONNECTION_TIMEOUT)); +} + +} + /*************************************************************************** * ConnectionClass::ConnectionClass -- class constructor * @@ -76,6 +100,7 @@ char const * ConnectionClass::Commands[PACKET_COUNT] = { * timeout the max amount of time before we give up on a packet* * (-1 means retry forever, based on this parameter) * * extralen max size of app-specific extra bytes (optional) * + * clock monotonic millisecond clock (default if NULL) * * * * OUTPUT: * * none. * @@ -88,7 +113,7 @@ char const * ConnectionClass::Commands[PACKET_COUNT] = { *=========================================================================*/ ConnectionClass::ConnectionClass (int numsend, int numreceive, int maxlen, unsigned short magicnum, unsigned int retry_delta, - unsigned int max_retries, unsigned int timeout, int extralen) + unsigned int max_retries, unsigned int timeout, int extralen, NetTiming::MillisecondClock const * clock) { /*------------------------------------------------------------------------ Compute our maximum packet length @@ -115,6 +140,7 @@ ConnectionClass::ConnectionClass (int numsend, int numreceive, Set the timeout for this connection. ------------------------------------------------------------------------*/ Timeout = timeout; + MillisecondTime = clock != nullptr ? clock : &NetTiming::Default_Clock(); /*------------------------------------------------------------------------ Allocate the packet staging buffer. This will be used to @@ -129,6 +155,7 @@ ConnectionClass::ConnectionClass (int numsend, int numreceive, is 0, the CommBufferClass ignores this parameter. ------------------------------------------------------------------------*/ Queue = new CommBufferClass (numsend, numreceive, MaxPacketLen, extralen); + memset(DroppedPackets, 0, sizeof(DroppedPackets)); } /* end of ConnectionClass */ @@ -186,9 +213,11 @@ void ConnectionClass::Init (void) PercentLost = 0; MissedOverall = 0; MissedMagic = 0; + memset(DroppedPackets, 0, sizeof(DroppedPackets)); LastSeqID = 0xffffffff; LastReadID = 0xffffffff; + RoundTripEstimator.Reset(); Queue->Init(); @@ -219,6 +248,15 @@ void ConnectionClass::Init (void) *=========================================================================*/ int ConnectionClass::Send_Packet (void * buf, int buflen, int ack_req) { + if (buf == NULL || buflen <= 0) { + Record_Packet_Drop(CONNECTION_DROP_EMPTY_DATA); + return(0); + } + if (buflen > MaxPacketLen - (int)sizeof(CommHeaderType)) { + Record_Packet_Drop(CONNECTION_DROP_OVERSIZED_DATA); + return(0); + } + /*------------------------------------------------------------------------ Set the magic # for the packet ------------------------------------------------------------------------*/ @@ -276,19 +314,33 @@ int ConnectionClass::Send_Packet (void * buf, int buflen, int ack_req) *=========================================================================*/ int ConnectionClass::Receive_Packet (void * buf, int buflen) { + CommHeaderType packet_header; CommHeaderType *packet; // ptr to packet header SendQueueType *send_entry; // ptr to send entry header ReceiveQueueType *rec_entry; // ptr to recv entry header - CommHeaderType *entry_data; // ptr to queue entry data CommHeaderType ackpacket; // ACK packet to send int i; int save_packet = 1; // 0 = this is a resend int found; + std::span packet_bytes; + if (buf != NULL && buflen > 0) { + packet_bytes = {static_cast(buf), static_cast(buflen)}; + } + NetAdmission::ConnectionResult const admission = NetAdmission::Admit_Connection_Packet(packet_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + if (!admission.Succeeded()) { + Record_Admission_Drop(admission.ErrorCode, admission.Code); + return(1); + } + + packet_header.MagicNumber = admission.Magic; + packet_header.Code = admission.Code; + packet_header.PacketID = admission.PacketID; + packet = &packet_header; + /*------------------------------------------------------------------------ Check the magic # ------------------------------------------------------------------------*/ - packet = (CommHeaderType *)buf; if (packet->MagicNumber != MagicNum) { MissedMagic++; return(0); @@ -309,13 +361,16 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) If ptr is valid, get ptr to its data ..................................................................*/ if (send_entry != NULL) { - entry_data = (CommHeaderType *)send_entry->Buffer; + std::span entry_bytes; + if (send_entry->Buffer != NULL && send_entry->BufLen > 0) { + entry_bytes = {reinterpret_cast(send_entry->Buffer), static_cast(send_entry->BufLen)}; + } + NetAdmission::ConnectionResult const entry = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); /*............................................................... If ACK is for this entry, mark it ...............................................................*/ - if (packet->PacketID==entry_data->PacketID && - entry_data->Code == PACKET_DATA_ACK) { + if (entry.Succeeded() && packet->PacketID == entry.PacketID && entry.Code == PACKET_DATA_ACK) { send_entry->IsACK = 1; break; } @@ -378,14 +433,16 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) rec_entry = Queue->Get_Receive(i); if (rec_entry) { - - entry_data = (CommHeaderType *)rec_entry->Buffer; + std::span entry_bytes; + if (rec_entry->Buffer != NULL && rec_entry->BufLen > 0) { + entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; + } + NetAdmission::ConnectionResult const entry = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); /*........................................................... Packet is found; it's a resend ...........................................................*/ - if (entry_data->Code == PACKET_DATA_ACK && - entry_data->PacketID == packet->PacketID) { + if (entry.Succeeded() && entry.Code == PACKET_DATA_ACK && entry.PacketID == packet->PacketID) { save_packet = 0; break; } @@ -436,15 +493,18 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) rec_entry = Queue->Get_Receive(i); if (rec_entry) { - entry_data = (CommHeaderType *)rec_entry->Buffer; + std::span entry_bytes; + if (rec_entry->Buffer != NULL && rec_entry->BufLen > 0) { + entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; + } + NetAdmission::ConnectionResult const entry = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); /*...................................................... Entry is found ......................................................*/ - if (entry_data->Code == PACKET_DATA_ACK && - entry_data->PacketID == (LastSeqID + 1)) { + if (entry.Succeeded() && entry.Code == PACKET_DATA_ACK && entry.PacketID == (LastSeqID + 1)) { - LastSeqID = entry_data->PacketID; + LastSeqID = entry.PacketID; found = 1; break; } @@ -475,6 +535,7 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) * * * INPUT: * * buf location to store buffer * + * capacity maximum bytes the caller's buffer can store * * buflen filled in with length of 'buf' * * * * OUTPUT: * @@ -486,13 +547,18 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) * HISTORY: * * 12/20/1994 BR : Created. * *=========================================================================*/ -int ConnectionClass::Get_Packet (void * buf, int *buflen) +int ConnectionClass::Get_Packet (void * buf, int capacity, int *buflen) { ReceiveQueueType *rec_entry; // ptr to receive entry header - int packetlen; // size of received packet - CommHeaderType *entry_data; int i; + if (buflen != NULL) { + (*buflen) = 0; + } + if (buf == NULL || buflen == NULL || capacity <= 0) { + return(0); + } + /*------------------------------------------------------------------------ Ensure that we read the packets in order. LastReadID is the ID of the last PACKET_DATA_ACK packet we read. @@ -505,39 +571,54 @@ int ConnectionClass::Get_Packet (void * buf, int *buflen) Only read this entry if it hasn't been yet .....................................................................*/ if (rec_entry && rec_entry->IsRead==0) { - - entry_data = (CommHeaderType *)rec_entry->Buffer; + std::span entry_bytes; + if (rec_entry->Buffer != NULL && rec_entry->BufLen > 0) { + entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; + } + NetAdmission::ConnectionResult const admission = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + if (!admission.Succeeded()) { + rec_entry->IsRead = 1; + Record_Admission_Drop(admission.ErrorCode, admission.Code); + continue; + } + if (admission.Code == PACKET_ACK) { + rec_entry->IsRead = 1; + Record_Packet_Drop(CONNECTION_DROP_INVALID_CODE); + continue; + } /*.................................................................. If this is a DATA_ACK packet, its ID must be one greater than the last one we read. ..................................................................*/ - if ( (entry_data->Code == PACKET_DATA_ACK) && - (entry_data->PacketID == (LastReadID + 1))) { + if ( (admission.Code == PACKET_DATA_ACK) && + (admission.PacketID == (LastReadID + 1))) { - LastReadID = entry_data->PacketID; + LastReadID = admission.PacketID; rec_entry->IsRead = 1; - packetlen = rec_entry->BufLen - sizeof(CommHeaderType); - if (packetlen > 0) { - memcpy(buf, rec_entry->Buffer + sizeof(CommHeaderType), - packetlen); + NetAdmission::Error const destination = NetAdmission::Validate_Destination(admission.Payload, static_cast(capacity)); + if (destination != NetAdmission::Error::NONE) { + Record_Admission_Drop(destination, admission.Code); + continue; } - (*buflen) = packetlen; + memcpy(buf, admission.Payload.data(), admission.Payload.size()); + (*buflen) = static_cast(admission.Payload.size()); return(1); } /*.................................................................. If this is a DATA_NOACK packet, who cares what the ID is? ..................................................................*/ - else if (entry_data->Code == PACKET_DATA_NOACK) { + else if (admission.Code == PACKET_DATA_NOACK) { rec_entry->IsRead = 1; - packetlen = rec_entry->BufLen - sizeof(CommHeaderType); - if (packetlen > 0) { - memcpy(buf, rec_entry->Buffer + sizeof(CommHeaderType), - packetlen); + NetAdmission::Error const destination = NetAdmission::Validate_Destination(admission.Payload, static_cast(capacity)); + if (destination != NetAdmission::Error::NONE) { + Record_Admission_Drop(destination, admission.Code); + continue; } - (*buflen) = packetlen; + memcpy(buf, admission.Payload.data(), admission.Payload.size()); + (*buflen) = static_cast(admission.Payload.size()); return(1); } } @@ -548,6 +629,81 @@ int ConnectionClass::Get_Packet (void * buf, int *buflen) } /* end of Get_Packet */ +namespace { + +/// Names a stable connection rejection reason. +char const * Packet_Drop_Name(ConnectionClass::PacketDropReasonType reason) +{ + switch (reason) { + case ConnectionClass::CONNECTION_DROP_SHORT_HEADER: return("connection-short-header"); + case ConnectionClass::CONNECTION_DROP_INVALID_CODE: return("connection-invalid-code"); + case ConnectionClass::CONNECTION_DROP_INVALID_LENGTH: return("connection-invalid-length"); + case ConnectionClass::CONNECTION_DROP_EMPTY_DATA: return("connection-empty-data"); + case ConnectionClass::CONNECTION_DROP_OVERSIZED_DATA: return("connection-oversized-data"); + case ConnectionClass::CONNECTION_DROP_OUTPUT_TOO_SMALL: return("connection-output-too-small"); + default: return("connection-unknown"); + } +} + +} // namespace + + +/// Returns a packet rejection count. +unsigned int ConnectionClass::Dropped_Packets(PacketDropReasonType reason) const +{ + if (reason < 0 || reason >= CONNECTION_DROP_COUNT) { + return(0); + } + + return(DroppedPackets[reason]); +} + + +/// Records a packet rejection. +void ConnectionClass::Record_Packet_Drop(PacketDropReasonType reason) +{ + if (reason < 0 || reason >= CONNECTION_DROP_COUNT) { + return; + } + + unsigned int count = ++DroppedPackets[reason]; + if (count == 1 || (count & (count - 1)) == 0) { + DebugString("Network packet drop [%s]: %u\n", Packet_Drop_Name(reason), count); + } +} + + +/// Maps a shared admission rejection to the connection counters. +void ConnectionClass::Record_Admission_Drop(NetAdmission::Error error, unsigned char code) +{ + switch (error) { + case NetAdmission::Error::HEADER_TOO_SHORT: + case NetAdmission::Error::DATAGRAM_TOO_SHORT: + Record_Packet_Drop(CONNECTION_DROP_SHORT_HEADER); + break; + case NetAdmission::Error::PACKET_TOO_LARGE: + case NetAdmission::Error::DATAGRAM_TOO_LARGE: + Record_Packet_Drop(CONNECTION_DROP_OVERSIZED_DATA); + break; + case NetAdmission::Error::INVALID_PACKET_CODE: + Record_Packet_Drop(CONNECTION_DROP_INVALID_CODE); + break; + case NetAdmission::Error::INVALID_PACKET_LENGTH: + Record_Packet_Drop(code == PACKET_ACK ? CONNECTION_DROP_INVALID_LENGTH : CONNECTION_DROP_EMPTY_DATA); + break; + case NetAdmission::Error::DESTINATION_TOO_SMALL: + Record_Packet_Drop(CONNECTION_DROP_OUTPUT_TOO_SMALL); + break; + case NetAdmission::Error::BAD_CRC: + Record_Packet_Drop(CONNECTION_DROP_INVALID_LENGTH); + break; + case NetAdmission::Error::NONE: + case NetAdmission::Error::COUNT: + break; + } +} + + /*************************************************************************** * ConnectionClass::Service -- main polling routine; services packets * * * @@ -604,7 +760,7 @@ int ConnectionClass::Service_Send_Queue (void) int i; int num_entries; SendQueueType *send_entry; // ptr to send queue entry - CommHeaderType *packet_hdr; // packet header + CommHeaderType packet_header; // packet header unsigned int curtime; // current time int bad_conn = 0; @@ -625,9 +781,15 @@ int ConnectionClass::Service_Send_Queue (void) /*.................................................................. Update this queue's response time ..................................................................*/ - packet_hdr = (CommHeaderType *)send_entry->Buffer; - if (packet_hdr->Code == PACKET_DATA_ACK) { - Queue->Add_Delay(Time() - send_entry->FirstTime); + if (send_entry->BufLen >= (int)sizeof(CommHeaderType)) { + CommHeaderType header; + memcpy(&header, send_entry->Buffer, sizeof(header)); + if (header.Code == PACKET_DATA_ACK) { + Queue->Add_Delay(Time() - send_entry->FirstTime); + if (send_entry->SendCount == 1 && Adaptive_Timing_Enabled()) { + RoundTripEstimator.Acknowledge(send_entry->FirstTimeMilliseconds, send_entry->SendCount, *MillisecondTime); + } + } } /*.................................................................. @@ -643,6 +805,16 @@ int ConnectionClass::Service_Send_Queue (void) need it. ------------------------------------------------------------------------*/ num_entries = Queue->Num_Send(); + curtime = Time(); + NetTiming::Milliseconds const current_milliseconds = MillisecondTime->Now(); + bool const adaptive_channel = Adaptive_Timing_Enabled(); + bool const adaptive_timing = adaptive_channel && RoundTripEstimator.Has_Sample(); + bool const timeout_enabled = Timeout != (unsigned int)-1; + NetTiming::Milliseconds const connection_timeout = !timeout_enabled + ? NetTiming::MAXIMUM_CONNECTION_TIMEOUT + : (adaptive_timing ? NetTiming::Connection_Timeout(RoundTripEstimator.Smoothed_Rtt()) + : (adaptive_channel ? Legacy_Connection_Timeout(Timeout) : Ticks_To_Milliseconds(Timeout))); + NetTiming::Milliseconds const base_retry_timeout = adaptive_timing ? RoundTripEstimator.Retransmit_Timeout() : Ticks_To_Milliseconds(RetryDelta); for (i = 0; i < num_entries; i++) { send_entry = Queue->Get_Send(i); @@ -651,13 +823,21 @@ int ConnectionClass::Service_Send_Queue (void) continue; } - /*..................................................................... - Only send the message if time has elapsed. (The message's Time - fields are init'd to 0 when a message is queue'd or unqueue'd, so the - first time through, the delta time will appear large.) - .....................................................................*/ - curtime = Time(); - if (curtime - send_entry->LastTime > RetryDelta) { + if (send_entry->SendCount != 0 && timeout_enabled && + NetTiming::Milliseconds_Have_Elapsed(send_entry->FirstTimeMilliseconds, current_milliseconds, connection_timeout)) { + bad_conn = 1; + send_entry->IsUndeliverable = true; + continue; + } + + NetTiming::Milliseconds const retry_timeout = !adaptive_channel || send_entry->SendCount == 0 + ? base_retry_timeout : send_entry->RetransmitTimeoutMilliseconds; + unsigned int const prior_retransmissions = send_entry->SendCount == 0 ? 0 : send_entry->SendCount - 1; + // Lobby traffic keeps its fixed retry cadence; private links back off. + bool const retry_due = send_entry->SendCount == 0 || (adaptive_channel + ? NetTiming::Retransmit_Is_Due(send_entry->LastTimeMilliseconds, current_milliseconds, retry_timeout, prior_retransmissions, connection_timeout) + : NetTiming::Milliseconds_Have_Elapsed(send_entry->LastTimeMilliseconds, current_milliseconds, retry_timeout)); + if (retry_due) { /*.................................................................. Send the message @@ -669,16 +849,19 @@ int ConnectionClass::Service_Send_Queue (void) Fill in Time fields ..................................................................*/ send_entry->LastTime = curtime; + send_entry->LastTimeMilliseconds = current_milliseconds; if (send_entry->SendCount==0) { send_entry->FirstTime = curtime; + send_entry->FirstTimeMilliseconds = current_milliseconds; + send_entry->RetransmitTimeoutMilliseconds = retry_timeout; /*............................................................... If this is the 1st time we're sending this packet, and it doesn't require an ACK, mark it as ACK'd; then, the next time through, it will just be removed from the queue. ...............................................................*/ - packet_hdr = (CommHeaderType *)send_entry->Buffer; - if (packet_hdr->Code == PACKET_DATA_NOACK) { + memcpy(&packet_header, send_entry->Buffer, sizeof(packet_header)); + if (packet_header.Code == PACKET_DATA_NOACK) { send_entry->IsACK = 1; } } else { @@ -698,11 +881,6 @@ int ConnectionClass::Service_Send_Queue (void) send_entry->IsUndeliverable = true; } - if (Timeout != -1 && - (send_entry->LastTime - send_entry->FirstTime) > Timeout) { - bad_conn = 1; - send_entry->IsUndeliverable = true; - } } } @@ -748,7 +926,7 @@ int ConnectionClass::Discard_Undeliverable_Packets(void) int ConnectionClass::Service_Receive_Queue (void) { ReceiveQueueType *rec_entry; // ptr to receive entry header - CommHeaderType *packet_hdr; // packet header + CommHeaderType packet_header; // packet header int i; /*------------------------------------------------------------------------ @@ -761,13 +939,18 @@ int ConnectionClass::Service_Receive_Queue (void) rec_entry = Queue->Get_Receive(i); if (rec_entry->IsRead) { - packet_hdr = (CommHeaderType *)(rec_entry->Buffer); + if (rec_entry->BufLen < (int)sizeof(packet_header)) { + Queue->UnQueue_Receive(NULL, NULL, i, NULL, NULL); + i--; + continue; + } + memcpy(&packet_header, rec_entry->Buffer, sizeof(packet_header)); - if (packet_hdr->Code == PACKET_DATA_NOACK) { + if (packet_header.Code == PACKET_DATA_NOACK) { Queue->UnQueue_Receive(NULL,NULL,i,NULL,NULL); i--; - } else if (packet_hdr->PacketID < LastSeqID) { + } else if (packet_header.PacketID < LastSeqID) { Queue->UnQueue_Receive(NULL,NULL,i,NULL,NULL); i--; } diff --git a/code/connect.h b/code/connect.h index a7e8f232..3c5070be 100644 --- a/code/connect.h +++ b/code/connect.h @@ -97,6 +97,8 @@ ********************************* Includes ********************************** */ #include "combuf.h" +#include "netadmit.h" +#include "nettiming.h" /* ********************************** Defines ********************************** @@ -114,9 +116,9 @@ PacketID: This is a unique numerical ID for this packet. The Connection sets this ID on all packets sent out. ---------------------------------------------------------------------------*/ struct CommHeaderType { - unsigned short MagicNumber; - unsigned char Code; - unsigned int PacketID; + std::uint16_t MagicNumber; + std::uint8_t Code; + std::uint32_t PacketID; }; #pragma pack(pop) @@ -133,18 +135,17 @@ class ConnectionClass These are the possible values for the Code field of the CommHeaderType: .....................................................................*/ enum ConnectionEnum { - PACKET_DATA_ACK, // this is a data packet requiring an ACK - PACKET_DATA_NOACK, // this is a data packet not requiring an ACK - PACKET_ACK, // this is an ACK for a packet - PACKET_COUNT // for computational purposes + PACKET_DATA_ACK = static_cast(NetAdmission::PacketCode::DATA_ACK), // this is a data packet requiring an ACK + PACKET_DATA_NOACK = static_cast(NetAdmission::PacketCode::DATA_NOACK), // this is a data packet not requiring an ACK + PACKET_ACK = static_cast(NetAdmission::PacketCode::ACK), // this is an ACK for a packet + PACKET_COUNT = static_cast(NetAdmission::PacketCode::COUNT) // for computational purposes }; /*..................................................................... Constructor/destructor. .....................................................................*/ - ConnectionClass (int numsend, int numrecieve, int maxlen, - unsigned short magicnum, unsigned int retry_delta, - unsigned int max_retries, unsigned int timeout, int extralen = 0); + ConnectionClass (int numsend, int numrecieve, int maxlen, unsigned short magicnum, unsigned int retry_delta, + unsigned int max_retries, unsigned int timeout, int extralen = 0, NetTiming::MillisecondClock const *clock = nullptr); virtual ~ConnectionClass (void); /*..................................................................... @@ -157,7 +158,7 @@ class ConnectionClass .....................................................................*/ virtual int Send_Packet (void * buf, int buflen, int ack_req); virtual int Receive_Packet (void * buf, int buflen); - virtual int Get_Packet (void * buf, int * buflen); + virtual int Get_Packet (void * buf, int capacity, int * buflen); /*..................................................................... The main polling routine for the connection. Should be called as often @@ -184,6 +185,13 @@ class ConnectionClass unsigned int Time_Out (void) { return(Timeout); } void Set_TimeOut (unsigned int t) { Timeout = t;} unsigned int Max_Packet_Len (void) { return(MaxPacketLen); } + std::optional Smoothed_Round_Trip_MS(void) const + { + if (!RoundTripEstimator.Has_Sample()) { + return(std::nullopt); + } + return(RoundTripEstimator.Smoothed_Rtt()); + } static const char * Command_Name(int command); int Num_Resends(void) const { return(NumResends); } @@ -192,6 +200,18 @@ class ConnectionClass int Missed_Overall(void) const { return(MissedOverall); } int Missed_Magic(void) const { return(MissedMagic); } + enum PacketDropReasonType { + CONNECTION_DROP_SHORT_HEADER, + CONNECTION_DROP_INVALID_CODE, + CONNECTION_DROP_INVALID_LENGTH, + CONNECTION_DROP_EMPTY_DATA, + CONNECTION_DROP_OVERSIZED_DATA, + CONNECTION_DROP_OUTPUT_TOO_SMALL, + CONNECTION_DROP_COUNT + }; + + unsigned int Dropped_Packets(PacketDropReasonType reason) const; + /*..................................................................... The packet "queue"; this non-sequenced version isn't really much of a queue, but more of a repository. @@ -214,8 +234,10 @@ class ConnectionClass is protected; it's only called by the ACK/Retry logic, not the application. .....................................................................*/ - virtual int Send(char *buf, int buflen, void *extrabuf, - int extralen) = 0; + virtual int Send(char *buf, int buflen, void *extrabuf, int extralen) = 0; + virtual bool Adaptive_Timing_Enabled(void) const {return(true);} + void Record_Packet_Drop(PacketDropReasonType reason); + void Record_Admission_Drop(NetAdmission::Error error, unsigned char code); /* * This is the number of times a packet had to be transmitted again because no ACK @@ -242,6 +264,7 @@ class ConnectionClass * match this connection's, meaning they came from some other product. */ int MissedMagic; + unsigned int DroppedPackets[CONNECTION_DROP_COUNT]; /*..................................................................... This is the maximum packet length, including our own internal header. @@ -277,6 +300,10 @@ class ConnectionClass .....................................................................*/ unsigned int Timeout; + // An injected clock must outlive the connection. + NetTiming::MillisecondClock const *MillisecondTime; + NetTiming::RttEstimator RoundTripEstimator; + /*..................................................................... Running totals of # of packets we send & receive which require an ACK, and those that don't. diff --git a/code/connmgr.h b/code/connmgr.h index e816c4a5..99dfaf1c 100644 --- a/code/connmgr.h +++ b/code/connmgr.h @@ -60,6 +60,10 @@ * - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ #pragma once +#include "nettime.h" + +#include + /* ***************************** Class Declaration ***************************** @@ -95,10 +99,8 @@ class ConnManClass /*..................................................................... Sending & receiving data .....................................................................*/ - virtual int Send_Private_Message (void *buf, int buflen, - int ack_req = 1, int conn_id = CONNECTION_NONE) = 0; - virtual int Get_Private_Message (void *buf, int *buflen, - int *conn_id) = 0; + virtual int Send_Private_Message (void *buf, int buflen, int ack_req = 1, int conn_id = CONNECTION_NONE) = 0; + virtual int Get_Private_Message (void *buf, int capacity, int *buflen, int *conn_id) = 0; /*..................................................................... Connection management @@ -120,10 +122,9 @@ class ConnManClass .....................................................................*/ virtual void Reset_Response_Time(bool zero) = 0; virtual unsigned int Response_Time(void) = 0; - virtual void Set_Timing (unsigned int retrydelta, - unsigned int maxretries, unsigned int timeout, bool set_external = true) = 0; - virtual void Set_External_Timing (unsigned int retrydelta, - unsigned int maxretries, unsigned int timeout) = 0; + virtual std::optional Worst_Local_Round_Trip_MS(void) const = 0; + virtual void Set_Timing (unsigned int retrydelta, unsigned int maxretries, unsigned int timeout, bool set_external = true) = 0; + virtual void Set_External_Timing (unsigned int retrydelta, unsigned int maxretries, unsigned int timeout) = 0; /*..................................................................... Debugging diff --git a/code/conquer.cpp b/code/conquer.cpp index 7ace5ddd..563613f5 100644 --- a/code/conquer.cpp +++ b/code/conquer.cpp @@ -96,6 +96,7 @@ #include "msgloop.h" #include "netdlg.h" #include "netdlg2.h" +#include "netglobal.h" #include "netshare.h" #include "progress.h" #include "queue.h" @@ -114,6 +115,7 @@ #include "special.hh" +#include #include #include #include @@ -123,6 +125,7 @@ #include #include #include +#include /**************************************** @@ -523,6 +526,70 @@ bool MapGen_Call_Back(void) } +static NetGlobal::RejectionCounters GlobalPacketRejections; + + +/// Records a rejected global packet. +static void Record_Global_Packet_Rejection(NetGlobal::DecodeError error) +{ + NetGlobal::RejectionRecord const record = GlobalPacketRejections.Record(error); + if (record.ShouldLog) { + DebugString("In-game global packet drop [%s]: %u\n", NetGlobal::Error_Name(error), record.Count); + } +} + + +/// Resolves a registered packet source. +static NodeNameType * Session_Member_From_Address(IPXAddressClass const & address, int & player_index, NetGlobal::DecodeError & error) +{ + std::array endpoints = {}; + std::array players = {}; + std::array player_indices = {}; + std::size_t count = 0; + + player_index = -1; + for (int index = 0; index < Session.Players.Count(); index++) { + NodeNameType * player = Session.Players[index]; + if (player != NULL && count < endpoints.size()) { + endpoints[count] = {player->Address.Get_IP(), player->Address.Get_Port()}; + players[count] = player; + player_indices[count] = index; + count++; + } + } + + NetGlobal::Endpoint const source{address.Get_IP(), address.Get_Port()}; + NetGlobal::EndpointResolution const resolution = NetGlobal::Resolve_Sender(source, std::span(endpoints.data(), count)); + error = resolution.Error; + if (resolution.Error != NetGlobal::DecodeError::NONE || resolution.RosterIndex < 0) { + return(NULL); + } + + player_index = player_indices[resolution.RosterIndex]; + return(players[resolution.RosterIndex]); +} + + +/// Builds the membership facts used to validate a global packet. +static NetGlobal::ValidationContext Global_Validation_Context(NodeNameType const * sender) +{ + NetGlobal::ValidationContext context; + for (int index = 0; index < Session.Players.Count(); index++) { + NodeNameType const * player = Session.Players[index]; + if (player != NULL && player->Player.ID >= 0 && player->Player.ID < static_cast(context.ActivePlayers.size())) { + context.ActivePlayers[player->Player.ID] = true; + } + } + + if (sender != NULL) { + context.SenderIsMember = true; + context.SenderPlayerID = sender->Player.ID; + context.SenderPlayerColor = sender->Player.Color; + } + return(context); +} + + /// /// Handles the network maintenance for a network game. /// This routine services the network connection and deals with the global packets that @@ -540,113 +607,80 @@ void IPX_Call_Back(void) ** messages from the connection dialogs. */ if (!Session.NetOpen) { - while (Ipx.Get_Global_Message (&Session.GPacket, &Session.GPacketlen, &Session.GAddress, &Session.GProductID)) { + while (Ipx.Get_Global_Message (&Session.GPacket, sizeof(Session.GPacket), &Session.GPacketlen, &Session.GAddress, &Session.GProductID)) { if (Session.GProductID == IPXGlobalConnClass::COMMAND_AND_CONQUER2) { + int sender_index = -1; + NetGlobal::DecodeError resolution_error = NetGlobal::DecodeError::SENDER_NOT_MEMBER; + NodeNameType * sender = Session_Member_From_Address(Session.GAddress, sender_index, resolution_error); + NetGlobal::ValidationContext const context = Global_Validation_Context(sender); + NetGlobal::DecodeError error = NetGlobal::Validate_In_Game_Packet(Session.GPacket, Session.GPacketlen, context); + if (error == NetGlobal::DecodeError::SENDER_NOT_MEMBER && resolution_error == NetGlobal::DecodeError::AMBIGUOUS_SENDER) { + error = resolution_error; + } - switch (Session.GPacket.Command) - { - - case NET_PROPOSE_KICK: - { - Kick_Packet_Received(Session.GPacket, Session.GAddress); - break; - } - - /* - ** If this is another player signing off, remove the connection & - ** mark that player's house as non-human, so the computer will take - ** it over. - */ - case NET_SIGN_OFF: - { - for (int i = 0; i < Ipx.Num_Connections(); i++) { - - int id = Ipx.Connection_ID(i); - - if (Session.GAddress == (*Ipx.Connection_Address(id))) { - Destroy_Connection(id, 0); - } - } - break; - } + if (error != NetGlobal::DecodeError::NONE) { + Record_Global_Packet_Rejection(error); + } else { + switch (Session.GPacket.Command) { + case NET_QUERY_GAME: + case NET_QUERY_PLAYER: + Process_Global_Packet(&Session.GPacket, &Session.GAddress); + break; - /* - ** Process a message from another user. - */ - case NET_MESSAGE: - { - bool msg_ok = false; - - /* - ** If NetProtect is set, make sure this message came from within - ** this game. - */ - if (!Session.NetProtect) { - msg_ok = true; - } else { - if (Session.GPacket.Message.NameCRC == - Compute_Name_CRC(Session.GameName)) { - msg_ok = true; - } else { - msg_ok = false; + case NET_PROPOSE_KICK: + error = Kick_Packet_Received(sender->Player.ID, static_cast(Session.GPacket.Kick.KickeeID)); + if (error != NetGlobal::DecodeError::NONE) { + Record_Global_Packet_Rejection(error); } - } + break; - if (msg_ok) { - if (!Session.Messages.Concat_Message(Session.GPacket.Name, - Session.GPacket.Message.Color, - Session.GPacket.Message.Buf, int(Rule->MessageDelay * TICKS_PER_MINUTE))) { - Session.Messages.Add_Message (Session.GPacket.Name, - Session.GPacket.Message.Color, - Session.GPacket.Message.Buf, - Session.Color_Index_To_Scheme(Session.GPacket.Message.Color), - (TextPrintType)(TPF_6PT_GRAD | TPF_USE_GRAD_PAL | TPF_FULLSHADOW), - int(Rule->MessageDelay * TICKS_PER_MINUTE)); - - Sound_Effect(Rule->IncomingMessage); + case NET_SIGN_OFF: { + int const connection = Ipx.Connection_Index(sender->Player.ID); + if (connection >= 0) { + Forget_Kick_Player(sender->Player.ID); + Destroy_Connection(sender->Player.ID, 0); } - - /* - ** Tell the map to do a partial update (just to force the messages - ** to redraw). - */ - Map.Flag_To_Redraw(GS_REDRAW_ALL); - - /* - ** Save this message in our last-message buffer - */ - strcpy(Session.LastMessage, Session.GPacket.Message.Buf); + break; } - break; - } - case NET_PROGRESS_REPORT: - { - for (int i = 0; i < Session.Players.Count(); i++) { - if (Session.Players[i]->Address == Session.GAddress) { - DebugString("Received progress message - %d%% from %s\n", Session.GPacket.Progress.Percent, Session.Players[i]->Name); - Progress.Set_Progress_Percent(i, Session.GPacket.Progress.Percent); - break; + case NET_MESSAGE: + if (!Session.NetProtect || Session.GPacket.Message.NameCRC == Compute_Name_CRC(Session.GameName)) { + int const color = sender->Player.Color; + if (!Session.Messages.Concat_Message(sender->Name, color, Session.GPacket.Message.Buf, int(Rule->MessageDelay * TICKS_PER_MINUTE))) { + Session.Messages.Add_Message(sender->Name, color, + Session.GPacket.Message.Buf, + Session.Color_Index_To_Scheme(color), + (TextPrintType)(TPF_6PT_GRAD | TPF_USE_GRAD_PAL | TPF_FULLSHADOW), + int(Rule->MessageDelay * TICKS_PER_MINUTE)); + + Sound_Effect(Rule->IncomingMessage); + } + + /* + ** Tell the map to do a partial update (just to force the messages + ** to redraw). + */ + Map.Flag_To_Redraw(GS_REDRAW_ALL); + + /* + ** Save this message in our last-message buffer + */ + strcpy(Session.LastMessage, Session.GPacket.Message.Buf); } - } - break; - } + break; - case NET_REQ_SCENARIO: - { - break; - } + case NET_PROGRESS_REPORT: + DebugString("Received progress message - %d%% from %s\n", Session.GPacket.Progress.Percent, sender->Name); + Progress.Set_Progress_Percent(sender_index, Session.GPacket.Progress.Percent); + break; - case NET_READY_TO_GO: - { - break; - } + case NET_READY_TO_GO: + break; - default: - { - Process_Global_Packet(&Session.GPacket, &Session.GAddress); - break; + default: + Record_Global_Packet_Rejection(NetGlobal::DecodeError::INVALID_COMMAND); + break; } } } diff --git a/code/event.cpp b/code/event.cpp index 2466ea50..8727b405 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -60,6 +60,7 @@ #include "house.h" #include "language\language.h" #include "mouse.h" +#include "netsemantic.h" #include "rules.h" #include "saveload.h" #include "scenario.h" @@ -76,87 +77,91 @@ #include "special.hh" -/*************************************************************************** -** Table of what data is really used in the EventClass struct for different -** events. This table must be kept current with the EventType enum. -*/ -unsigned char EventClass::EventLength[EventClass::LAST_EVENT] = { - 0, // EMPTY - size_of(EventClass, Data.Target ), /// POWERON - size_of(EventClass, Data.Target ), /// POWEROFF - size_of(EventClass, Data.General ), // ALLY - size_of(EventClass, Data.MegaMission ), // MEGAMISSION - size_of(EventClass, Data.MegaMission_F ), // MEGAMISSION_F - size_of(EventClass, Data.Target ), // IDLE - size_of(EventClass, Data.Target ), // SCATTER - 0, // DESTRUCT - size_of(EventClass, Data.Target ), // DEPLOY - size_of(EventClass, Data.Place ), // PLACE - 0, // OPTIONS - size_of(EventClass, Data.General ), // GAMESPEED - size_of(EventClass, Data.Specific ), // PRODUCE - size_of(EventClass, Data.Specific ), // SUSPEND - size_of(EventClass, Data.Specific ), // ABANDON - size_of(EventClass, Data.Target ), // PRIMARY - size_of(EventClass, Data.Special ), // SPECIAL_PLACE - 0, // EXIT - size_of(EventClass, Data.Anim ), // ANIMATION - size_of(EventClass, Data.Target ), // REPAIR - size_of(EventClass, Data.Target ), // SELL - size_of(EventClass, Data.SellCell), // SELLCELL - size_of(EventClass, Data.Options ), // SPECIAL - 0, // FRAMESYNC - 0, // MESSAGE - size_of(EventClass, Data.FrameInfo.Delay ), // RESPONSE_TIME - size_of(EventClass, Data.FrameInfo ), // FRAMEINFO - 0, // SAVEGAME - size_of(EventClass, Data.NavCom ), // ARCHIVE - size_of(EventClass, Data.Variable.Size), // ADDPLAYER - size_of(EventClass, Data.Timing ), // TIMING - size_of(EventClass, Data.ProcessTime ), // PROCESS_TIME - 0, /// PAGEUSER - size_of(EventClass, Data.General ), /// REMOVEPLAYER - size_of(EventClass, Data.General ), /// LATENCYFUDGE -}; - -char const * EventClass::EventNames[EventClass::LAST_EVENT] = { - "EMPTY", - "POWERON", - "POWEROFF", - "ALLY", - "MEGAMISSION", - "MEGAMISSION_F", - "IDLE", - "SCATTER", - "DESTRUCT", - "DEPLOY", - "PLACE", - "OPTIONS", - "GAMESPEED", - "PRODUCE", - "SUSPEND", - "ABANDON", - "PRIMARY", - "SPECIAL_PLACE", - "EXIT", - "ANIMATION", - "REPAIR", - "SELL", - "SELLCELL", - "SPECIAL", - "FRAMESYNC", - "MESSAGE", - "RESPONSE_TIME", - "FRAMEINFO", - "SAVEGAME", - "ARCHIVE", - "ADDPLAYER", - "TIMING", - "PROCESS_TIME", - "PAGEUSER", - "REMOVEPLAYER", - "LATENCYFUDGE", -}; +namespace { + static_assert(EventClass::NETWORK_RTT_UNAVAILABLE == NetTiming::MAXIMUM_REPORTED_RTT + 1u); + + enum class EventRejectReason : unsigned int { + InvalidType, + InvalidOrigin, + MissingOrigin, + InvalidAllyHouse, + InvalidAnimationType, + InvalidAnimationOwner, + InvalidGameSpeed, + InvalidRemovedHouse, + InvalidLatencyFudge, + UnauthorizedSubject, + UnauthorizedTiming, + InvalidTimingArithmetic, + InvalidTimingValues, + UnschedulableTiming, + InvalidNetworkReport, + Count, + }; + + char const * const EventRejectReasonNames[] = { + "invalid type", + "invalid origin", + "missing origin", + "invalid ally house", + "invalid animation type", + "invalid animation owner", + "invalid game speed", + "invalid removed house", + "invalid latency fudge", + "unauthorized subject", + "unauthorized timing", + "invalid timing arithmetic", + "invalid timing values", + "unschedulable timing", + "invalid network report", + }; + + static_assert(ARRAY_SIZE(EventRejectReasonNames) == (int)EventRejectReason::Count); + unsigned int EventRejectCounts[(unsigned int)EventRejectReason::Count] = {}; + + + /// Records a rejected synchronized event. + void Log_Event_Rejection(EventRejectReason reason, unsigned int type, int origin, int detail) + { + unsigned int const reason_index = (unsigned int)reason; + unsigned int const count = ++EventRejectCounts[reason_index]; + if (count == 0 || (count & (count - 1)) != 0) { + return; + } + + DebugString("Rejected network event: %s, type %u, origin %d, detail %d (count %u)\n", EventRejectReasonNames[reason_index], type, origin, detail, count); + } + + + /// Resolves the object controlled by an ownership-gated event. + TechnoClass * Event_Subject(EventClass const & event, bool & requires_ownership) + { + requires_ownership = true; + switch (event.Type) { + case EventClass::POWERON: + case EventClass::POWEROFF: + case EventClass::REPAIR: + case EventClass::PRIMARY: + case EventClass::IDLE: + case EventClass::DEPLOY: + case EventClass::SCATTER: + case EventClass::SELL: + return(event.Data.Target.Whom.As_Techno()); + + case EventClass::ARCHIVE: + return(event.Data.NavCom.Whom.As_Techno()); + + case EventClass::MEGAMISSION: + case EventClass::MEGAMISSION_F: + return(event.Data.MegaMission.Whom.As_Techno()); + + default: + requires_ownership = false; + return(NULL); + } + } +} /*********************************************************************************************** @@ -632,7 +637,34 @@ void EventClass::Execute(void) TechnoClass * techno = NULL; BuildingClass * building = NULL; AnimClass * anim = NULL; + if (Type == EMPTY || Type >= LAST_EVENT) { + Log_Event_Rejection(EventRejectReason::InvalidType, Type, -1, Type); + return; + } + if (!NetSemantic::Index_Is_Valid(ID, Houses.Count())) { + Log_Event_Rejection(EventRejectReason::InvalidOrigin, Type, ID, ID); + return; + } + if (Houses[ID] == NULL) { + Log_Event_Rejection(EventRejectReason::MissingOrigin, Type, ID, ID); + return; + } + HouseClass * house = Houses[ID]; + if (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET) { + bool requires_ownership = false; + TechnoClass * subject = Event_Subject(*this, requires_ownership); + if (requires_ownership) { + if (subject == NULL || !subject->IsActive || subject->Strength <= 0) { + return; + } + int const owner = subject->House != NULL ? subject->House->HeapID : -1; + if (!NetSemantic::Subject_Owner_Is_Valid(ID, owner)) { + Log_Event_Rejection(EventRejectReason::UnauthorizedSubject, Type, ID, owner); + return; + } + } + } HouseClass * hptr = NULL; const char *str = NULL; // Cell cell; @@ -642,7 +674,6 @@ void EventClass::Execute(void) // bool formation = false; int i; int index; - unsigned int ul; // RTTIType rt; //if (Debug_Print_Events) { @@ -680,11 +711,16 @@ void EventClass::Execute(void) ** Make or break alliance. */ case ALLY: - hptr = Houses[Data.General.Value]; + index = Data.General.Value; + if (!NetSemantic::Index_Is_Valid(index, Houses.Count()) || Houses[index] == NULL) { + Log_Event_Rejection(EventRejectReason::InvalidAllyHouse, Type, ID, index); + break; + } + hptr = Houses[index]; if (house->Is_Ally(hptr)) { - house->Make_Enemy((HousesType)Data.General.Value); + house->Make_Enemy((HousesType)index); } else { - house->Make_Ally((HousesType)Data.General.Value); + house->Make_Ally((HousesType)index); } break; @@ -755,6 +791,19 @@ void EventClass::Execute(void) */ case ANIMATION: { + int const animation_type = (int)Data.Anim.What; + int const owner = (int)Data.Anim.Owner; + if (!NetSemantic::Animation_Type_Is_Valid(animation_type, ANIM_NONE, AnimTypes.Count()) || + (animation_type != ANIM_NONE && AnimTypes[animation_type] == NULL)) { + Log_Event_Rejection(EventRejectReason::InvalidAnimationType, Type, ID, animation_type); + break; + } + if (!NetSemantic::Animation_Owner_Is_Valid(owner, HOUSE_NONE, Houses.Count()) || + (owner != HOUSE_NONE && Houses[owner] == NULL)) { + Log_Event_Rejection(EventRejectReason::InvalidAnimationOwner, Type, ID, owner); + break; + } + Coord coord(Data.Anim.Where.X, Data.Anim.Where.Y); coord.Z = Map.Get_Height_GL(coord); if (Map[coord].IsUnderBridge) { @@ -766,7 +815,7 @@ void EventClass::Execute(void) anim = new AnimClass(AnimTypes[Data.Anim.What], coord); } if (anim) { - if (Data.Anim.Owner != HOUSE_NONE && !Houses[Data.Anim.Owner]->Is_Player_Control()) { + if (owner != HOUSE_NONE && !Houses[owner]->Is_Player_Control()) { anim->Make_Invisible(); } } @@ -1079,6 +1128,10 @@ void EventClass::Execute(void) ** Process the options Game Speed */ case GAMESPEED: + if (!NetSemantic::Game_Speed_Is_Valid(Data.General.Value)) { + Log_Event_Rejection(EventRejectReason::InvalidGameSpeed, Type, ID, Data.General.Value); + break; + } Options.GameSpeed = Data.General.Value; house = Houses[ID]; @@ -1095,8 +1148,20 @@ void EventClass::Execute(void) ** Adjust connection timing for multiplayer games */ case RESPONSE_TIME: - Session.MaxAhead = Data.FrameInfo.Delay; + { + int const master_id = Session.Master_Player_ID(); + if (!Session.Play && !NetSemantic::Timing_Authority_Is_Valid(ID, master_id)) { + Log_Event_Rejection(EventRejectReason::UnauthorizedTiming, Type, ID, master_id); + break; + } + bool const compressed = Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP; + if (!NetSemantic::Response_Time_Is_Valid(Data.FrameInfo.Delay, NETWORK_MIN_MAX_AHEAD, Session.FrameSendRate, compressed)) { + Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, Data.FrameInfo.Delay); + break; + } + Session.Apply_Network_Response_Time(Data.FrameInfo.Delay, Frame >= 0 ? static_cast(Frame) : 0u); break; + } /* ** Save a multiplayer game (this event is only generated in multiplayer mode) @@ -1124,10 +1189,17 @@ void EventClass::Execute(void) break; case REMOVEPLAYER: + index = Data.General.Value; + if (!NetSemantic::Index_Is_Valid(index, Houses.Count()) || Houses[index] == NULL) { + Log_Event_Rejection(EventRejectReason::InvalidRemovedHouse, Type, ID, index); + break; + } + if (!Houses[index]->Is_Human_Player()) { + break; + } DebugString("Executing REMOVEPLAYER event. Frame is %d\n", ::Frame); Disable_Multiplayer_Saving(); - index = Data.General.Value; - + Session.Remove_Network_Timing_Player(index, Frame >= 0 ? static_cast(Frame) : 0u); house = Houses[index]; if (Session.Type == GAME_INTERNET && WestwoodOnline_Tournament) { house->Flag_To_Die(); @@ -1139,6 +1211,10 @@ void EventClass::Execute(void) break; case LATENCYFUDGE: + if (!NetSemantic::Latency_Fudge_Is_Valid(Data.General.Value)) { + Log_Event_Rejection(EventRejectReason::InvalidLatencyFudge, Type, ID, Data.General.Value); + break; + } DebugString("Executing LATENCYFUDGE event. Frame is %d\n", ::Frame); Session.LatencyFudge = Data.General.Value; DebugString("LatencyFudge is %d\n", Session.LatencyFudge); @@ -1159,7 +1235,45 @@ void EventClass::Execute(void) // COMM_MULTI_E_COMP protocol. // case TIMING: - Data.Timing.MaxAhead -= Scen->Special.IsFogOfWar ? 10 : 0; + { + int const master_id = Session.Master_Player_ID(); + if (!Session.Play && !NetSemantic::Timing_Authority_Is_Valid(ID, master_id)) { + Log_Event_Rejection(EventRejectReason::UnauthorizedTiming, Type, ID, master_id); + break; + } + + if (Frame < 0) { + Log_Event_Rejection(EventRejectReason::InvalidTimingArithmetic, Type, ID, Data.Timing.MaxAhead); + break; + } + + std::optional const decoded_settings = NetSemantic::Decode_Timing_Settings( + Data.Timing.DesiredFrameRate, Data.Timing.MaxAhead, Data.Timing.FrameSendRate); + if (!decoded_settings) { + Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, Data.Timing.MaxAhead); + break; + } + NetTiming::TimingSettings const settings = *decoded_settings; + NetTiming::ConnectionQuality const old_quality = NetTiming::Connection_Quality_For_Settings(Session.Network_Timing_Target()); + + unsigned int const old_frame_send_rate = Session.FrameSendRate; + unsigned int const old_max_ahead = Session.MaxAhead; + NetTiming::ScheduleResult const result = Session.Schedule_Network_Timing(settings, Data.Timing.DesiredFrameRate, (unsigned int)Frame); + if (result == NetTiming::ScheduleResult::Rejected) { + Log_Event_Rejection(EventRejectReason::UnschedulableTiming, Type, ID, (int)settings.MaxAhead); + break; + } + + NetTiming::ConnectionQuality const quality = NetTiming::Connection_Quality_For_Settings(settings); + if (quality != old_quality) { + char const * format = Fetch_String(TXT_CONNECTION_QUALITY_STATUS); + char const * quality_name = Fetch_String(Network_Quality_Text_ID(quality)); + if (format != NULL && quality_name != NULL && format[0] != '\0' && quality_name[0] != '\0') { + snprintf(msg, sizeof(msg), format, quality_name); + Session.Messages.Add_Message(NULL, 0, msg, house->Scheme, + TextPrintType(TPF_6PT_GRAD|TPF_USE_GRAD_PAL|TPF_FULLSHADOW), Rule->MessageDelay * TICKS_PER_MINUTE); + } + } #if (TIMING_FIX) // @@ -1169,27 +1283,17 @@ void EventClass::Execute(void) // period of vulnerability's frame start & end values, so we // can reschedule these events to execute after it's over. // - if (Data.Timing.MaxAhead > Session.MaxAhead || Data.Timing.FrameSendRate > Session.FrameSendRate) { + if (result == NetTiming::ScheduleResult::Applied && + (settings.MaxAhead > old_max_ahead || settings.FrameSendRate > old_frame_send_rate)) { NewMaxAheadFrame1 = Frame; - NewMaxAheadFrame2 = Data.Timing.FrameSendRate * ((Data.Timing.FrameSendRate + Data.Timing.MaxAhead + Frame - 1) / Data.Timing.FrameSendRate); + NewMaxAheadFrame2 = settings.FrameSendRate * ((settings.FrameSendRate + settings.MaxAhead + Frame - 1) / settings.FrameSendRate); } else { NewMaxAheadFrame1 = 0; NewMaxAheadFrame2 = 0; } #endif - - ul = Session.MaxMaxAhead; - - Session.DesiredFrameRate = Data.Timing.DesiredFrameRate; - Session.MaxAhead = Data.Timing.MaxAhead; - - if (ul <= Session.MaxAhead) { - Session.MaxMaxAhead = Session.MaxAhead; - } - - Session.FrameSendRate = Data.Timing.FrameSendRate; - break; + } // // This event tells all systems what the other systems' process @@ -1205,6 +1309,13 @@ void EventClass::Execute(void) } break; + case NETWORK_REPORT: + if (Frame < 0 || + !Session.Record_Network_Report(ID, Data.NetworkReport.AverageProcessMilliseconds, Data.NetworkReport.WorstRoundTripMilliseconds, (unsigned int)Frame)) { + Log_Event_Rejection(EventRejectReason::InvalidNetworkReport, Type, ID, Data.NetworkReport.WorstRoundTripMilliseconds); + } + break; + /* ** Default: do nothing. */ diff --git a/code/event.h b/code/event.h index 5a3aaf57..6436a0ec 100644 --- a/code/event.h +++ b/code/event.h @@ -42,6 +42,7 @@ #include "mph.hh" #include "speed.hh" +#include #include /* @@ -108,10 +109,13 @@ class EventClass REMOVEPLAYER, LATENCYFUDGE, + NETWORK_REPORT, LAST_EVENT, // one past the last event }; + static constexpr std::uint16_t NETWORK_RTT_UNAVAILABLE = UINT16_MAX; + unsigned char Type; // Type of queue command object. /* @@ -236,6 +240,11 @@ class EventClass unsigned short AverageTicks; } ProcessTime; + struct { + std::uint16_t AverageProcessMilliseconds; + std::uint16_t WorstRoundTripMilliseconds; + } NetworkReport; + } Data; //-------------- Constructors --------------------- diff --git a/code/goptions.cpp b/code/goptions.cpp index c22beb60..c4bacbee 100644 --- a/code/goptions.cpp +++ b/code/goptions.cpp @@ -115,24 +115,30 @@ void Game_Options_Dialog(void) } +/// Returns the localized label for a synchronized connection-quality tier. +int Network_Quality_Text_ID(NetTiming::ConnectionQuality quality) +{ + switch (quality) { + case NetTiming::ConnectionQuality::Fast: return(TXT_BEST_CONNECTION); + case NetTiming::ConnectionQuality::Normal: return(TXT_GOOD_CONNECTION); + case NetTiming::ConnectionQuality::Poor: return(TXT_POOR_CONNECTION); + case NetTiming::ConnectionQuality::Bad: return(TXT_WORST_CONNECTION); + } + return(TXT_WORST_CONNECTION); +} + + /// /// Handles messages for the in game options dialog. /// This routine offers every message to the owner draw system first. What is left it uses /// to service the option buttons -- save, load, delete, briefing, resume, abort and /// settings -- either acting on them directly or noting the player's choice for -/// Game_Options_Dialog to deal with once the dialog comes down. Dragging the game speed or -/// connection quality slider updates the label beside it. +/// Game_Options_Dialog to deal with once the dialog comes down. Dragging the game speed +/// slider updates the label beside it. /// /// Returns with TRUE if the owner draw system consumed the message. BOOL CALLBACK Game_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam) { - static int GameConnectionQualityNames[] = { - TXT_WORST_CONNECTION, - TXT_POOR_CONNECTION, - TXT_GOOD_CONNECTION, - TXT_BEST_CONNECTION - }; - BOOL rc = OwnerDraw::Default_Dialog_Proc(window, message, wparam, lparam); HWND handle; @@ -204,14 +210,6 @@ BOOL CALLBACK Game_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam, case IDC_RESUME_MISSION: if (!code) { if (Session.Type == GAME_INTERNET) { - handle = GetDlgItem(window, IDC_CTRLWOL_CONNECTION); - if (handle) { - int fudge = 3 - SendMessage(handle, TBM_GETPOS, 0, 0); - if (fudge != Session.LatencyFudge) { - OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::LATENCYFUDGE, fudge)); - DebugString("LATENCYFUDGE event created - %d\n", fudge); - } - } handle = GetDlgItem(window, IDC_GAME_SPEED_SLIDER); if (handle) { int speed = (OptionsClass::MAX_SPEED_SETTING-1) - SendMessage(handle, TBM_GETPOS, 0, 0); @@ -254,20 +252,11 @@ BOOL CALLBACK Game_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam, case WM_HSCROLL: { if (LOWORD(wparam) == SB_THUMBTRACK) { int pos = HIWORD(wparam); - int textid; - if ((HWND)lparam == GetDlgItem(window, IDC_GAME_SPEED_SLIDER)) { - textid = GameSpeedNames[pos]; handle = GetDlgItem(window, IDC_GAME_SPEED_LABEL); - } else if ((HWND)lparam == GetDlgItem(window, IDC_CTRLWOL_CONNECTION)) { - textid = GameConnectionQualityNames[pos]; - handle = GetDlgItem(window, IDC_SCROLL_SPEED_LABEL); - } else { - break; - } - - if (handle) { - Static_SetText(handle, Fetch_String(textid)); + if (handle) { + Static_SetText(handle, Fetch_String(GameSpeedNames[pos])); + } } } break; @@ -285,7 +274,7 @@ BOOL CALLBACK Game_Options_Dialog_Proc(HWND window, UINT message, WPARAM wparam, /// Prepares the controls of the game options dialog. /// This routine is called when the dialog is created, and again whenever a save or delete /// has changed what is on disk. It decides which buttons the current game type allows the -/// player to use and primes the game speed and connection quality sliders. +/// player to use and primes the game speed and connection-quality controls. /// void Game_Options_On_INITDIALOG(HWND window) { @@ -313,10 +302,17 @@ void Game_Options_On_INITDIALOG(HWND window) } if (Session.Type == GAME_INTERNET) { + NetTiming::ConnectionQuality const quality = NetTiming::Connection_Quality_For_Settings(Session.Network_Timing_Target()); handle = GetDlgItem(window, IDC_CTRLWOL_CONNECTION); if (handle) { - SetSliderRangeAndPos(handle, 0, 3, 3 - Session.LatencyFudge); + SetSliderRangeAndPos(handle, 0, 3, static_cast(quality)); + EnableWindow(handle, FALSE); + } + + handle = GetDlgItem(window, IDC_SCROLL_SPEED_LABEL); + if (handle) { + Static_SetText(handle, Fetch_String(Network_Quality_Text_ID(quality))); } handle = GetDlgItem(window, IDC_GAME_SPEED_SLIDER); diff --git a/code/goptions.h b/code/goptions.h index c090f7ad..a2c69c11 100644 --- a/code/goptions.h +++ b/code/goptions.h @@ -33,6 +33,7 @@ #pragma once #include "gadget.h" +#include "nettiming.h" #include "options.h" @@ -42,4 +43,5 @@ class GameOptionsClass : public OptionsClass { }; int Abort_Dialog(void); +int Network_Quality_Text_ID(NetTiming::ConnectionQuality quality); void Game_Options_Dialog(void); diff --git a/code/init.cpp b/code/init.cpp index c4543ac6..dff056ff 100644 --- a/code/init.cpp +++ b/code/init.cpp @@ -1438,6 +1438,8 @@ bool Select_Game(bool ) Ipx.Set_Timing(std::max(TIMER_SECOND, Ipx.Global_Response_Time() + 2), (unsigned int) -1, 10 * TIMER_SECOND); } } + } else if (Session.Play && (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET)) { + Session.Reset_Network_Timing(Frame >= 0 ? static_cast(Frame) : 0u); } /* diff --git a/code/ipxgconn.cpp b/code/ipxgconn.cpp index f861d1fe..2315f2c2 100644 --- a/code/ipxgconn.cpp +++ b/code/ipxgconn.cpp @@ -141,6 +141,15 @@ int IPXGlobalConnClass::Send_Packet (void * buf, int buflen, { IPXAddressClass dest_addr; + if (buf == NULL || buflen <= 0) { + Record_Packet_Drop(CONNECTION_DROP_EMPTY_DATA); + return(0); + } + if (buflen > MaxPacketLen - (int)sizeof(GlobalHeaderType)) { + Record_Packet_Drop(CONNECTION_DROP_OVERSIZED_DATA); + return(0); + } + /*------------------------------------------------------------------------ Store the packet's Magic Number ------------------------------------------------------------------------*/ @@ -212,25 +221,37 @@ int IPXGlobalConnClass::Send_Packet (void * buf, int buflen, int IPXGlobalConnClass::Receive_Packet (void * buf, int buflen, IPXAddressClass *address) { - GlobalHeaderType *packet; // ptr to this packet SendQueueType *send_entry; // ptr to send entry header - GlobalHeaderType *entry_data; // ptr to queue entry data GlobalHeaderType ackpacket; // ACK packet to send int i; int resend; + if (address == NULL) { + Record_Packet_Drop(CONNECTION_DROP_SHORT_HEADER); + return(1); + } + + std::span packet_bytes; + if (buf != NULL && buflen > 0) { + packet_bytes = {static_cast(buf), static_cast(buflen)}; + } + NetAdmission::ConnectionResult const packet = NetAdmission::Admit_Connection_Packet(packet_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); + if (!packet.Succeeded()) { + Record_Admission_Drop(packet.ErrorCode, packet.Code); + return(1); + } + /*------------------------------------------------------------------------ Check the magic # ------------------------------------------------------------------------*/ - packet = (GlobalHeaderType *)buf; - if (packet->Header.MagicNumber!=MagicNum) { + if (packet.Magic != MagicNum) { return(0); } /*------------------------------------------------------------------------ Process the packet based on its Code ------------------------------------------------------------------------*/ - switch (packet->Header.Code) { + switch (packet.Code) { //..................................................................... // DATA_ACK: Check for a resend by comparing the source address & // ID of this packet with our last 4 received packets. @@ -248,7 +269,7 @@ int IPXGlobalConnClass::Receive_Packet (void * buf, int buflen, break; } if ((*address)==LastAddress[i] && - packet->Header.PacketID==LastPacketID[i]) { + packet.PacketID == LastPacketID[i]) { resend = 1; break; } @@ -263,7 +284,7 @@ int IPXGlobalConnClass::Receive_Packet (void * buf, int buflen, if (!resend) { if (Queue->Queue_Receive (buf, buflen, address, sizeof(IPXAddressClass))) { LastAddress[LastRXIndex] = (*address); - LastPacketID[LastRXIndex] = packet->Header.PacketID; + LastPacketID[LastRXIndex] = packet.PacketID; LastRXIndex++; if (LastRXIndex >= MaxRXIndex) { LastRXIndex = 0; @@ -284,7 +305,7 @@ int IPXGlobalConnClass::Receive_Packet (void * buf, int buflen, if (send_ack) { ackpacket.Header.MagicNumber = MagicNum; ackpacket.Header.Code = PACKET_ACK; - ackpacket.Header.PacketID = packet->Header.PacketID; + ackpacket.Header.PacketID = packet.PacketID; ackpacket.ProductID = ProductID; if (!Send ((char *)&ackpacket, sizeof(GlobalHeaderType), address, sizeof(IPXAddressClass))) { @@ -320,13 +341,19 @@ int IPXGlobalConnClass::Receive_Packet (void * buf, int buflen, /*............................................................... If ptr is valid, get ptr to its data ...............................................................*/ - entry_data = (GlobalHeaderType *)(send_entry->Buffer); + if (send_entry == NULL) { + continue; + } + std::span entry_bytes; + if (send_entry->Buffer != NULL && send_entry->BufLen > 0) { + entry_bytes = {reinterpret_cast(send_entry->Buffer), static_cast(send_entry->BufLen)}; + } + NetAdmission::ConnectionResult const entry = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); /*............................................................... If ACK is for this entry, mark it ...............................................................*/ - if (packet->Header.PacketID==entry_data->Header.PacketID && - entry_data->Header.Code == PACKET_DATA_ACK) { + if (entry.Succeeded() && packet.PacketID == entry.PacketID && entry.Code == PACKET_DATA_ACK) { send_entry->IsACK = 1; break; } @@ -350,6 +377,7 @@ int IPXGlobalConnClass::Receive_Packet (void * buf, int buflen, * * * INPUT: * * buf location to store buffer * + * capacity maximum bytes the caller's buffer can store * * buflen filled in with length of 'buf' * * address filled in with sender's address * * product_id filled in with sender's ProductID * @@ -363,12 +391,17 @@ int IPXGlobalConnClass::Receive_Packet (void * buf, int buflen, * HISTORY: * * 12/20/1994 BR : Created. * *=========================================================================*/ -int IPXGlobalConnClass::Get_Packet (void * buf, int *buflen, +int IPXGlobalConnClass::Get_Packet (void * buf, int capacity, int *buflen, IPXAddressClass *address, unsigned short *product_id) { ReceiveQueueType *rec_entry; // ptr to receive entry header - GlobalHeaderType *packet; - int packetlen; // size of received packet + + if (buflen != NULL) { + (*buflen) = 0; + } + if (buf == NULL || buflen == NULL || address == NULL || product_id == NULL || capacity <= 0) { + return(0); + } /*------------------------------------------------------------------------ Return if nothing to do @@ -386,6 +419,21 @@ int IPXGlobalConnClass::Get_Packet (void * buf, int *buflen, Read it if it's un-read ------------------------------------------------------------------------*/ if (rec_entry!=NULL && rec_entry->IsRead==0) { + std::span entry_bytes; + if (rec_entry->Buffer != NULL && rec_entry->BufLen > 0) { + entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; + } + NetAdmission::ConnectionResult const admission = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); + if (!admission.Succeeded()) { + rec_entry->IsRead = 1; + Record_Admission_Drop(admission.ErrorCode, admission.Code); + return(0); + } + if (admission.Code == PACKET_ACK) { + rec_entry->IsRead = 1; + Record_Packet_Drop(CONNECTION_DROP_INVALID_CODE); + return(0); + } /*..................................................................... Mark as read @@ -395,14 +443,15 @@ int IPXGlobalConnClass::Get_Packet (void * buf, int *buflen, /*..................................................................... Copy data packet .....................................................................*/ - packet = (GlobalHeaderType *)(rec_entry->Buffer); - packetlen = rec_entry->BufLen - sizeof(GlobalHeaderType); - if (packetlen > 0) { - memcpy(buf, rec_entry->Buffer + sizeof(GlobalHeaderType), packetlen); + NetAdmission::Error const destination = NetAdmission::Validate_Destination(admission.Payload, static_cast(capacity)); + if (destination != NetAdmission::Error::NONE) { + Record_Admission_Drop(destination, admission.Code); + return(0); } - (*buflen) = packetlen; - (*product_id) = packet->ProductID; - (*address) = (*((IPXAddressClass *)(rec_entry->ExtraBuffer))); + memcpy(buf, admission.Payload.data(), admission.Payload.size()); + (*buflen) = static_cast(admission.Payload.size()); + memcpy(product_id, entry_bytes.data() + offsetof(GlobalHeaderType, ProductID), sizeof(*product_id)); + memcpy(address, rec_entry->ExtraBuffer, sizeof(*address)); return(1); } @@ -566,9 +615,9 @@ int IPXGlobalConnClass::Receive_Packet(void * buf, int buflen) /// /// Pointer to the value to fill in with the length of the packet. /// Returns with non-zero if a packet was pulled off the queue. -int IPXGlobalConnClass::Get_Packet(void * buf, int * buflen) +int IPXGlobalConnClass::Get_Packet(void * buf, int capacity, int * buflen) { - return(ConnectionClass::Get_Packet(buf, buflen)); + return(ConnectionClass::Get_Packet(buf, capacity, buflen)); } diff --git a/code/ipxgconn.h b/code/ipxgconn.h index c0012fd1..6cd0e6bb 100644 --- a/code/ipxgconn.h +++ b/code/ipxgconn.h @@ -89,11 +89,10 @@ //--------------------------------------------------------------------------- struct GlobalHeaderType { CommHeaderType Header; - unsigned short ProductID; + std::uint16_t ProductID; }; #pragma pack(pop) - /* ***************************** Class Declaration ***************************** */ @@ -143,14 +142,11 @@ class IPXGlobalConnClass : public IPXConnClass //..................................................................... virtual int Send_Packet (void * buf, int buflen, int ack_req) override; virtual int Receive_Packet (void * buf, int buflen) override; - virtual int Get_Packet (void * buf, int * buflen) override; + virtual int Get_Packet (void * buf, int capacity, int * buflen) override; - virtual int Send_Packet (void * buf, int buflen, - IPXAddressClass *address, int ack_req); - virtual int Receive_Packet (void * buf, int buflen, - IPXAddressClass *address); - virtual int Get_Packet (void * buf, int *buflen, - IPXAddressClass *address, unsigned short *product_id); + virtual int Send_Packet (void * buf, int buflen, IPXAddressClass *address, int ack_req); + virtual int Receive_Packet (void * buf, int buflen, IPXAddressClass *address); + virtual int Get_Packet (void * buf, int capacity, int *buflen, IPXAddressClass *address, unsigned short *product_id); virtual int Discard_Undeliverable_Packets(void) override; @@ -170,6 +166,7 @@ class IPXGlobalConnClass : public IPXConnClass // stored in the extra buffer within the Queue. //..................................................................... virtual int Send (char *buf, int buflen, void *extrabuf, int extralen) override; + virtual bool Adaptive_Timing_Enabled(void) const override {return(false);} //..................................................................... // This routine is overloaded from SequencedConnClass, because the diff --git a/code/ipxmgr.cpp b/code/ipxmgr.cpp index e8e0cfca..433902d5 100644 --- a/code/ipxmgr.cpp +++ b/code/ipxmgr.cpp @@ -143,6 +143,7 @@ IPXManagerClass::IPXManagerClass (int glb_maxlen, int pvt_maxlen, SendOverflows = 0; ReceiveOverflows = 0; + ReceiveDiscards = 0; BadConnection = CONNECTION_NONE; //------------------------------------------------------------------------ @@ -794,7 +795,7 @@ int IPXManagerClass::Send_Global_Message(void *buf, int buflen, * HISTORY: * * 01/25/1995 BR : Created. * *=========================================================================*/ -int IPXManagerClass::Get_Global_Message(void *buf, int *buflen, +int IPXManagerClass::Get_Global_Message(void *buf, int capacity, int *buflen, IPXAddressClass *address, unsigned short *product_id) { //------------------------------------------------------------------------ @@ -802,7 +803,7 @@ int IPXManagerClass::Get_Global_Message(void *buf, int *buflen, //------------------------------------------------------------------------ if (!Listening) return(0); - return(GlobalChannel->Get_Packet (buf, buflen, address, product_id)); + return(GlobalChannel->Get_Packet (buf, capacity, buflen, address, product_id)); } /* end of Get_Global_Message */ @@ -909,7 +910,7 @@ int IPXManagerClass::Send_Private_Message(void *buf, int buflen, int ack_req, * HISTORY: * * 01/25/1995 BR : Created. * *=========================================================================*/ -int IPXManagerClass::Get_Private_Message(void *buf, int *buflen, int *conn_id) +int IPXManagerClass::Get_Private_Message(void *buf, int capacity, int *buflen, int *conn_id) { int i; int rc; @@ -937,7 +938,7 @@ int IPXManagerClass::Get_Private_Message(void *buf, int *buflen, int *conn_id) //..................................................................... // Check this connection for a packet //..................................................................... - rc = Connection[CurConnection]->Get_Packet (buf, buflen); + rc = Connection[CurConnection]->Get_Packet (buf, capacity, buflen); c_id = Connection[CurConnection]->ID; //..................................................................... @@ -981,6 +982,7 @@ int IPXManagerClass::Service(void) { int rc = 1; int i; + CommHeaderType packet_header; CommHeaderType *packet; int packetlen; IPXAddressClass address; @@ -999,15 +1001,24 @@ int IPXManagerClass::Service(void) temp_address_len = sizeof (temp_address); packetlen = PacketTransport->Read ( temp_receive_buffer, temp_receive_buffer_len, temp_address, temp_address_len ); if ( packetlen ) { - address = *((IPXAddressClass*) temp_address); + if (packetlen < (int)sizeof(unsigned short)) { + ReceiveDiscards++; + if (ReceiveDiscards == 1 || (ReceiveDiscards & (ReceiveDiscards - 1)) == 0) { + DebugString("Network packet drop [manager-short-magic]: %d\n", ReceiveDiscards); + } + continue; + } + memcpy(&address, temp_address, sizeof(address)); - packet = (CommHeaderType *)temp_receive_buffer; + memset(&packet_header, 0, sizeof(packet_header)); + memcpy(&packet_header, temp_receive_buffer, std::min(packetlen, (int)sizeof(packet_header))); + packet = &packet_header; if (packet->MagicNumber == GlobalChannel->Magic_Num()) { /* ** Put the packet in the Global Queue */ - if (!GlobalChannel->Receive_Packet (packet, packetlen, &address)) { + if (!GlobalChannel->Receive_Packet (temp_receive_buffer, packetlen, &address)) { ReceiveOverflows++; DebugString("GlobalChannel recive buffer overflow %d\n", ReceiveOverflows); break; @@ -1026,58 +1037,13 @@ int IPXManagerClass::Service(void) } } if (found_address) { - if (!Connection[i]->Receive_Packet (packet, packetlen)) { + if (!Connection[i]->Receive_Packet (temp_receive_buffer, packetlen)) { ReceiveOverflows++; DebugString("Recive buffer overflow %d\n", ReceiveOverflows); packetlen = 0; break; } } - - if (!found_address) { - // A tunnel ID names a player rather than a place, so it cannot - // go stale the way an address a player moved away from can. - if ( TransportMode != TRANSPORT_TUNNEL && !ScenarioInit) - { - /* - ** This packet came from an unknown source. If it looks like one of our players - ** packets then it might be from a player whos IP has changed. - */ - if (Frame > 8 && packetlen > 8U) { - if (packet->Code == ConnectionClass::PACKET_DATA_NOACK){ - /* - ** Magic number and packet code are valid. It's probably a C&C packet. - */ - EventClass *event = (EventClass*) (((char*) packet) + sizeof (CommHeaderType)); - - /* - ** If this is a framesync packet then grab the address and match it to an existing player. - */ - if (event->Type == EventClass::FRAMESYNC) { - int id = event->ID; - - assert (id != PlayerPtr->ID); - for ( int i=1 ; iPlayer.ID == id) { - - Session.Players[i]->Address = address; - - if ( Connection_Index(id) != CONNECTION_NONE ) // (else Create_Connections() has not yet been called) - { - /* - ** Found a likely candidate. Update his address. It should be OK to drop this - ** packet since it's a framesync packet and will will pick up the next one. - */ - Connection[Connection_Index(id)]->Address = address; - } - break; - } - } - } - } - } - } - } } } } @@ -1340,6 +1306,22 @@ unsigned int IPXManagerClass::Response_Time(void) } /* end of Response_Time */ +/// Returns the worst measured round trip among active private links. +std::optional IPXManagerClass::Worst_Local_Round_Trip_MS(void) const +{ + NetTiming::Milliseconds worst = 0; + for (int i = 0; i < NumConnections; i++) { + std::optional const round_trip = Connection[i]->Smoothed_Round_Trip_MS(); + if (!round_trip) { + return(std::nullopt); + } + worst = std::max(worst, *round_trip); + } + + return(worst); +} + + /// /// Fetches the average response time of a single connection. /// This routine is used by the network queue logic to pace itself against the slowest diff --git a/code/ipxmgr.h b/code/ipxmgr.h index e2cd899f..1534d61c 100644 --- a/code/ipxmgr.h +++ b/code/ipxmgr.h @@ -196,14 +196,11 @@ class IPXManagerClass : public ConnManClass /*..................................................................... This is how the application sends & receives messages. .....................................................................*/ - int Send_Global_Message (void *buf, int buflen, int ack_req = 0, - IPXAddressClass *address = NULL); - int Get_Global_Message (void *buf, int *buflen, IPXAddressClass *address, - unsigned short *product_id); + int Send_Global_Message (void *buf, int buflen, int ack_req = 0, IPXAddressClass *address = NULL); + int Get_Global_Message (void *buf, int capacity, int *buflen, IPXAddressClass *address, unsigned short *product_id); - virtual int Send_Private_Message (void *buf, int buflen, - int ack_req = 1, int conn_id = CONNECTION_NONE) override; - virtual int Get_Private_Message (void *buf, int *buflen, int *conn_id) override; + virtual int Send_Private_Message (void *buf, int buflen, int ack_req = 1, int conn_id = CONNECTION_NONE) override; + virtual int Get_Private_Message (void *buf, int capacity, int *buflen, int *conn_id) override; /*..................................................................... The main polling routine; should be called as often as possible. @@ -214,6 +211,7 @@ class IPXManagerClass : public ConnManClass This routine reports which connection has an error on it. .....................................................................*/ int Get_Bad_Connection(void); + int Receive_Discards(void) const { return(ReceiveDiscards); } /*..................................................................... Queue utility routines. The application can determine how many @@ -229,6 +227,7 @@ class IPXManagerClass : public ConnManClass reset the response time for all queues. .....................................................................*/ virtual unsigned int Response_Time(void) override; + virtual std::optional Worst_Local_Round_Trip_MS(void) const override; unsigned int Global_Response_Time(void); virtual void Reset_Response_Time(bool zero) override; @@ -304,6 +303,7 @@ class IPXManagerClass : public ConnManClass .....................................................................*/ int SendOverflows; int ReceiveOverflows; + int ReceiveDiscards; int BadConnection; }; diff --git a/code/language/language.h b/code/language/language.h index e2eddd6e..1e90a75b 100644 --- a/code/language/language.h +++ b/code/language/language.h @@ -800,6 +800,7 @@ #define TXT_UNKNOWN_PING 1042 #define TXT_MANUAL_PLACE 1043 #define TXT_MANUAL_PLACE_DESC 1044 +#define TXT_CONNECTION_QUALITY_STATUS 1045 #define IDC_LADDER_TYPE 1043 #define IDC_LADDER_LOCATION 1044 #define IDC_FINDGAME_LOCATION 1046 diff --git a/code/language/language.rc b/code/language/language.rc index af1c8189..f467398e 100644 --- a/code/language/language.rc +++ b/code/language/language.rc @@ -2422,6 +2422,7 @@ BEGIN TXT_UNKNOWN_PING "Unknown Ping" TXT_MANUAL_PLACE "Place Building" TXT_MANUAL_PLACE_DESC "Enters placement mode for the completed building waiting on the sidebar." + TXT_CONNECTION_QUALITY_STATUS "Connection quality: %s." END #endif // English (U.S.) resources diff --git a/code/mainloop.cpp b/code/mainloop.cpp index ee46616c..22cbf4a2 100644 --- a/code/mainloop.cpp +++ b/code/mainloop.cpp @@ -746,6 +746,7 @@ void Message_Input(KeyNumType &input) /* ** Network game: fill in a GlobalPacketType & send it. */ + memset(&Session.GPacket, 0, sizeof(Session.GPacket)); Session.GPacket.Command = NET_MESSAGE; strcpy (Session.GPacket.Name, Session.Players[0]->Name); Session.GPacket.Message.Color = Session.ColorIdx; diff --git a/code/netadmit.cpp b/code/netadmit.cpp new file mode 100644 index 00000000..82db1adb --- /dev/null +++ b/code/netadmit.cpp @@ -0,0 +1,139 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "netadmit.h" + +#include + + +namespace NetAdmission +{ + namespace { + + constexpr std::size_t CRC_SIZE = sizeof(std::uint32_t); + constexpr std::size_t COMMON_HEADER_SIZE = PRIVATE_HEADER_SIZE; + + + /// Folds one native-endian word into the legacy network CRC. + void Add_CRC_Value(std::uint32_t & crc, std::uint32_t value) noexcept + { + std::uint32_t const high_bit = crc >> 31; + crc = (crc << 1) + value + high_bit; + } + + } // namespace + + + /// Calculates the legacy CRC over a datagram payload. + std::uint32_t Calculate_Datagram_CRC(std::span payload) noexcept + { + std::uint32_t crc = 0; + std::size_t position = 0; + while (payload.size() - position >= sizeof(std::uint32_t)) { + std::uint32_t value = 0; + std::memcpy(&value, payload.data() + position, sizeof(value)); + Add_CRC_Value(crc, value); + position += sizeof(value); + } + + if (position < payload.size()) { + std::uint32_t value = 0; + std::memcpy(&value, payload.data() + position, payload.size() - position); + Add_CRC_Value(crc, value); + } + return(crc); + } + + + /// Validates a datagram's capacity and CRC. + DatagramResult Admit_Datagram(std::span datagram, std::size_t payload_capacity) noexcept + { + DatagramResult result; + if (datagram.size() <= CRC_SIZE) { + result.ErrorCode = Error::DATAGRAM_TOO_SHORT; + return(result); + } + + result.Payload = datagram.subspan(CRC_SIZE); + if (result.Payload.size() > payload_capacity) { + result.Payload = {}; + result.ErrorCode = Error::DATAGRAM_TOO_LARGE; + return(result); + } + + std::memcpy(&result.WireCRC, datagram.data(), sizeof(result.WireCRC)); + if (result.WireCRC != Calculate_Datagram_CRC(result.Payload)) { + result.Payload = {}; + result.ErrorCode = Error::BAD_CRC; + } + return(result); + } + + + /// Validates a reliable-channel packet envelope. + ConnectionResult Admit_Connection_Packet(std::span packet, std::size_t header_size, std::size_t packet_capacity) noexcept + { + ConnectionResult result; + if (header_size < COMMON_HEADER_SIZE || packet.size() < header_size) { + result.ErrorCode = Error::HEADER_TOO_SHORT; + return(result); + } + if (packet.size() > packet_capacity) { + result.ErrorCode = Error::PACKET_TOO_LARGE; + return(result); + } + + std::memcpy(&result.Magic, packet.data(), sizeof(result.Magic)); + std::memcpy(&result.Code, packet.data() + sizeof(result.Magic), sizeof(result.Code)); + std::memcpy(&result.PacketID, packet.data() + sizeof(result.Magic) + sizeof(result.Code), sizeof(result.PacketID)); + if (result.Code >= static_cast(PacketCode::COUNT)) { + result.ErrorCode = Error::INVALID_PACKET_CODE; + return(result); + } + + result.Payload = packet.subspan(header_size); + // Acknowledgements are header-only; data packet codes always carry application bytes. + bool const ack_has_payload = result.Code == static_cast(PacketCode::ACK) && !result.Payload.empty(); + bool const data_has_no_payload = + (result.Code == static_cast(PacketCode::DATA_ACK) + || result.Code == static_cast(PacketCode::DATA_NOACK)) + && result.Payload.empty(); + if (ack_has_payload || data_has_no_payload) { + result.Payload = {}; + result.ErrorCode = Error::INVALID_PACKET_LENGTH; + } + return(result); + } + + + /// Checks that a caller can hold an admitted payload. + Error Validate_Destination(std::span payload, std::size_t destination_capacity) noexcept + { + return(payload.size() <= destination_capacity ? Error::NONE : Error::DESTINATION_TOO_SMALL); + } + + + /// Returns a stable admission-error name. + char const * Error_Name(Error error) noexcept + { + switch (error) { + case Error::NONE: return("none"); + case Error::DATAGRAM_TOO_SHORT: return("datagram too short"); + case Error::DATAGRAM_TOO_LARGE: return("datagram too large"); + case Error::BAD_CRC: return("bad datagram CRC"); + case Error::HEADER_TOO_SHORT: return("message header too short"); + case Error::PACKET_TOO_LARGE: return("message too large"); + case Error::INVALID_PACKET_CODE: return("invalid message code"); + case Error::INVALID_PACKET_LENGTH: return("invalid message length"); + case Error::DESTINATION_TOO_SMALL: return("destination too small"); + case Error::COUNT: break; + } + return("unknown admission error"); + } +} diff --git a/code/netadmit.h b/code/netadmit.h new file mode 100644 index 00000000..8a871c6b --- /dev/null +++ b/code/netadmit.h @@ -0,0 +1,79 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include +#include +#include + + +namespace NetAdmission +{ + constexpr std::size_t DATAGRAM_PAYLOAD_CAPACITY = 768; + constexpr std::size_t PRIVATE_HEADER_SIZE = sizeof(std::uint16_t) + sizeof(std::uint8_t) + sizeof(std::uint32_t); + constexpr std::size_t GLOBAL_HEADER_SIZE = PRIVATE_HEADER_SIZE + sizeof(std::uint16_t); + + + enum class PacketCode : std::uint8_t + { + DATA_ACK, + DATA_NOACK, + ACK, + COUNT, + }; + + + enum class Error + { + NONE, + DATAGRAM_TOO_SHORT, + DATAGRAM_TOO_LARGE, + BAD_CRC, + HEADER_TOO_SHORT, + PACKET_TOO_LARGE, + INVALID_PACKET_CODE, + INVALID_PACKET_LENGTH, + DESTINATION_TOO_SMALL, + COUNT, + }; + + + struct DatagramResult + { + Error ErrorCode = Error::NONE; + std::uint32_t WireCRC = 0; + std::span Payload; + + bool Succeeded(void) const noexcept {return(ErrorCode == Error::NONE);} + }; + + + struct ConnectionResult + { + Error ErrorCode = Error::NONE; + std::uint16_t Magic = 0; + std::uint8_t Code = 0; + std::uint32_t PacketID = 0; + std::span Payload; + + bool Succeeded(void) const noexcept {return(ErrorCode == Error::NONE);} + }; + + + std::uint32_t Calculate_Datagram_CRC(std::span payload) noexcept; + + DatagramResult Admit_Datagram(std::span datagram, std::size_t payload_capacity = DATAGRAM_PAYLOAD_CAPACITY) noexcept; + + ConnectionResult Admit_Connection_Packet(std::span packet, std::size_t header_size, std::size_t packet_capacity) noexcept; + + Error Validate_Destination(std::span payload, std::size_t destination_capacity) noexcept; + + char const * Error_Name(Error error) noexcept; +} diff --git a/code/netdlg.cpp b/code/netdlg.cpp index e120ea7f..8997e390 100644 --- a/code/netdlg.cpp +++ b/code/netdlg.cpp @@ -283,15 +283,9 @@ void Destroy_Connection(int id, int error) //------------------------------------------------------------------------ Ipx.Delete_Connection(id); - if (error) { + // Every survivor reports the departure; execution makes later copies no-ops. + if (PlayerPtr != NULL) { OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::REMOVEPLAYER, id)); - } else if (Session.Type == GAME_INTERNET && WestwoodOnline_Tournament) { - housep->Flag_To_Die(); - } else { - //------------------------------------------------------------------------ - // Turn the player's house over to the computer's AI - //------------------------------------------------------------------------ - housep->AI_Takeover(); } Session.NumPlayers--; diff --git a/code/netdlg2.cpp b/code/netdlg2.cpp index 65b91c17..aaedad90 100644 --- a/code/netdlg2.cpp +++ b/code/netdlg2.cpp @@ -34,6 +34,7 @@ #include "msgbox.h" #include "netdlg.h" #include "netshare.h" +#include "nettiming.h" #include "newmenu.h" #include "ownrdraw.h" #include "rules.h" @@ -920,17 +921,12 @@ bool Net2Remote_Connect(void) PregameSetup(); - //..................................................................... - // Compute frame delay value for packet transmissions: - // - Divide global channel's response time by 8 (2 to convert to 1-way - // value, 4 more to convert from ticks to frames) - //..................................................................... + // Compressed games begin at the balanced baseline; legacy games retain the measured delay. Session.LatencyFudge = 0; - Session.PrecalcMaxAhead = 0; - Session.PrecalcDesiredFrameRate = 0; - Session.FrameSendRate = 3; + NetTiming::TimingSettings const initial_timing = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG); + Session.FrameSendRate = initial_timing.FrameSendRate; if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { - Session.MaxAhead = std::max(((((Ipx.Global_Response_Time() / 8) + (Session.FrameSendRate - 1)) / Session.FrameSendRate) * Session.FrameSendRate), NETWORK_MIN_MAX_AHEAD * 3); + Session.MaxAhead = initial_timing.MaxAhead; } else { Session.MaxAhead = std::max(((int)Ipx.Global_Response_Time() / 8), NETWORK_MIN_MAX_AHEAD); } @@ -964,17 +960,12 @@ bool Net2Remote_Connect(void) PregameSetup(); - //..................................................................... - // Compute frame delay value for packet transmissions: - // - Divide global channel's response time by 8 (2 to convert to 1-way - // value, 4 more to convert from ticks to frames) - //..................................................................... - Session.FrameSendRate = 3; + // Compressed games begin at the balanced baseline; legacy games retain the measured delay. Session.LatencyFudge = 0; - Session.PrecalcMaxAhead = 0; - Session.PrecalcDesiredFrameRate = 0; + NetTiming::TimingSettings const initial_timing = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG); + Session.FrameSendRate = initial_timing.FrameSendRate; if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { - Session.MaxAhead = std::max(((((Ipx.Global_Response_Time() / 8) + (Session.FrameSendRate - 1)) / Session.FrameSendRate) * Session.FrameSendRate), NETWORK_MIN_MAX_AHEAD * 3); + Session.MaxAhead = initial_timing.MaxAhead; } else { Session.MaxAhead = std::max(((int)Ipx.Global_Response_Time() / 8), NETWORK_MIN_MAX_AHEAD); } @@ -1015,7 +1006,7 @@ bool Net2Remote_Connect(void) do { Call_Back(); - int retcode = Ipx.Get_Global_Message(&Session.GPacket, &Session.GPacketlen, &Session.GAddress, &Session.GProductID); + int retcode = Ipx.Get_Global_Message(&Session.GPacket, sizeof(Session.GPacket), &Session.GPacketlen, &Session.GAddress, &Session.GProductID); if (retcode && Session.GProductID == IPXGlobalConnClass::COMMAND_AND_CONQUER2) { for (int i = 1; i < Session.Players.Count(); i++) { if (Session.Players[i]->Address == Session.GAddress) { @@ -2078,7 +2069,7 @@ static void Unjoin_Game(int game_index) *=============================================================================================*/ static void Send_Join_Queries(int gamenow, int playernow, int chatnow, int init) { - GlobalPacketType packet; + GlobalPacketType packet = {}; //........................................................................ // These values control the timeouts for sending various types of packets; @@ -2185,7 +2176,7 @@ static void Send_Join_Queries(int gamenow, int playernow, int chatnow, int init) *=============================================================================================*/ bool Process_Global_Packet(GlobalPacketType *packet, IPXAddressClass *address) { - GlobalPacketType mypacket; + GlobalPacketType mypacket = {}; #if 0 //------------------------------------------------------------------------ // If our Players vector is empty, just return. @@ -2303,7 +2294,8 @@ static void Get_Join_Responses(void) //------------------------------------------------------------------------ // If there is no incoming packet, just return //------------------------------------------------------------------------ - for (Call_Back(); (rc = Ipx.Get_Global_Message (&Session.GPacket, &Session.GPacketlen, &Session.GAddress, &Session.GProductID)) != 0; Call_Back()) { + for (Call_Back(); (rc = Ipx.Get_Global_Message (&Session.GPacket, sizeof(Session.GPacket), + &Session.GPacketlen, &Session.GAddress, &Session.GProductID)) != 0; Call_Back()) { if (Session.GProductID != IPXGlobalConnClass::COMMAND_AND_CONQUER2) { continue; } @@ -2832,7 +2824,23 @@ static void Get_Join_Responses(void) //------------------------------------------------------------------------ else if (Session.GPacket.Command==NET_GO || Session.GPacket.Command==NET_LOADGAME) { if ( JoinState==JOIN_CONFIRMED) { - Session.MaxAhead = Session.GPacket.ResponseTime.OneWay; + if (Session.GPacket.Command == NET_GO && Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { + int const max_ahead = Session.GPacket.ResponseTime.OneWay; + if (max_ahead < 0) { + continue; + } + + NetTiming::TimingSettings const initial_timing = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG); + NetTiming::TimingSettings const received_timing{initial_timing.FrameSendRate, static_cast(max_ahead)}; + if (!NetTiming::Timing_Settings_Are_Valid(received_timing) || received_timing != initial_timing) { + continue; + } + + Session.FrameSendRate = received_timing.FrameSendRate; + Session.MaxAhead = received_timing.MaxAhead; + } else { + Session.MaxAhead = Session.GPacket.ResponseTime.OneWay; + } Session.HostAddress = Session.GAddress; Session.NumPlayers = Session.Players.Count(); _netresponse = IDOK; @@ -2955,7 +2963,7 @@ static void Get_Join_Responses(void) // NET_QUERY_JOIN: //------------------------------------------------------------------------ if (Session.GPacket.Command==NET_QUERY_JOIN) { - GlobalPacketType packet; + GlobalPacketType packet = {}; if (!Session.Players.Count()) { memset (&packet, 0, sizeof(GlobalPacketType)); @@ -3191,7 +3199,7 @@ static void Get_Join_Responses(void) /// bool; Is this machine ready to go? bool Net2ReadyToGo(int load_game) { - GlobalPacketType packet; + GlobalPacketType packet = {}; int i; Ipx.Set_Timing(Ipx.Global_Response_Time() + 2 > 30 ? Ipx.Global_Response_Time () + 2 : 30, (unsigned int) -1, 1000); diff --git a/code/netglobal.cpp b/code/netglobal.cpp new file mode 100644 index 00000000..18b65b0e --- /dev/null +++ b/code/netglobal.cpp @@ -0,0 +1,214 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "always.h" + +#include "netglobal.h" + +#include +#include +#include + + +namespace NetGlobal +{ + namespace { + + static_assert(std::is_trivially_copyable_v); + constexpr std::size_t PACKET_SIZE = sizeof(GlobalPacketType); + + + /// Checks that a fixed wire string contains a terminator. + bool Has_Terminator(char const * text, std::size_t capacity) + { + return(std::memchr(text, '\0', capacity) != NULL); + } + + + /// Checks a player index against the current session roster. + bool Is_Active_Player(ValidationContext const & context, int player) + { + return(player >= 0 && player < static_cast(context.ActivePlayers.size()) && context.ActivePlayers[player]); + } + + } // namespace + + + /// Clears an outgoing packet before selecting its command. + void Initialize_Packet(GlobalPacketType & packet, NetCommandType command) noexcept + { + std::memset(&packet, 0, sizeof(packet)); + packet.Command = command; + } + + + /// Resolves a sender through an exact endpoint or one unique zero-port roster entry. + EndpointResolution Resolve_Sender(Endpoint const & sender, std::span roster) noexcept + { + int match = -1; + for (std::size_t index = 0; index < roster.size(); index++) { + if (roster[index].IP == sender.IP && roster[index].Port == sender.Port) { + if (match >= 0) { + return(EndpointResolution{DecodeError::AMBIGUOUS_SENDER}); + } + match = static_cast(index); + } + } + if (match >= 0) { + return(EndpointResolution{DecodeError::NONE, EndpointMatch::EXACT, match}); + } + + for (std::size_t index = 0; index < roster.size(); index++) { + if (roster[index].IP == sender.IP && roster[index].Port == 0) { + if (match >= 0) { + return(EndpointResolution{DecodeError::AMBIGUOUS_SENDER}); + } + match = static_cast(index); + } + } + if (match >= 0) { + return(EndpointResolution{DecodeError::NONE, EndpointMatch::ZERO_PORT, match}); + } + + return(EndpointResolution{}); + } + + + /// Identifies public in-game discovery commands. + static bool Command_Is_Public(NetCommandType command) + { + return(command == NET_QUERY_GAME || command == NET_QUERY_PLAYER); + } + + + /// Identifies commands restricted to session members. + static bool Command_Requires_Member(NetCommandType command) + { + switch (command) { + case NET_SIGN_OFF: + case NET_MESSAGE: + case NET_PROGRESS_REPORT: + case NET_READY_TO_GO: + case NET_PROPOSE_KICK: + return(true); + + default: + return(false); + } + } + + + /// Validates an in-game global packet before dispatch. + DecodeError Validate_In_Game_Packet(GlobalPacketType const & packet, std::size_t packet_length, ValidationContext const & context) + { + if (packet_length != PACKET_SIZE) { + return(DecodeError::INVALID_LENGTH); + } + + bool const is_public = Command_Is_Public(packet.Command); + bool const requires_member = Command_Requires_Member(packet.Command); + if (!is_public && !requires_member) { + return(DecodeError::INVALID_COMMAND); + } + if (requires_member && !context.SenderIsMember) { + return(DecodeError::SENDER_NOT_MEMBER); + } + + switch (packet.Command) { + case NET_QUERY_PLAYER: + if (!Has_Terminator(packet.Name, sizeof(packet.Name))) { + return(DecodeError::UNTERMINATED_NAME); + } + break; + + case NET_MESSAGE: + if (!Has_Terminator(packet.Name, sizeof(packet.Name))) { + return(DecodeError::UNTERMINATED_NAME); + } + if (!Has_Terminator(packet.Message.Buf, sizeof(packet.Message.Buf))) { + return(DecodeError::UNTERMINATED_MESSAGE); + } + if (context.SenderPlayerColor < 0 || context.SenderPlayerColor >= MAX_MPLAYER_COLORS) { + return(DecodeError::INVALID_COLOR); + } + break; + + case NET_PROGRESS_REPORT: + if (packet.Progress.Percent < 0 || packet.Progress.Percent > 100) { + return(DecodeError::INVALID_PROGRESS); + } + break; + + case NET_PROPOSE_KICK: { + if (!Is_Active_Player(context, context.SenderPlayerID) || packet.Kick.KickeeID >= context.ActivePlayers.size() + || !context.ActivePlayers[packet.Kick.KickeeID]) { + return(DecodeError::INVALID_KICK_PLAYER); + } + if (context.SenderPlayerID == static_cast(packet.Kick.KickeeID)) { + return(DecodeError::SELF_KICK); + } + break; + } + + default: + break; + } + + return(DecodeError::NONE); + } + + + /// Counts a rejection and selects sparse diagnostics. + RejectionRecord RejectionCounters::Record(DecodeError error) noexcept + { + std::size_t const index = static_cast(error); + if (error == DecodeError::NONE || index >= Counts.size()) { + return(RejectionRecord{}); + } + + std::uint32_t & count = Counts[index]; + if (count != std::numeric_limits::max()) { + count++; + } + + return(RejectionRecord{count, count == 1 || (count & (count - 1)) == 0}); + } + + + /// Returns one rejection category's count. + std::uint32_t RejectionCounters::Count(DecodeError error) const noexcept + { + std::size_t const index = static_cast(error); + return(index < Counts.size() ? Counts[index] : 0); + } + + + /// Returns a stable global-packet rejection name. + char const * Error_Name(DecodeError error) noexcept + { + switch (error) { + case DecodeError::NONE: return("none"); + case DecodeError::INVALID_LENGTH: return("invalid length"); + case DecodeError::INVALID_COMMAND: return("invalid command"); + case DecodeError::SENDER_NOT_MEMBER: return("sender is not a session member"); + case DecodeError::UNTERMINATED_NAME: return("unterminated player name"); + case DecodeError::UNTERMINATED_MESSAGE: return("unterminated message"); + case DecodeError::INVALID_COLOR: return("invalid session-member color"); + case DecodeError::INVALID_PROGRESS: return("invalid progress value"); + case DecodeError::INVALID_KICK_PLAYER: return("invalid kick player"); + case DecodeError::SELF_KICK: return("self kick proposal"); + case DecodeError::DUPLICATE_KICK_PROPOSAL: return("duplicate kick proposal"); + case DecodeError::KICK_PROPOSAL_QUEUE_FULL: return("kick proposal queue full"); + case DecodeError::AMBIGUOUS_SENDER: return("ambiguous session-member endpoint"); + case DecodeError::COUNT: break; + } + + return("unknown global packet error"); + } +} diff --git a/code/netglobal.h b/code/netglobal.h new file mode 100644 index 00000000..47b8128b --- /dev/null +++ b/code/netglobal.h @@ -0,0 +1,98 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "session.h" + +#include +#include +#include +#include + + +namespace NetGlobal +{ + enum class DecodeError + { + NONE, + INVALID_LENGTH, + INVALID_COMMAND, + SENDER_NOT_MEMBER, + UNTERMINATED_NAME, + UNTERMINATED_MESSAGE, + INVALID_COLOR, + INVALID_PROGRESS, + INVALID_KICK_PLAYER, + SELF_KICK, + DUPLICATE_KICK_PROPOSAL, + KICK_PROPOSAL_QUEUE_FULL, + AMBIGUOUS_SENDER, + COUNT, + }; + + + struct Endpoint + { + std::uint32_t IP = 0; + std::uint16_t Port = 0; + }; + + + enum class EndpointMatch + { + NONE, + EXACT, + ZERO_PORT, + }; + + + struct EndpointResolution + { + DecodeError Error = DecodeError::SENDER_NOT_MEMBER; + EndpointMatch Match = EndpointMatch::NONE; + int RosterIndex = -1; + }; + + + struct ValidationContext + { + bool SenderIsMember = false; + int SenderPlayerID = -1; + int SenderPlayerColor = -1; + std::array ActivePlayers = {}; + }; + + + struct RejectionRecord + { + std::uint32_t Count = 0; + bool ShouldLog = false; + }; + + + class RejectionCounters + { + public: + RejectionRecord Record(DecodeError error) noexcept; + std::uint32_t Count(DecodeError error) const noexcept; + + private: + std::array(DecodeError::COUNT)> Counts = {}; + }; + + + void Initialize_Packet(GlobalPacketType & packet, NetCommandType command) noexcept; + + EndpointResolution Resolve_Sender(Endpoint const & sender, std::span roster) noexcept; + + DecodeError Validate_In_Game_Packet(GlobalPacketType const & packet, std::size_t packet_length, ValidationContext const & context); + + char const * Error_Name(DecodeError error) noexcept; +} diff --git a/code/netpacket.cpp b/code/netpacket.cpp new file mode 100644 index 00000000..136309a3 --- /dev/null +++ b/code/netpacket.cpp @@ -0,0 +1,680 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "netpacket.h" + +#include "netreader.h" + +#include +#include +#include +#include +#include + + +namespace NetPacket +{ + namespace { + + using EventDataType = decltype(std::declval().Data); + using FrameInfoType = decltype(std::declval().Data.FrameInfo); + using MegaMissionType = decltype(std::declval().Data.MegaMission); + using VariableDataType = decltype(std::declval().Data.Variable); + using VariableSizeType = decltype(std::declval().Size); + using EventTypeField = decltype(std::declval().Type); + using EventFrameField = decltype(std::declval().Frame); + using EventExecutedField = decltype(std::declval().IsExecuted); + using EventSenderField = decltype(std::declval().ID); + + constexpr std::size_t EVENT_FRAME_OFFSET = offsetof(EventClass, Frame); + constexpr std::size_t EVENT_SENDER_OFFSET = offsetof(EventClass, ID); + constexpr std::size_t EVENT_DATA_OFFSET = offsetof(EventClass, Data); + constexpr std::size_t EVENT_DATA_SIZE = sizeof(EventDataType); + constexpr std::size_t FRAMEINFO_DELAY_OFFSET = offsetof(FrameInfoType, Delay); + constexpr std::size_t VARIABLE_SIZE_OFFSET = offsetof(VariableDataType, Size); + constexpr std::size_t MEGAMISSION_WHOM_OFFSET = offsetof(MegaMissionType, Whom); + constexpr std::size_t MEGAMISSION_WHOM_SIZE = sizeof(std::declval().Whom); + + static_assert(std::is_standard_layout_v); + static_assert(std::is_trivially_copyable_v); + static_assert(EventClass::LAST_EVENT <= (std::numeric_limits::max)()); + + + // Pending events keep packet decoding transactional until every byte is validated. + struct PendingEvent + { + std::uint8_t Type = EventClass::EMPTY; + int Frame = 0; + int Sender = 0; + std::array Data{}; + std::vector AddPlayerData; + }; + + + struct PacketEnvelope + { + std::uint8_t Type = EventClass::EMPTY; + int Frame = 0; + int Sender = 0; + std::array FrameInfo{}; + }; + + + /// Builds a packet-decode failure at a stable byte offset. + DecodeResult Failed(DecodeError code, std::size_t offset, std::uint8_t event_type = NO_EVENT_TYPE) + { + DecodeResult result; + result.Failure.Code = code; + result.Failure.Offset = offset; + result.Failure.EventType = event_type; + return(result); + } + + + /// Checks an event type before table lookup. + bool Is_Known_Event(std::uint8_t type) + { + return(type < EventClass::LAST_EVENT); + } + + + /// Identifies packet-level synchronization events. + bool Is_Envelope(std::uint8_t type) + { + return(type == EventClass::FRAMEINFO || type == EventClass::FRAMESYNC); + } + + + /// Reads a complete frame envelope from bounded packet bytes. + bool Read_Envelope(Reader & reader, PacketEnvelope & envelope, DecodeFailure & failure) + { + std::size_t const offset = reader.Offset(); + auto type = reader.Read_Value(); + auto frame = reader.Read_Value(); + auto executed = reader.Take(sizeof(EventExecutedField)); + auto sender = reader.Read_Value(); + auto frame_info = reader.Take(sizeof(FrameInfoType)); + + if (!type || !frame || !executed || !sender || !frame_info) { + failure.Code = DecodeError::TRUNCATED_ENVELOPE; + failure.Offset = offset; + failure.EventType = type.value_or(NO_EVENT_TYPE); + return(false); + } + + envelope.Type = *type; + envelope.Frame = *frame; + envelope.Sender = *sender; + std::memcpy(envelope.FrameInfo.data(), frame_info->data(), envelope.FrameInfo.size()); + return(true); + } + + + /// Validates the sender-frame arithmetic retained in an envelope. + bool Validate_Envelope_Frame(int frame, std::span frame_info, std::uint8_t type, DecodeFailure & failure) + { + std::uint8_t delay = 0; + std::memcpy(&delay, frame_info.data() + FRAMEINFO_DELAY_OFFSET, sizeof(delay)); + if (Compute_Reported_Frame(frame, delay)) { + return(true); + } + + failure.Code = DecodeError::INVALID_FRAME_ARITHMETIC; + failure.Offset = frame < 0 ? EVENT_FRAME_OFFSET : EVENT_DATA_OFFSET + FRAMEINFO_DELAY_OFFSET; + failure.EventType = type; + return(false); + } + + + /// Converts an admitted envelope into a pending event. + PendingEvent Pending_From_Envelope(PacketEnvelope const & envelope) + { + PendingEvent event; + event.Type = envelope.Type; + event.Frame = envelope.Frame; + event.Sender = envelope.Sender; + std::memcpy(event.Data.data(), envelope.FrameInfo.data(), envelope.FrameInfo.size()); + return(event); + } + + + /// Fetches a validated event payload length. + bool Event_Data_Length(std::uint8_t type, std::size_t offset, std::size_t & length, DecodeFailure & failure) + { + length = EventClass::EventLength[type]; + if (length <= EVENT_DATA_SIZE) { + return(true); + } + + failure.Code = DecodeError::INVALID_EVENT_LENGTH; + failure.Offset = offset; + failure.EventType = type; + return(false); + } + + + /// Places compact wire data into its EventClass field. + void Copy_Event_Data(PendingEvent & event, std::uint8_t type, std::span data) + { + std::size_t offset = 0; + if (type == EventClass::RESPONSE_TIME) { + offset = FRAMEINFO_DELAY_OFFSET; + } + + std::memcpy(event.Data.data() + offset, data.data(), data.size()); + } + + + /// Reads and owns a variable-length ADDPLAYER payload. + bool Read_Add_Player(Reader & reader, PendingEvent & event, std::size_t event_offset, DecodeFailure & failure) + { + auto size = reader.Read_Value(); + if (!size) { + failure.Code = DecodeError::TRUNCATED_ADDPLAYER; + failure.Offset = event_offset; + failure.EventType = EventClass::ADDPLAYER; + return(false); + } + + auto data = reader.Take(*size); + if (!data) { + failure.Code = DecodeError::TRUNCATED_ADDPLAYER; + failure.Offset = event_offset; + failure.EventType = EventClass::ADDPLAYER; + return(false); + } + + std::memcpy(event.Data.data() + VARIABLE_SIZE_OFFSET, &*size, sizeof(*size)); + event.AddPlayerData.assign(data->begin(), data->end()); + return(true); + } + + + /// Expands a bounded compressed MEGAMISSION run into pending events. + bool Read_Compressed_Mega_Mission(Reader & reader, int frame, int sender, std::size_t event_offset, + std::vector & events, DecodeFailure & failure) + { + auto count = reader.Read_Value(); + if (!count) { + failure.Code = DecodeError::TRUNCATED_MEGAMISSION; + failure.Offset = event_offset; + failure.EventType = EventClass::MEGAMISSION; + return(false); + } + if (*count == 0) { + failure.Code = DecodeError::ZERO_MEGAMISSION_COUNT; + failure.Offset = event_offset; + failure.EventType = EventClass::MEGAMISSION; + return(false); + } + + std::size_t const data_length = EventClass::EventLength[EventClass::MEGAMISSION]; + auto data = reader.Take(data_length); + if (!data) { + failure.Code = DecodeError::TRUNCATED_MEGAMISSION; + failure.Offset = event_offset; + failure.EventType = EventClass::MEGAMISSION; + return(false); + } + + PendingEvent first; + first.Type = EventClass::MEGAMISSION; + first.Frame = frame; + first.Sender = sender; + std::memcpy(first.Data.data(), data->data(), data->size()); + events.push_back(first); + + for (std::uint8_t index = 1; index < *count; index++) { + auto whom = reader.Take(MEGAMISSION_WHOM_SIZE); + if (!whom) { + failure.Code = DecodeError::TRUNCATED_MEGAMISSION; + failure.Offset = event_offset; + failure.EventType = EventClass::MEGAMISSION; + return(false); + } + + PendingEvent repeated = first; + std::memcpy(repeated.Data.data() + MEGAMISSION_WHOM_OFFSET, whom->data(), whom->size()); + events.push_back(std::move(repeated)); + } + + return(true); + } + + + /// Materializes a completely validated batch of pending events. + DecodeResult Materialize(std::vector pending) + { + DecodeResult result; + result.Events.reserve(pending.size()); + + for (PendingEvent & source : pending) { + EventClass event; + std::memset(&event, 0, sizeof(event)); + event.Type = source.Type; + event.Frame = source.Frame; + event.IsExecuted = false; + event.ID = source.Sender; + std::memcpy(&event.Data, source.Data.data(), source.Data.size()); + + result.Events.emplace_back(event, std::move(source.AddPlayerData)); + } + if (!result.Events.empty()) { + result.Envelope = result.Events.front().Event; + result.HasEnvelope = true; + } + + return(result); + } + + + /// Preserves a validated FRAMESYNC envelope without scheduling it as an event. + DecodeResult Materialize_Frame_Sync(PacketEnvelope const & envelope) + { + DecodeResult result = Materialize({Pending_From_Envelope(envelope)}); + result.Events.clear(); + return(result); + } + + + /// Decodes a compressed event packet transactionally. + DecodeResult Decode_Compressed(std::span packet, int expected_sender) + { + Reader reader(packet); + std::uint8_t const first_type = std::to_integer(packet.front()); + + if (!Is_Known_Event(first_type)) { + return(Failed(DecodeError::INVALID_EVENT_TYPE, 0, first_type)); + } + if (!Is_Envelope(first_type)) { + return(Failed(DecodeError::INVALID_PREFIX, 0, first_type)); + } + + PacketEnvelope envelope; + DecodeFailure failure; + if (!Read_Envelope(reader, envelope, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + if (envelope.Sender != expected_sender) { + return(Failed(DecodeError::SENDER_MISMATCH, EVENT_SENDER_OFFSET, envelope.Type)); + } + if (envelope.Type == EventClass::FRAMESYNC) { + if (!reader.Empty()) { + return(Failed(DecodeError::FRAMESYNC_NOT_ALONE, reader.Offset(), envelope.Type)); + } + if (!Validate_Envelope_Frame(envelope.Frame, envelope.FrameInfo, envelope.Type, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + return(Materialize_Frame_Sync(envelope)); + } + if (!Validate_Envelope_Frame(envelope.Frame, envelope.FrameInfo, envelope.Type, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + + // Compact children inherit the identity from the already validated envelope. + std::vector events; + events.push_back(Pending_From_Envelope(envelope)); + + while (!reader.Empty()) { + std::size_t const event_offset = reader.Offset(); + auto type_value = reader.Read_Value(); + if (!type_value) { + return(Failed(DecodeError::TRUNCATED_EVENT, event_offset)); + } + + std::uint8_t const type = *type_value; + if (!Is_Known_Event(type)) { + return(Failed(DecodeError::INVALID_EVENT_TYPE, event_offset, type)); + } + if (Is_Envelope(type)) { + return(Failed(DecodeError::NESTED_ENVELOPE, event_offset, type)); + } + + if (type == EventClass::MEGAMISSION) { + if (!Read_Compressed_Mega_Mission(reader, envelope.Frame, envelope.Sender, event_offset, events, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + continue; + } + + PendingEvent event; + event.Type = type; + event.Frame = envelope.Frame; + event.Sender = envelope.Sender; + + if (type == EventClass::ADDPLAYER) { + if (EventClass::EventLength[type] != sizeof(VariableSizeType)) { + return(Failed(DecodeError::INVALID_EVENT_LENGTH, event_offset, type)); + } + if (!Read_Add_Player(reader, event, event_offset, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + events.push_back(std::move(event)); + continue; + } + + std::size_t data_length = 0; + if (!Event_Data_Length(type, event_offset, data_length, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + auto data = reader.Take(data_length); + if (!data) { + return(Failed(DecodeError::TRUNCATED_EVENT, event_offset, type)); + } + + Copy_Event_Data(event, type, *data); + events.push_back(std::move(event)); + } + + return(Materialize(std::move(events))); + } + + + /// Reads one fixed-size EventClass record into a pending event. + bool Read_Full_Event(std::span bytes, PendingEvent & event, std::size_t event_offset, int expected_sender, DecodeFailure & failure) + { + Reader reader(bytes); + auto type = reader.Read_Value(); + auto frame = reader.Read_Value(); + auto executed = reader.Take(sizeof(EventExecutedField)); + auto sender = reader.Read_Value(); + auto data = reader.Take(EVENT_DATA_SIZE); + + if (!type || !frame || !executed || !sender || !data) { + failure.Code = DecodeError::TRUNCATED_EVENT; + failure.Offset = event_offset; + failure.EventType = type.value_or(NO_EVENT_TYPE); + return(false); + } + if (!Is_Known_Event(*type)) { + failure.Code = DecodeError::INVALID_EVENT_TYPE; + failure.Offset = event_offset; + failure.EventType = *type; + return(false); + } + if (*sender != expected_sender) { + failure.Code = DecodeError::SENDER_MISMATCH; + failure.Offset = event_offset + EVENT_SENDER_OFFSET; + failure.EventType = *type; + return(false); + } + + event.Type = *type; + event.Frame = *frame; + event.Sender = *sender; + + std::size_t data_length = 0; + if (!Event_Data_Length(*type, event_offset, data_length, failure)) { + return(false); + } + + if (*type == EventClass::FRAMEINFO) { + std::memcpy(event.Data.data(), data->data(), sizeof(FrameInfoType)); + } else if (*type == EventClass::ADDPLAYER) { + std::memcpy(event.Data.data() + VARIABLE_SIZE_OFFSET, data->data() + VARIABLE_SIZE_OFFSET, sizeof(VariableSizeType)); + } else if (*type == EventClass::RESPONSE_TIME) { + std::memcpy(event.Data.data() + FRAMEINFO_DELAY_OFFSET, data->data() + FRAMEINFO_DELAY_OFFSET, data_length); + } else { + std::memcpy(event.Data.data(), data->data(), data_length); + } + + return(true); + } + + + /// Reads the variable payload size retained in an ADDPLAYER event. + VariableSizeType Add_Player_Size(PendingEvent const & event) + { + VariableSizeType size = 0; + std::memcpy(&size, event.Data.data() + VARIABLE_SIZE_OFFSET, sizeof(size)); + return(size); + } + + + /// Decodes an uncompressed event packet transactionally. + DecodeResult Decode_Uncompressed(std::span packet, int expected_sender) + { + std::uint8_t const first_type = std::to_integer(packet.front()); + if (!Is_Known_Event(first_type)) { + return(Failed(DecodeError::INVALID_EVENT_TYPE, 0, first_type)); + } + if (!Is_Envelope(first_type)) { + return(Failed(DecodeError::INVALID_PREFIX, 0, first_type)); + } + + if (first_type == EventClass::FRAMESYNC) { + Reader reader(packet); + PacketEnvelope envelope; + DecodeFailure failure; + if (!Read_Envelope(reader, envelope, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + if (envelope.Sender != expected_sender) { + return(Failed(DecodeError::SENDER_MISMATCH, EVENT_SENDER_OFFSET, envelope.Type)); + } + if (!reader.Empty()) { + return(Failed(DecodeError::FRAMESYNC_NOT_ALONE, reader.Offset(), envelope.Type)); + } + if (!Validate_Envelope_Frame(envelope.Frame, envelope.FrameInfo, envelope.Type, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + return(Materialize_Frame_Sync(envelope)); + } + if (packet.size() < sizeof(EventClass)) { + return(Failed(DecodeError::TRUNCATED_ENVELOPE, 0, first_type)); + } + + Reader reader(packet); + std::vector events; + DecodeFailure failure; + + while (!reader.Empty()) { + std::size_t const event_offset = reader.Offset(); + if (reader.Remaining() < sizeof(EventClass)) { + return(Failed(DecodeError::TRAILING_BYTES, event_offset)); + } + + auto bytes = reader.Take(sizeof(EventClass)); + PendingEvent event; + if (!Read_Full_Event(*bytes, event, event_offset, expected_sender, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + + if (events.empty()) { + if (event.Type != EventClass::FRAMEINFO) { + return(Failed(DecodeError::INVALID_PREFIX, event_offset, event.Type)); + } + if (!Validate_Envelope_Frame(event.Frame, event.Data, event.Type, failure)) { + DecodeResult result; + result.Failure = failure; + return(result); + } + } else if (Is_Envelope(event.Type)) { + return(Failed(DecodeError::NESTED_ENVELOPE, event_offset, event.Type)); + } + + if (event.Type == EventClass::ADDPLAYER) { + VariableSizeType const size = Add_Player_Size(event); + auto data = reader.Take(size); + if (!data) { + return(Failed(DecodeError::TRUNCATED_ADDPLAYER, event_offset, event.Type)); + } + event.AddPlayerData.assign(data->begin(), data->end()); + } + + events.push_back(std::move(event)); + } + + return(Materialize(std::move(events))); + } + + } // namespace + + + /// Constructs an empty decoded event. + DecodedEvent::DecodedEvent(void) noexcept + { + std::memset(&Event, 0, sizeof(Event)); + } + + + /// Owns one decoded event and its optional variable payload. + DecodedEvent::DecodedEvent(EventClass const & event, std::vector add_player_data) noexcept + : Event(event), AddPlayerData(std::move(add_player_data)) + { + Bind_AddPlayer_Data(); + } + + + /// Copies a decoded event and repairs its owned payload pointer. + DecodedEvent::DecodedEvent(DecodedEvent const & other) + : Event(other.Event), AddPlayerData(other.AddPlayerData) + { + Bind_AddPlayer_Data(); + } + + + /// Moves a decoded event and repairs both payload pointers. + DecodedEvent::DecodedEvent(DecodedEvent && other) noexcept + : Event(other.Event), AddPlayerData(std::move(other.AddPlayerData)) + { + Bind_AddPlayer_Data(); + other.Bind_AddPlayer_Data(); + } + + + /// Copies a decoded event and repairs its owned payload pointer. + DecodedEvent & DecodedEvent::operator=(DecodedEvent const & other) + { + if (this != &other) { + Event = other.Event; + AddPlayerData = other.AddPlayerData; + Bind_AddPlayer_Data(); + } + return(*this); + } + + + /// Moves a decoded event and repairs both payload pointers. + DecodedEvent & DecodedEvent::operator=(DecodedEvent && other) noexcept + { + if (this != &other) { + Event = other.Event; + AddPlayerData = std::move(other.AddPlayerData); + Bind_AddPlayer_Data(); + other.Bind_AddPlayer_Data(); + } + return(*this); + } + + + /// Binds an ADDPLAYER event to its owned variable payload. + void DecodedEvent::Bind_AddPlayer_Data(void) noexcept + { + if (Event.Type != EventClass::ADDPLAYER) { + return; + } + + Event.Data.Variable.Size = static_cast(AddPlayerData.size()); + Event.Data.Variable.Pointer = AddPlayerData.empty() ? nullptr : AddPlayerData.data(); + } + + + /// Checks whether packet decoding completed without error. + bool DecodeResult::Succeeded(void) const noexcept + { + return(Failure.Code == DecodeError::NONE); + } + + + /// Computes the sender frame without signed underflow. + std::optional Compute_Reported_Frame(int event_frame, std::uint8_t delay) noexcept + { + std::int64_t const frame = event_frame; + std::int64_t const frame_delay = delay; + if (frame < 0 || frame_delay > frame) { + return(std::nullopt); + } + + return(frame - frame_delay); + } + + + /// Bounds a reported sender frame against the receiver's current frame. + std::optional Compute_Reported_Frame(int event_frame, std::uint8_t delay, int receiver_frame, std::uint32_t maximum_lead) noexcept + { + std::optional const reported = Compute_Reported_Frame(event_frame, delay); + std::int64_t const maximum = static_cast(receiver_frame) + maximum_lead; + return(reported && *reported <= maximum ? reported : std::nullopt); + } + + + /// Decodes a complete event packet using its negotiated encoding. + DecodeResult Decode_Event_Packet(std::span packet, Encoding encoding, int expected_sender) + { + if (packet.empty()) { + return(Failed(DecodeError::EMPTY_PACKET, 0)); + } + + switch (encoding) { + case Encoding::UNCOMPRESSED: + return(Decode_Uncompressed(packet, expected_sender)); + + case Encoding::COMPRESSED: + return(Decode_Compressed(packet, expected_sender)); + } + + return(Failed(DecodeError::INVALID_PREFIX, 0)); + } + + + /// Returns a stable packet-decode error name. + char const * Error_Name(DecodeError error) noexcept + { + switch (error) { + case DecodeError::NONE: return("none"); + case DecodeError::EMPTY_PACKET: return("empty packet"); + case DecodeError::INVALID_EVENT_TYPE: return("invalid event type"); + case DecodeError::INVALID_PREFIX: return("invalid packet prefix"); + case DecodeError::TRUNCATED_ENVELOPE: return("truncated packet envelope"); + case DecodeError::FRAMESYNC_NOT_ALONE: return("framesync is not alone"); + case DecodeError::NESTED_ENVELOPE: return("nested packet envelope"); + case DecodeError::SENDER_MISMATCH: return("sender identity mismatch"); + case DecodeError::INVALID_FRAME_ARITHMETIC: return("invalid frame arithmetic"); + case DecodeError::INVALID_EVENT_LENGTH: return("invalid event length"); + case DecodeError::TRUNCATED_EVENT: return("truncated event"); + case DecodeError::ZERO_MEGAMISSION_COUNT: return("zero megamission count"); + case DecodeError::TRUNCATED_MEGAMISSION: return("truncated megamission"); + case DecodeError::TRUNCATED_ADDPLAYER: return("truncated add-player data"); + case DecodeError::TRAILING_BYTES: return("trailing packet bytes"); + case DecodeError::INVALID_CONNECTION: return("invalid connection"); + case DecodeError::COUNT: break; + } + + return("unknown packet error"); + } +} diff --git a/code/netpacket.h b/code/netpacket.h new file mode 100644 index 00000000..c9dcc3b0 --- /dev/null +++ b/code/netpacket.h @@ -0,0 +1,97 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "event.h" + +#include +#include +#include +#include +#include + + +namespace NetPacket +{ + enum class Encoding + { + UNCOMPRESSED, + COMPRESSED, + }; + + + enum class DecodeError + { + NONE, + EMPTY_PACKET, + INVALID_EVENT_TYPE, + INVALID_PREFIX, + TRUNCATED_ENVELOPE, + FRAMESYNC_NOT_ALONE, + NESTED_ENVELOPE, + SENDER_MISMATCH, + INVALID_FRAME_ARITHMETIC, + INVALID_EVENT_LENGTH, + TRUNCATED_EVENT, + ZERO_MEGAMISSION_COUNT, + TRUNCATED_MEGAMISSION, + TRUNCATED_ADDPLAYER, + TRAILING_BYTES, + INVALID_CONNECTION, + COUNT, + }; + + + constexpr std::uint8_t NO_EVENT_TYPE = UINT8_MAX; + + + struct DecodeFailure + { + DecodeError Code = DecodeError::NONE; + std::size_t Offset = 0; + std::uint8_t EventType = NO_EVENT_TYPE; + }; + + + struct DecodedEvent + { + DecodedEvent(void) noexcept; + DecodedEvent(EventClass const & event, std::vector add_player_data) noexcept; + DecodedEvent(DecodedEvent const & other); + DecodedEvent(DecodedEvent && other) noexcept; + DecodedEvent & operator=(DecodedEvent const & other); + DecodedEvent & operator=(DecodedEvent && other) noexcept; + + EventClass Event; + std::vector AddPlayerData; + + private: + void Bind_AddPlayer_Data(void) noexcept; + }; + + + struct DecodeResult + { + bool Succeeded(void) const noexcept; + + DecodeFailure Failure; + EventClass Envelope; + bool HasEnvelope = false; + std::vector Events; + }; + + + DecodeResult Decode_Event_Packet(std::span packet, Encoding encoding, int expected_sender); + + std::optional Compute_Reported_Frame(int event_frame, std::uint8_t delay) noexcept; + std::optional Compute_Reported_Frame(int event_frame, std::uint8_t delay, int receiver_frame, std::uint32_t maximum_lead) noexcept; + + char const * Error_Name(DecodeError error) noexcept; +} diff --git a/code/netreader.cpp b/code/netreader.cpp new file mode 100644 index 00000000..4c65fc1a --- /dev/null +++ b/code/netreader.cpp @@ -0,0 +1,54 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "netreader.h" + + +namespace NetPacket +{ + /// Starts a bounded read over packet bytes. + Reader::Reader(std::span data) noexcept + : Data(data), Position(0) + { + } + + + /// Returns the current read offset. + std::size_t Reader::Offset(void) const noexcept + { + return(Position); + } + + + /// Returns the unread byte count. + std::size_t Reader::Remaining(void) const noexcept + { + return(Data.size() - Position); + } + + + /// Checks whether all packet bytes were consumed. + bool Reader::Empty(void) const noexcept + { + return(Remaining() == 0); + } + + + /// Advances over a bounded span of packet bytes. + std::optional> Reader::Take(std::size_t size) noexcept + { + if (size > Remaining()) { + return(std::nullopt); + } + + std::span bytes = Data.subspan(Position, size); + Position += size; + return(bytes); + } +} diff --git a/code/netreader.h b/code/netreader.h new file mode 100644 index 00000000..4a3c63b0 --- /dev/null +++ b/code/netreader.h @@ -0,0 +1,50 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include +#include +#include +#include +#include + + +namespace NetPacket +{ + class Reader + { + public: + explicit Reader(std::span data) noexcept; + + std::size_t Offset(void) const noexcept; + std::size_t Remaining(void) const noexcept; + bool Empty(void) const noexcept; + + std::optional> Take(std::size_t size) noexcept; + + template + requires std::is_trivially_copyable_v + std::optional Read_Value(void) noexcept + { + auto bytes = Take(sizeof(T)); + if (!bytes) { + return(std::nullopt); + } + + T value{}; + std::memcpy(&value, bytes->data(), sizeof(value)); + return(value); + } + + private: + std::span Data; + std::size_t Position; + }; +} diff --git a/code/netsemantic.cpp b/code/netsemantic.cpp new file mode 100644 index 00000000..db018709 --- /dev/null +++ b/code/netsemantic.cpp @@ -0,0 +1,87 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#include "netsemantic.h" + +namespace NetSemantic +{ + /// Checks a signed index against a collection size. + bool Index_Is_Valid(int index, std::size_t count) noexcept + { + return(index >= 0 && static_cast(index) < count); + } + + + /// Checks that a synchronized object's current owner matches its sender. + bool Subject_Owner_Is_Valid(int sender, int owner) noexcept + { + return(sender >= 0 && sender == owner); + } + + + /// Checks a game-speed selector before table lookup. + bool Game_Speed_Is_Valid(int game_speed) noexcept + { + return(game_speed >= 0 && game_speed <= 6); + } + + + /// Checks a latency-margin selector before use. + bool Latency_Fudge_Is_Valid(int latency_fudge) noexcept + { + return(latency_fudge >= 0 && latency_fudge <= 3); + } + + + /// Checks an animation type or its sentinel. + bool Animation_Type_Is_Valid(int animation, int none, std::size_t count) noexcept + { + return(animation == none || Index_Is_Valid(animation, count)); + } + + + /// Checks an animation owner or its sentinel. + bool Animation_Owner_Is_Valid(int owner, int none, std::size_t count) noexcept + { + return(owner == none || Index_Is_Valid(owner, count)); + } + + + /// Checks that a timing event came from the master. + bool Timing_Authority_Is_Valid(int sender, int master) noexcept + { + return(master >= 0 && sender == master); + } + + + /// Validates the legacy propagation delay for its negotiated protocol. + bool Response_Time_Is_Valid(unsigned int delay, unsigned int minimum_delay, unsigned int frame_send_rate, bool compressed) noexcept + { + if (delay < minimum_delay) { + return(false); + } + if (!compressed) { + return(true); + } + return(NetTiming::Timing_Transition_Source_Is_Valid({frame_send_rate, delay})); + } + + + /// Validates and decodes settings carried by a timing event. + std::optional Decode_Timing_Settings(std::uint16_t desired_frame_rate, std::uint16_t max_ahead, std::uint8_t frame_send_rate) noexcept + { + if (desired_frame_rate == 0 || desired_frame_rate > 60) { + return(std::nullopt); + } + + NetTiming::TimingSettings const settings{frame_send_rate, max_ahead}; + return(NetTiming::Timing_Settings_Are_Valid(settings) ? std::optional(settings) : std::nullopt); + } + +} diff --git a/code/netsemantic.h b/code/netsemantic.h new file mode 100644 index 00000000..06eacd8d --- /dev/null +++ b/code/netsemantic.h @@ -0,0 +1,38 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +#pragma once + +#include "nettiming.h" + +#include +#include +#include + + +namespace NetSemantic +{ + bool Index_Is_Valid(int index, std::size_t count) noexcept; + + bool Subject_Owner_Is_Valid(int sender, int owner) noexcept; + + bool Game_Speed_Is_Valid(int game_speed) noexcept; + + bool Latency_Fudge_Is_Valid(int latency_fudge) noexcept; + + bool Animation_Type_Is_Valid(int animation, int none, std::size_t count) noexcept; + + bool Animation_Owner_Is_Valid(int owner, int none, std::size_t count) noexcept; + + bool Timing_Authority_Is_Valid(int sender, int master) noexcept; + + bool Response_Time_Is_Valid(unsigned int delay, unsigned int minimum_delay, unsigned int frame_send_rate, bool compressed) noexcept; + + std::optional Decode_Timing_Settings(std::uint16_t desired_frame_rate, std::uint16_t max_ahead, std::uint8_t frame_send_rate) noexcept; +} diff --git a/code/netshare.cpp b/code/netshare.cpp index 5909278d..694e5b37 100644 --- a/code/netshare.cpp +++ b/code/netshare.cpp @@ -1484,11 +1484,11 @@ void Send_Preview_To_Guests(void) Call_Back(); - GlobalPacketType response; + GlobalPacketType response = {}; int length = 455; unsigned short product_id; - if (Ipx.Get_Global_Message(&response, &length, &sender_address, &product_id)) { + if (Ipx.Get_Global_Message(&response, sizeof(response), &length, &sender_address, &product_id)) { if (response.Command == NET_PREVIEW_ACK) { for (int j = 1; j < Session.Players.Count(); j++) { if (sender_address == Session.Players[j]->Address) { diff --git a/code/nettime.cpp b/code/nettime.cpp new file mode 100644 index 00000000..a1b82f3e --- /dev/null +++ b/code/nettime.cpp @@ -0,0 +1,42 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "nettime.h" + +#include +#include + + +namespace NetTiming +{ + namespace + { + class SystemMillisecondClock final : public MillisecondClock + { + public: + Milliseconds Now(void) const override; + }; + } + + + /// Reads the system's wrapping millisecond clock. + Milliseconds SystemMillisecondClock::Now(void) const + { + return(static_cast(::timeGetTime())); + } + + + /// Returns the process-wide network clock. + MillisecondClock const & Default_Clock(void) + { + static SystemMillisecondClock clock; + return(clock); + } +} diff --git a/code/nettime.h b/code/nettime.h new file mode 100644 index 00000000..d07200cd --- /dev/null +++ b/code/nettime.h @@ -0,0 +1,38 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#pragma once + +#include + + +namespace NetTiming +{ + using Milliseconds = std::uint32_t; + + class MillisecondClock + { + public: + virtual ~MillisecondClock() = default; + virtual Milliseconds Now(void) const = 0; + }; + + MillisecondClock const & Default_Clock(void); + + constexpr Milliseconds Elapsed_Milliseconds(Milliseconds start, Milliseconds finish) + { + return(finish - start); + } + + constexpr bool Milliseconds_Have_Elapsed(Milliseconds start, Milliseconds now, Milliseconds duration) + { + return(Elapsed_Milliseconds(start, now) >= duration); + } +} diff --git a/code/nettiming.cpp b/code/nettiming.cpp new file mode 100644 index 00000000..0109993f --- /dev/null +++ b/code/nettiming.cpp @@ -0,0 +1,547 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "nettiming.h" + +#include +#include +#include + + +namespace NetTiming +{ + namespace + { + /// Divides positive integers without losing a remainder. + constexpr std::uint64_t Divide_Round_Up(std::uint64_t numerator, std::uint64_t denominator) + { + return((numerator + denominator - 1) / denominator); + } + + + /// Constrains a retransmission timeout to the supported range. + constexpr Milliseconds Clamp_Rto(std::uint64_t value) + { + return(static_cast(std::clamp(value, MINIMUM_RTO, MAXIMUM_RTO))); + } + + + /// Selects timing for the current report census. + TimingSettings Desired_Settings(TimingCensus const & census, unsigned int target_fps, bool require_headroom) + { + if (census.RequiresConservativeTiming) { + return(TimingSettings{MAXIMUM_TIMING_RUNG, MAXIMUM_MAX_AHEAD}); + } + if (census.ActivePlayers == 0) { + return(Settings_For_Rung(INITIAL_TIMING_RUNG)); + } + return(Select_Timing_Settings(census.WorstRoundTrip, target_fps, require_headroom)); + } + + + /// Checks whether settings increase the scheduling horizon. + bool Timing_Is_Worse(TimingSettings candidate, TimingSettings current) + { + return(candidate.FrameSendRate > current.FrameSendRate + || (candidate.FrameSendRate == current.FrameSendRate && candidate.MaxAhead > current.MaxAhead)); + } + + + /// Checks whether settings reduce the scheduling horizon. + bool Timing_Is_Better(TimingSettings candidate, TimingSettings current) + { + return(candidate.FrameSendRate < current.FrameSendRate + || (candidate.FrameSendRate == current.FrameSendRate && candidate.MaxAhead < current.MaxAhead)); + } + } + + + /// Restores the estimator to its unsampled state. + void RttEstimator::Reset(void) + { + Initialized = false; + SmoothedRtt = 0; + RttVariation = 0; + RetransmitTimeout = MINIMUM_RTO; + } + + + /// Updates SRTT, RTTVAR, and RTO from an eligible sample. + bool RttEstimator::Add_Sample(Milliseconds round_trip, bool retransmitted) + { + // Karn's rule excludes ambiguous acknowledgements after retransmission. + if (retransmitted) { + return(false); + } + + if (!Initialized) { + Initialized = true; + SmoothedRtt = round_trip; + RttVariation = (round_trip + 1) / 2; + } else { + Milliseconds const error = SmoothedRtt > round_trip ? SmoothedRtt - round_trip : round_trip - SmoothedRtt; + RttVariation = static_cast((3ull * RttVariation + error + 2) / 4); + SmoothedRtt = static_cast((7ull * SmoothedRtt + round_trip + 4) / 8); + } + + std::uint64_t const variation = std::max(1, 4ull * RttVariation); + RetransmitTimeout = Clamp_Rto(static_cast(SmoothedRtt) + variation); + return(true); + } + + + /// Samples an acknowledgement when its send time is unambiguous. + bool RttEstimator::Acknowledge(Milliseconds sent_at, unsigned int transmission_count, MillisecondClock const & clock) + { + if (transmission_count != 1) { + return(false); + } + return(Add_Sample(Elapsed_Milliseconds(sent_at, clock.Now()))); + } + + + /// Derives the connection timeout from smoothed latency. + Milliseconds Connection_Timeout(Milliseconds smoothed_rtt) + { + std::uint64_t const timeout = 8ull * smoothed_rtt + 250; + return(static_cast(std::clamp(timeout, MINIMUM_CONNECTION_TIMEOUT, MAXIMUM_CONNECTION_TIMEOUT))); + } + + + /// Applies bounded exponential backoff to a packet's RTO. + Milliseconds Retransmit_Delay(Milliseconds base_rto, unsigned int prior_retransmissions, Milliseconds maximum_delay) + { + maximum_delay = std::max(maximum_delay, MINIMUM_RTO); + std::uint64_t delay = std::clamp(base_rto, MINIMUM_RTO, maximum_delay); + while (prior_retransmissions-- > 0 && delay < maximum_delay) { + delay = std::min(delay * 2, maximum_delay); + } + return(static_cast(delay)); + } + + + /// Checks whether a packet's current backoff interval has elapsed. + bool Retransmit_Is_Due(Milliseconds last_send, Milliseconds now, Milliseconds base_rto, unsigned int prior_retransmissions, Milliseconds maximum_delay) + { + return(Milliseconds_Have_Elapsed(last_send, now, Retransmit_Delay(base_rto, prior_retransmissions, maximum_delay))); + } + + + /// Maps a policy rung to its balanced timing settings. + TimingSettings Settings_For_Rung(unsigned int rung) + { + rung = std::clamp(rung, MINIMUM_TIMING_RUNG, MAXIMUM_TIMING_RUNG); + return(TimingSettings{rung, rung == 1 ? 4u : 3u * rung}); + } + + + /// Maps balanced timing settings to player-facing connection quality. + ConnectionQuality Connection_Quality_For_Settings(TimingSettings settings) + { + if (!Timing_Settings_Are_Valid(settings) || settings.MaxAhead > Settings_For_Rung(settings.FrameSendRate).MaxAhead) { + return(ConnectionQuality::Bad); + } + if (settings.FrameSendRate <= 2) { + return(ConnectionQuality::Fast); + } + if (settings.FrameSendRate <= 5) { + return(ConnectionQuality::Normal); + } + if (settings.FrameSendRate <= 8) { + return(ConnectionQuality::Poor); + } + return(ConnectionQuality::Bad); + } + + + /// Checks timing bounds and send-period alignment. + bool Timing_Settings_Are_Valid(TimingSettings settings) + { + TimingSettings const minimum = Settings_For_Rung(settings.FrameSendRate); + return(settings.FrameSendRate >= MINIMUM_TIMING_RUNG && settings.FrameSendRate <= MAXIMUM_TIMING_RUNG + && settings.MaxAhead >= minimum.MaxAhead && settings.MaxAhead <= MAXIMUM_MAX_AHEAD && settings.MaxAhead % settings.FrameSendRate == 0); + } + + + /// Accepts a legacy aligned horizon as the source of a safe transition. + bool Timing_Transition_Source_Is_Valid(TimingSettings settings) + { + return(settings.FrameSendRate >= MINIMUM_TIMING_RUNG && settings.FrameSendRate <= MAXIMUM_TIMING_RUNG + && settings.MaxAhead >= 2 * settings.FrameSendRate && settings.MaxAhead <= MAXIMUM_MAX_AHEAD + && settings.MaxAhead % settings.FrameSendRate == 0); + } + + + /// Rounds a scheduling horizon up to a complete send period. + std::optional Align_Max_Ahead(unsigned int required, unsigned int frame_send_rate) + { + if (frame_send_rate == 0) { + return(std::nullopt); + } + + std::uint64_t const aligned = Divide_Round_Up(required, frame_send_rate) * frame_send_rate; + if (aligned > MAXIMUM_MAX_AHEAD) { + return(std::nullopt); + } + return(static_cast(aligned)); + } + + + /// Chooses the lowest rung that covers the adjusted RTT. + TimingSettings Select_Timing_Settings(Milliseconds worst_round_trip, unsigned int target_fps, bool require_headroom) + { + target_fps = std::clamp(target_fps, 1u, 60u); + + std::uint64_t adjusted = worst_round_trip; + if (require_headroom) { + adjusted = Divide_Round_Up(adjusted * 5, 4); + } + + std::uint64_t const one_way_frames = Divide_Round_Up(adjusted * target_fps, 2000); + // A rung must cover one-way flight time plus a complete send period. + for (unsigned int rung = MINIMUM_TIMING_RUNG; rung < MAXIMUM_TIMING_RUNG; rung++) { + TimingSettings const settings = Settings_For_Rung(rung); + std::uint64_t const floor = 3ull * settings.FrameSendRate; + std::uint64_t const needed = std::max(floor, one_way_frames + settings.FrameSendRate); + if (needed > std::numeric_limits::max()) { + continue; + } + + std::optional const aligned = Align_Max_Ahead(static_cast(needed), settings.FrameSendRate); + if (aligned && *aligned <= settings.MaxAhead) { + return(settings); + } + } + + TimingSettings settings = Settings_For_Rung(MAXIMUM_TIMING_RUNG); + std::uint64_t const needed = std::max(settings.MaxAhead, one_way_frames + settings.FrameSendRate); + if (needed >= MAXIMUM_MAX_AHEAD) { + settings.MaxAhead = MAXIMUM_MAX_AHEAD - (MAXIMUM_MAX_AHEAD % settings.FrameSendRate); + } else { + settings.MaxAhead = *Align_Max_Ahead(static_cast(needed), settings.FrameSendRate); + } + return(settings); + } + + + /// Uses two early reports before settling on the normal cadence. + bool Report_Is_Due(std::uint32_t elapsed_frames) + { + return(elapsed_frames > 0 && ((elapsed_frames <= BOOTSTRAP_FIRST_EVALUATION && elapsed_frames % BOOTSTRAP_REPORT_INTERVAL == 0) + || elapsed_frames % REPORT_INTERVAL == 0)); + } + + + /// Schedules two bootstrap evaluations and the steady-state cadence. + bool Evaluation_Is_Due(std::uint32_t elapsed_frames) + { + return(elapsed_frames == BOOTSTRAP_FIRST_EVALUATION || elapsed_frames == BOOTSTRAP_FINAL_EVALUATION + || (elapsed_frames > 0 && elapsed_frames % EVALUATION_INTERVAL == 0)); + } + + + /// Clears the active-player report census. + void TimingReportCensus::Reset(void) + { + Reports = {}; + } + + + /// Adds or removes a player from the census. + bool TimingReportCensus::Set_Player_Active(unsigned int player, bool active, std::uint32_t frame) + { + if (player >= Reports.size()) { + return(false); + } + + PlayerReport & report = Reports[player]; + if (report.Active != active) { + report = {}; + report.Active = active; + report.ActiveSinceFrame = frame; + } + return(true); + } + + + /// Checks whether a player belongs to the timing census. + bool TimingReportCensus::Is_Player_Active(unsigned int player) const + { + return(player < Reports.size() && Reports[player].Active); + } + + + /// Records process time and optional RTT as one report. + bool TimingReportCensus::Record_Report(unsigned int player, Milliseconds process_milliseconds, std::optional round_trip, std::uint32_t frame) + { + if (player >= Reports.size() || !Reports[player].Active || process_milliseconds > MAXIMUM_PROCESS_MILLISECONDS + || (round_trip && *round_trip > MAXIMUM_REPORTED_RTT)) { + return(false); + } + + PlayerReport & report = Reports[player]; + report.HasReport = true; + report.HasRoundTrip = round_trip.has_value(); + report.EverHadRoundTrip |= round_trip.has_value(); + report.ProcessMilliseconds = process_milliseconds; + report.RoundTrip = round_trip.value_or(0); + report.ReportFrame = frame; + return(true); + } + + + /// Summarizes fresh reports for a simulation frame. + TimingCensus TimingReportCensus::Inspect(std::uint32_t frame) const + { + TimingCensus result; + for (PlayerReport const & report : Reports) { + if (!report.Active) { + continue; + } + + result.ActivePlayers++; + bool const fresh = report.HasReport && frame - report.ReportFrame < REPORT_EXPIRY; + if (fresh) { + result.FreshProcessReports++; + result.WorstProcessMilliseconds = std::max(result.WorstProcessMilliseconds, report.ProcessMilliseconds); + } else { + result.ProcessComplete = false; + } + + if (fresh && report.HasRoundTrip) { + result.FreshRoundTripReports++; + result.WorstRoundTrip = std::max(result.WorstRoundTrip, report.RoundTrip); + } else { + result.RoundTripComplete = false; + if (report.EverHadRoundTrip || frame - report.ActiveSinceFrame >= REPORT_EXPIRY) { + result.RequiresConservativeTiming = true; + } + } + } + return(result); + } + + + /// Uses fresh process reports without discarding the synchronized frame rate. + unsigned int Select_Desired_Frame_Rate(TimingCensus const & census, unsigned int synchronized_fps, unsigned int game_speed_fps) + { + synchronized_fps = std::clamp(synchronized_fps, 1u, 60u); + game_speed_fps = std::clamp(game_speed_fps, 1u, 60u); + if (!census.ProcessComplete) { + return(synchronized_fps); + } + + unsigned int const process_fps = census.WorstProcessMilliseconds == 0 ? 60u + : static_cast(std::max(1, 1000 / census.WorstProcessMilliseconds)); + return(std::min(process_fps, game_speed_fps)); + } + + + /// Restores the balanced policy's initial state. + void BalancedTimingPolicy::Reset(std::uint32_t frame) + { + CurrentRung = INITIAL_TIMING_RUNG; + CurrentSettings = Settings_For_Rung(INITIAL_TIMING_RUNG); + GoodEvaluations = 0; + BootstrapStartFrame = frame; + LastEvaluationFrame = frame; + LastChangeFrame = 0; + HasEvaluated = false; + HasChanged = false; + Bootstrapping = true; + } + + + /// Restores synchronized policy state after a master handoff. + void BalancedTimingPolicy::Reset_From(TimingSettings settings, std::uint32_t frame) + { + CurrentRung = std::clamp(settings.FrameSendRate, MINIMUM_TIMING_RUNG, MAXIMUM_TIMING_RUNG); + CurrentSettings = settings; + GoodEvaluations = 0; + LastEvaluationFrame = frame; + LastChangeFrame = frame; + HasEvaluated = true; + HasChanged = true; + Bootstrapping = false; + } + + + /// Commits a policy change and resets hysteresis. + void BalancedTimingPolicy::Change_To(TimingSettings settings, std::uint32_t frame) + { + CurrentRung = std::clamp(settings.FrameSendRate, MINIMUM_TIMING_RUNG, MAXIMUM_TIMING_RUNG); + CurrentSettings = settings; + GoodEvaluations = 0; + LastChangeFrame = frame; + HasChanged = true; + } + + + /// Anchors steady-state evaluations to 256 frames after reset. + void BalancedTimingPolicy::Finish_Bootstrap(void) + { + Bootstrapping = false; + GoodEvaluations = 0; + LastEvaluationFrame = BootstrapStartFrame; + HasEvaluated = true; + } + + + /// Applies cadence, hysteresis, and improvement headroom. + TimingEvaluation BalancedTimingPolicy::Evaluate(TimingCensus const & census, unsigned int target_fps, std::uint32_t frame) + { + TimingEvaluation result{Current_Settings(), CurrentRung, false, false}; + if (Bootstrapping) { + std::uint32_t const elapsed_frames = frame - BootstrapStartFrame; + if (elapsed_frames < BOOTSTRAP_FIRST_EVALUATION || (HasEvaluated && elapsed_frames < BOOTSTRAP_FINAL_EVALUATION)) { + return(result); + } + + HasEvaluated = true; + LastEvaluationFrame = frame; + result.Evaluated = true; + bool const complete = census.ProcessComplete && census.RoundTripComplete; + if (census.RequiresConservativeTiming || complete || elapsed_frames >= BOOTSTRAP_FINAL_EVALUATION) { + TimingSettings const selected = census.RequiresConservativeTiming ? Desired_Settings(census, target_fps, false) + : complete ? Desired_Settings(census, target_fps, true) : Settings_For_Rung(BOOTSTRAP_FALLBACK_RUNG); + if (selected != CurrentSettings) { + Change_To(selected, frame); + result.Changed = true; + } + Finish_Bootstrap(); + result.Settings = Current_Settings(); + result.Rung = CurrentRung; + } + return(result); + } + + if (HasEvaluated && frame - LastEvaluationFrame < EVALUATION_INTERVAL) { + return(result); + } + + HasEvaluated = true; + LastEvaluationFrame = frame; + result.Evaluated = true; + if (!census.RequiresConservativeTiming && census.ActivePlayers > 0 && !census.RoundTripComplete) { + GoodEvaluations = 0; + return(result); + } + + // Worsening is immediate; improvement must clear the headroom, cadence, and cooldown gates. + TimingSettings const desired_settings = Desired_Settings(census, target_fps, false); + if (Timing_Is_Worse(desired_settings, CurrentSettings)) { + Change_To(desired_settings, frame); + result.Changed = true; + } else if (Timing_Is_Better(desired_settings, CurrentSettings) && (!HasChanged || frame - LastChangeFrame >= CHANGE_COOLDOWN)) { + TimingSettings const headroom = Desired_Settings(census, target_fps, true); + if (Timing_Is_Better(headroom, CurrentSettings)) { + GoodEvaluations++; + if (GoodEvaluations >= GOOD_EVALUATIONS_REQUIRED) { + TimingSettings const next = desired_settings.FrameSendRate < CurrentRung + ? Settings_For_Rung(CurrentRung - 1) : desired_settings; + Change_To(next, frame); + result.Changed = true; + } + } else { + GoodEvaluations = 0; + } + } else { + GoodEvaluations = 0; + } + + result.Settings = Current_Settings(); + result.Rung = CurrentRung; + return(result); + } + + + /// Delays decreases until the old scheduling horizon drains. + std::optional Stage_Timing_Update(TimingSettings current, TimingSettings requested, std::uint32_t event_frame) + { + if (!Timing_Transition_Source_Is_Valid(current) || !Timing_Settings_Are_Valid(requested)) { + return(std::nullopt); + } + + bool const decrease = requested.FrameSendRate < current.FrameSendRate || requested.MaxAhead < current.MaxAhead; + if (!decrease) { + return(StagedTimingUpdate{requested, requested.MaxAhead, event_frame, false}); + } + + std::uint64_t const period = std::lcm(current.FrameSendRate, requested.FrameSendRate); + std::uint64_t const old_horizon = static_cast(event_frame) + current.MaxAhead; + std::uint64_t const activation = Divide_Round_Up(old_horizon, period) * period; + if (activation > std::numeric_limits::max()) { + return(std::nullopt); + } + + unsigned int const minimum_horizon = std::max(requested.MaxAhead, current.MaxAhead - current.FrameSendRate); + std::optional const initial_max_ahead = Align_Max_Ahead(minimum_horizon, requested.FrameSendRate); + if (!initial_max_ahead) { + return(std::nullopt); + } + + return(StagedTimingUpdate{requested, *initial_max_ahead, static_cast(activation), true}); + } + + + /// Advances one catch-up step without dropping below the target horizon. + std::optional Next_Transition_Max_Ahead(TimingSettings current, TimingSettings requested) + { + if (!Timing_Settings_Are_Valid(current) || !Timing_Settings_Are_Valid(requested) || current.FrameSendRate != requested.FrameSendRate) { + return(std::nullopt); + } + + if (current.MaxAhead <= requested.MaxAhead) { + return(requested.MaxAhead); + } + return(std::max(requested.MaxAhead, current.MaxAhead - requested.FrameSendRate)); + } + + + /// Advances one deterministic drain or catch-up boundary. + std::optional Advance_Timing_Transition(TimingTransitionState & transition, TimingSettings current, std::uint32_t frame) + { + bool const current_is_valid = transition.Activated ? Timing_Settings_Are_Valid(current) : Timing_Transition_Source_Is_Valid(current); + if (!transition.Plan.Deferred || !current_is_valid || !Timing_Settings_Are_Valid(transition.Plan.Settings) + || !Timing_Settings_Are_Valid({transition.Plan.Settings.FrameSendRate, transition.Plan.InitialMaxAhead})) { + return(std::nullopt); + } + + TimingTransitionAdvance result{current}; + if (!transition.Activated) { + if (!Timing_Update_Is_Due(frame, transition.Plan.ActivationFrame)) { + return(result); + } + result.Settings = {transition.Plan.Settings.FrameSendRate, transition.Plan.InitialMaxAhead}; + result.Changed = result.Settings != current; + transition.LastStepFrame = frame; + transition.Activated = true; + } else if (current.MaxAhead > transition.Plan.Settings.MaxAhead && frame > transition.LastStepFrame + && frame % transition.Plan.Settings.FrameSendRate == 0) { + std::optional const next = Next_Transition_Max_Ahead(current, transition.Plan.Settings); + if (!next) { + return(std::nullopt); + } + result.Settings.MaxAhead = *next; + result.Changed = result.Settings != current; + transition.LastStepFrame = frame; + } + + result.Complete = transition.Activated && result.Settings == transition.Plan.Settings; + return(result); + } + + + /// Checks a staged activation frame with wraparound semantics. + bool Timing_Update_Is_Due(std::uint32_t frame, std::uint32_t activation_frame) + { + return(static_cast(frame - activation_frame) >= 0); + } +} diff --git a/code/nettiming.h b/code/nettiming.h new file mode 100644 index 00000000..cc34b8bc --- /dev/null +++ b/code/nettiming.h @@ -0,0 +1,195 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#pragma once + +#include "nettime.h" + +#include +#include +#include +#include + + +namespace NetTiming +{ + constexpr Milliseconds MINIMUM_RTO = 100; + constexpr Milliseconds MAXIMUM_RTO = 2000; + constexpr Milliseconds MINIMUM_CONNECTION_TIMEOUT = 2000; + constexpr Milliseconds MAXIMUM_CONNECTION_TIMEOUT = 30000; + constexpr Milliseconds MAXIMUM_PROCESS_MILLISECONDS = 1000; + constexpr Milliseconds MAXIMUM_REPORTED_RTT = UINT16_MAX - 1u; + + constexpr unsigned int MAX_TIMING_PLAYERS = 8; + constexpr unsigned int MINIMUM_TIMING_RUNG = 1; + constexpr unsigned int MAXIMUM_TIMING_RUNG = 10; + constexpr unsigned int INITIAL_TIMING_RUNG = 2; + constexpr unsigned int BOOTSTRAP_FALLBACK_RUNG = 3; + constexpr unsigned int MAXIMUM_MAX_AHEAD = 250; + + constexpr std::uint32_t BOOTSTRAP_REPORT_INTERVAL = 32; + constexpr std::uint32_t BOOTSTRAP_FIRST_EVALUATION = 64; + constexpr std::uint32_t BOOTSTRAP_FINAL_EVALUATION = 128; + constexpr std::uint32_t REPORT_INTERVAL = 128; + constexpr std::uint32_t EVALUATION_INTERVAL = 256; + constexpr std::uint32_t CHANGE_COOLDOWN = 256; + constexpr std::uint32_t REPORT_EXPIRY = 512; + constexpr unsigned int GOOD_EVALUATIONS_REQUIRED = 3; + + class RttEstimator + { + public: + void Reset(void); + bool Add_Sample(Milliseconds round_trip, bool retransmitted = false); + bool Acknowledge(Milliseconds sent_at, unsigned int transmission_count, MillisecondClock const & clock = Default_Clock()); + + bool Has_Sample(void) const {return(Initialized);} + Milliseconds Smoothed_Rtt(void) const {return(SmoothedRtt);} + Milliseconds Rtt_Variation(void) const {return(RttVariation);} + Milliseconds Retransmit_Timeout(void) const {return(RetransmitTimeout);} + + private: + bool Initialized = false; + Milliseconds SmoothedRtt = 0; + Milliseconds RttVariation = 0; + Milliseconds RetransmitTimeout = MINIMUM_RTO; + }; + + Milliseconds Connection_Timeout(Milliseconds smoothed_rtt); + Milliseconds Retransmit_Delay(Milliseconds base_rto, unsigned int prior_retransmissions, Milliseconds maximum_delay = MAXIMUM_RTO); + bool Retransmit_Is_Due(Milliseconds last_send, Milliseconds now, Milliseconds base_rto, + unsigned int prior_retransmissions, Milliseconds maximum_delay = MAXIMUM_RTO); + + struct TimingSettings { + unsigned int FrameSendRate = 2; + unsigned int MaxAhead = 6; + + bool operator==(TimingSettings const &) const = default; + }; + + enum class ConnectionQuality : unsigned char { + Bad, + Poor, + Normal, + Fast, + }; + + TimingSettings Settings_For_Rung(unsigned int rung); + ConnectionQuality Connection_Quality_For_Settings(TimingSettings settings); + bool Timing_Settings_Are_Valid(TimingSettings settings); + bool Timing_Transition_Source_Is_Valid(TimingSettings settings); + std::optional Align_Max_Ahead(unsigned int required, unsigned int frame_send_rate); + TimingSettings Select_Timing_Settings(Milliseconds worst_round_trip, unsigned int target_fps, bool require_headroom = false); + bool Report_Is_Due(std::uint32_t elapsed_frames); + bool Evaluation_Is_Due(std::uint32_t elapsed_frames); + + struct TimingCensus { + unsigned int ActivePlayers = 0; + unsigned int FreshProcessReports = 0; + unsigned int FreshRoundTripReports = 0; + Milliseconds WorstProcessMilliseconds = 0; + Milliseconds WorstRoundTrip = 0; + bool ProcessComplete = true; + bool RoundTripComplete = true; + bool RequiresConservativeTiming = false; + }; + + class TimingReportCensus + { + public: + void Reset(void); + bool Set_Player_Active(unsigned int player, bool active, std::uint32_t frame); + bool Is_Player_Active(unsigned int player) const; + bool Record_Report(unsigned int player, Milliseconds process_milliseconds, std::optional round_trip, std::uint32_t frame); + TimingCensus Inspect(std::uint32_t frame) const; + + private: + struct PlayerReport { + bool Active = false; + bool HasReport = false; + bool HasRoundTrip = false; + bool EverHadRoundTrip = false; + Milliseconds ProcessMilliseconds = 0; + Milliseconds RoundTrip = 0; + std::uint32_t ActiveSinceFrame = 0; + std::uint32_t ReportFrame = 0; + }; + + std::array Reports = {}; + }; + + unsigned int Select_Desired_Frame_Rate(TimingCensus const & census, unsigned int synchronized_fps, unsigned int game_speed_fps); + + struct TimingEvaluation { + TimingSettings Settings; + unsigned int Rung = INITIAL_TIMING_RUNG; + bool Evaluated = false; + bool Changed = false; + }; + + class BalancedTimingPolicy + { + public: + void Reset(std::uint32_t frame = 0); + void Reset_From(TimingSettings settings, std::uint32_t frame); + TimingEvaluation Evaluate(TimingCensus const & census, unsigned int target_fps, std::uint32_t frame); + + unsigned int Current_Rung(void) const {return(CurrentRung);} + TimingSettings Current_Settings(void) const {return(CurrentSettings);} + unsigned int Good_Evaluations(void) const {return(GoodEvaluations);} + bool Is_Bootstrapping(void) const {return(Bootstrapping);} + std::uint32_t Cadence_Origin(void) const {return(BootstrapStartFrame);} + + private: + void Change_To(TimingSettings settings, std::uint32_t frame); + void Finish_Bootstrap(void); + + unsigned int CurrentRung = INITIAL_TIMING_RUNG; + TimingSettings CurrentSettings = {2, 6}; + unsigned int GoodEvaluations = 0; + std::uint32_t BootstrapStartFrame = 0; + std::uint32_t LastEvaluationFrame = 0; + std::uint32_t LastChangeFrame = 0; + bool HasEvaluated = false; + bool HasChanged = false; + bool Bootstrapping = true; + }; + + struct StagedTimingUpdate { + TimingSettings Settings; + unsigned int InitialMaxAhead = 0; + std::uint32_t ActivationFrame = 0; + bool Deferred = false; + }; + + struct TimingTransitionState { + StagedTimingUpdate Plan; + std::uint32_t LastStepFrame = 0; + bool Activated = false; + }; + + struct TimingTransitionAdvance { + TimingSettings Settings; + bool Changed = false; + bool Complete = false; + }; + + enum class ScheduleResult + { + Rejected, + Applied, + Staged, + }; + + std::optional Stage_Timing_Update(TimingSettings current, TimingSettings requested, std::uint32_t event_frame); + std::optional Next_Transition_Max_Ahead(TimingSettings current, TimingSettings requested); + std::optional Advance_Timing_Transition(TimingTransitionState & transition, TimingSettings current, std::uint32_t frame); + bool Timing_Update_Is_Due(std::uint32_t frame, std::uint32_t activation_frame); +} diff --git a/code/queue.cpp b/code/queue.cpp index 4fd3e313..9af77c57 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -125,6 +125,9 @@ #include "msgbox.h" #include "msgloop.h" #include "netdlg.h" +#include "netglobal.h" +#include "netpacket.h" +#include "nettiming.h" #include "netshare.h" #include "opents_build.h" #include "overlay.h" @@ -170,7 +173,10 @@ #include "special.hh" #include +#include +#include #include +#include /********************************** Defines *********************************/ @@ -268,6 +274,23 @@ BasicTimerClass SentFrameSyncTimer; FrameSyncStruct TheirFrameSync[MAX_PLAYERS - 1]; unsigned short SentCommandCount; // # cmds I've sent out +static std::array(NetPacket::DecodeError::COUNT)> NetworkPacketDrops = {}; + + +/// Records and rate-limits one stable event-packet rejection reason. +static void Record_Network_Packet_Drop(NetPacket::DecodeError error) +{ + std::size_t const index = static_cast(error); + if (error == NetPacket::DecodeError::NONE || index >= NetworkPacketDrops.size()) { + return; + } + + unsigned int const count = ++NetworkPacketDrops[index]; + if (count == 1 || (count & (count - 1)) == 0) { + DebugString("Network event packet drop [%s]: %u\n", NetPacket::Error_Name(error), count); + } +} + /********************************* Prototypes *******************************/ @@ -278,10 +301,9 @@ static void Queue_AI_Normal(void); static void Queue_AI_Multiplayer(void); static RetcodeType Wait_For_Players(int first_time, ConnManClass *net, int resend_delta, int dialog_time, int timeout, char *multi_packet_buf, - int my_sent, FrameSyncStruct *their); -static void Generate_Timing_Event(ConnManClass *net, int my_sent); -static void Generate_Real_Timing_Event(ConnManClass *net, int my_sent); -static void Generate_Process_Time_Event(ConnManClass *net); + int multi_packet_max, int my_sent, FrameSyncStruct *their); +static void Generate_Real_Timing_Event(void); +static void Generate_Network_Report_Event(ConnManClass *net); static int Process_Send_Period(ConnManClass *net); //, int init); static int Send_Packets(ConnManClass *net, char *multi_packet_buf, int multi_packet_max, int max_ahead, int my_sent); @@ -297,7 +319,7 @@ static void Stop_Game(bool=false); BOOL CALLBACK Reconnect_Dialog_Proc(HWND window, UINT message, WPARAM wparam, LPARAM lparam); static void Close_Reconnect_Dialog(void); void Kick_Player_Now(ConnManClass *net, int kickee, FrameSyncStruct * their, bool error); -void Cast_Kick_Vote(int kicker, int kickee); +bool Cast_Kick_Vote(int kicker, int kickee); void Multiplayer_Debug_Print(bool noframecheck); //........................................................................... @@ -309,9 +331,6 @@ int Add_Uncompressed_Events(void *buf, int bufsize, int frame_delay, int size, int cap); int Add_Compressed_Events(void *buf, int bufsize, int frame_delay, int size, int cap, int & processed); -static int Breakup_Receive_Packet(void *buf, int bufsize ); -int Extract_Uncompressed_Events(void *buf, int bufsize); -int Extract_Compressed_Events(void *buf, int bufsize); //........................................................................... // DoList management: @@ -466,6 +485,10 @@ bool Queue_Exit(void) *=========================================================================*/ void Queue_AI(void) { + if (Frame >= 0 && (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET)) { + Session.Advance_Network_Timing(static_cast(Frame)); + } + if (Session.Play) { Queue_Playback(); } @@ -707,6 +730,8 @@ static void Queue_AI_Multiplayer(void) // If we've just started a game, or loaded a multiplayer game, we must // wait for all other systems to signal ready. //------------------------------------------------------------------------ + std::uint32_t const network_timing_frame = Frame > 0 + ? static_cast(Frame) - Session.NetworkTimingPolicy.Cadence_Origin() : 0; if (Frame==0 || Session.LoadGame) { //..................................................................... // Initialize static locals @@ -746,7 +771,8 @@ static void Queue_AI_Multiplayer(void) // Wait for the other guys //..................................................................... rc = Wait_For_Players (1, net, _timings[Session.Type].MIXFILE_RESEND_DELTA, _timings[Session.Type].FRAMESYNC_DLG_TIME, - _timings[Session.Type].MIXFILE_TIMEOUT, multi_packet_buf, SentCommandCount, TheirFrameSync); + _timings[Session.Type].MIXFILE_TIMEOUT, multi_packet_buf, multi_packet_max, + SentCommandCount, TheirFrameSync); if (rc != RC_NORMAL) { if (Session.Type == GAME_INTERNET){ @@ -803,35 +829,17 @@ static void Queue_AI_Multiplayer(void) } // end of Frame 0 wait //------------------------------------------------------------------------ - // Adjust connection timing parameters every 128 frames. + // Report sooner during bootstrap, then continue at the normal cadence. //------------------------------------------------------------------------ - else if ( (Frame & 0x007f) == 0) { - // - // If we're using the new spiffy protocol, do proper timing handling. - // If we're the net "master", compute our desired frame rate & new - // 'MaxAhead' value. - // - //if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { - - // - // All systems will transmit their required process time. - // - Generate_Process_Time_Event(net); - - //} else { - // // - // // For the older protocols, do the old broken timing handling. - // // - // Generate_Timing_Event(net, SentCommandCount); - // } + else if (Frame > 0 && NetTiming::Report_Is_Due(network_timing_frame)) { + Generate_Network_Report_Event(net); } - // - // The game "host" will transmit timing adjustment events. - // - if (Session.Am_I_Master() && (Session.PrecalcMaxAhead != 0 || Session.PrecalcDesiredFrameRate != 0 || !(char)Frame)) { - Generate_Real_Timing_Event(net, SentCommandCount); + // The deterministic master evaluates bootstrap and steady-state reports. + int const timing_master = Session.Master_Player_ID(); + if (PlayerPtr != NULL && PlayerPtr->HeapID == timing_master && Frame > 0 && NetTiming::Evaluation_Is_Due(network_timing_frame)) { + Generate_Real_Timing_Event(); } //------------------------------------------------------------------------ @@ -869,7 +877,7 @@ static void Queue_AI_Multiplayer(void) TIMER_SECOND, /// (Session.MaxAhead << 3), std::max((int) net->Response_Time() * 3, _timings[Session.Type].FRAMESYNC_TIMEOUT ), _timings[Session.Type].MIXFILE_TIMEOUT, - multi_packet_buf, SentCommandCount, TheirFrameSync); + multi_packet_buf, multi_packet_max, SentCommandCount, TheirFrameSync); if (rc != RC_NORMAL) { DebugString("Wait_For_Players returned %d\n", rc); @@ -1038,7 +1046,7 @@ void Wait_For_End_Of_Queue(void) message_limit = MAX_EVENTS; while ( (messages_this_loop++ < message_limit) && - net->Get_Private_Message (multi_packet_buf, &packetlen, &id) ) { + net->Get_Private_Message (multi_packet_buf, multi_packet_max, &packetlen, &id) ) { Keyboard->Check(); @@ -1134,7 +1142,7 @@ void Wait_For_End_Of_Queue(void) static int SyncWaitElapsed; /// how long Wait_For_Players has been waiting static RetcodeType Wait_For_Players(int first_time, ConnManClass *net, int resend_delta, int dialog_time, int timeout, char *multi_packet_buf, - int my_sent, FrameSyncStruct *their) + int multi_packet_max, int my_sent, FrameSyncStruct *their) { //........................................................................ // Variables for sending, receiving & parsing packets: @@ -1243,7 +1251,12 @@ static RetcodeType Wait_For_Players(int first_time, ConnManClass *net, * .................................................................. */ for (int i = 0; i < Session.Players.Count(); i++) { - int votes = Session.KickVoteCount[Session.Players[i]->Player.ID]; + int const player_id = Session.Players[i]->Player.ID; + if (player_id < 0 || player_id >= MAX_PLAYERS) { + continue; + } + + int votes = Session.KickVoteCount[player_id]; if (votes >= Session.Players.Count() - 1) { DebugString("Kicking player %s from the game due to %d votes\n", Session.Players[i]->Name, votes); if (i == 0) { @@ -1253,7 +1266,7 @@ static RetcodeType Wait_For_Players(int first_time, ConnManClass *net, } return(RC_CANCEL); } - Kick_Player_Now(net, net->Connection_Index(Session.Players[i]->Player.ID), their, true); + Kick_Player_Now(net, net->Connection_Index(player_id), their, true); } } @@ -1308,7 +1321,7 @@ static RetcodeType Wait_For_Players(int first_time, ConnManClass *net, message_limit = MAX_EVENTS; while ( (messages_this_loop++ < message_limit) && - net->Get_Private_Message (multi_packet_buf, &packetlen, &id) ) { + net->Get_Private_Message (multi_packet_buf, multi_packet_max, &packetlen, &id) ) { Keyboard->Check(); @@ -1438,322 +1451,76 @@ static RetcodeType Wait_For_Players(int first_time, ConnManClass *net, } // end of Wait_For_Players -/*************************************************************************** - * Generate_Timing_Event -- computes & queues a RESPONSE_TIME event * - * * - * This routine adjusts the connection timing on the local system; it also * - * optionally generates a RESPONSE_TIME event, to tell all systems to * - * dynamically adjust the current MaxAhead value. This allows both the * - * MaxAhead & the connection retry logic to have dynamic timing, to adjust * - * to varying line conditions. * - * * - * INPUT: * - * net ptr to connection manager * - * my_sent # commands I've sent out so far * - * * - * OUTPUT: * - * none. * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 11/21/1995 BRR : Created. * - *=========================================================================*/ -static void Generate_Timing_Event(ConnManClass *net, int my_sent) +/// Maps the validated game-speed setting to its historical frame-rate target. +static int Game_Speed_Frame_Rate(void) { - unsigned int resp_time; // connection response time, in ticks - EventClass ev; - - //------------------------------------------------------------------------ - // Measure the current connection response time. This time will be in - // 60ths of a second, and represents full round-trip time of a packet. - // To convert to one-way packet time, divide by 2; to convert to game - // frames, divide again by 4, assuming a game rate of 15 fps. - //------------------------------------------------------------------------ - resp_time = net->Response_Time(); - - //------------------------------------------------------------------------ - // Adjust my connection retry timing; only do this if I've sent out more - // than 5 commands, so I know I have a measure of the response time. - //------------------------------------------------------------------------ - if (my_sent > 5) { - - net->Set_Timing (resp_time + TIMER_SECOND / 6, -1, (resp_time * 4) + TIMER_SECOND / 4); - - //..................................................................... - // If I'm the network "master", I'm also responsible for updating the - // MaxAhead value on all systems, so do that here too. - //..................................................................... - if (Session.Am_I_Master()) { - ev.Type = EventClass::RESPONSE_TIME; - //.................................................................. - // For multi-frame compressed events, the MaxAhead must be an even - // multiple of the FrameSendRate. - //.................................................................. - if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { - ev.Data.FrameInfo.Delay = std::max( ((((resp_time / 8) + - (Session.FrameSendRate - 1)) / Session.FrameSendRate) * - Session.FrameSendRate), (Session.FrameSendRate * 2) ); - } - //.................................................................. - // For sending packets every frame, just use the 1-way connection - // response time. - //.................................................................. - else { - if (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET) { - ev.Data.FrameInfo.Delay = std::max( (resp_time / 8), - NETWORK_MIN_MAX_AHEAD ); - } - } - OutList.push_back(ev); - } + switch (Options.GameSpeed) { + case 0: return(60); + case 1: return(45); + case 2: return(30); + case 3: return(20); + case 4: return(15); + case 5: return(12); + case 6: return(10); + default: return(60); } - -} // end of Generate_Timing_Event +} -/*************************************************************************** - * Generate_Real_Timing_Event -- Generates a TIMING event * - * * - * INPUT: * - * net ptr to connection manager * - * my_sent # commands I've sent out so far * - * * - * OUTPUT: * - * none. * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 07/02/1996 BRR : Created. * - *=========================================================================*/ -static void Generate_Real_Timing_Event(ConnManClass *net, int my_sent) +/// Queues timing selected from the synchronized report census. +static void Generate_Real_Timing_Event(void) { - unsigned int resp_time; // connection response time, in ticks - EventClass ev; - int highest_ticks; - int i; - int specified_frame_rate; - int maxahead; - unsigned char frame_send_rate; - - if (Session.PrecalcMaxAhead != 0 || Session.PrecalcDesiredFrameRate != 0) { - DebugString("Sending precalculated network timings on frame %d\n", Frame); - - ev.Type = EventClass::TIMING; - ev.Data.Timing.DesiredFrameRate = Session.PrecalcDesiredFrameRate; - ev.Data.Timing.MaxAhead = Session.PrecalcMaxAhead; - ev.Data.Timing.FrameSendRate = Session.PrecalcDesiredFrameRate > 30u ? 10 : 5; - - OutList.push_back(ev); - - Session.PrecalcMaxAhead = 0; - Session.PrecalcDesiredFrameRate = 0; + EventClass event; + memset(&event, 0, sizeof(event)); + if (Frame < 0) { return; } - - // - // If we haven't sent out at least 5 guaranteed-delivery packets, don't - // bother trying to measure our connection response time; just return. - // - if (my_sent < 5) { + unsigned int const frame = static_cast(Frame); + int const master_id = Session.Master_Player_ID(); + if (PlayerPtr == NULL || PlayerPtr->HeapID != master_id) { return; } + Session.Prepare_Network_Timing_Master(master_id, frame); - // - // Find the highest processing time we have stored - // - highest_ticks = 0; - for (i = 0; i < Session.Players.Count(); i++) { - - // - // If we haven't heard from all systems yet, bail out. - // - if (Session.Players[i]->Player.ProcessTime == -1) { - return; - } - if (Session.Players[i]->Player.ProcessTime > highest_ticks) { - highest_ticks = Session.Players[i]->Player.ProcessTime; - } - } - - // - // Compute our "desired" frame rate as the lower of: - // - What the user has dialed into the options screen - // - What we're really able to run at - // - if (highest_ticks == 0) { - Session.DesiredFrameRate = 60; - } else { - Session.DesiredFrameRate = std::max(1, 1000 / highest_ticks); - } - - switch (Options.GameSpeed) { - case 0: - specified_frame_rate = 60; - break; - case 1: - specified_frame_rate = 45; - break; - default: - specified_frame_rate = 60 / Options.GameSpeed; - break; - } - - Session.DesiredFrameRate = std::min(Session.DesiredFrameRate, specified_frame_rate); - - // - // Measure the current connection response time. This time will be in - // 60ths of a second, and represents full round-trip time of a packet. - // To convert to one-way packet time, divide by 2; to convert to game - // frames, ....uh.... - // - resp_time = net->Response_Time(); - frame_send_rate = Session.FrameSendRate; - if (Session.Type == GAME_INTERNET) { - frame_send_rate = Session.DesiredFrameRate > 30 ? 10 : 5; - } - - int fudge = 0; - if (resp_time != 0) { - switch (Session.LatencyFudge) { - case 0: - DebugString("Response time = %d\n", resp_time); - break; - case 1: - resp_time += resp_time >> 1; - fudge = 10; - DebugString("Response time = %d\n", resp_time); - break; - case 2: - resp_time *= 2; - fudge = 20; - DebugString("Response time = %d\n", resp_time); - break; - case 3: - resp_time *= 3; - fudge = 30; - DebugString("Response time = %d\n", resp_time); - break; - } + NetTiming::TimingCensus const census = Session.Network_Timing_Census(frame); + unsigned int const desired_frame_rate = NetTiming::Select_Desired_Frame_Rate( + census, static_cast(std::clamp(Session.DesiredFrameRate, 1, 60)), static_cast(Game_Speed_Frame_Rate())); + NetTiming::TimingEvaluation const evaluation = Session.Evaluate_Network_Timing(census, desired_frame_rate, frame); + if (!evaluation.Changed && desired_frame_rate == static_cast(Session.DesiredFrameRate)) { + return; } - // - // Compute our new 'MaxAhead' value, based upon the response time of our - // connection and our desired frame rate. - // 'MaxAhead' in frames is: - // - // (resp_time / 2 ticks) * (1 sec/60 ticks) * (n Frames / sec) - // - // resp_time is divided by 2 because, as reported, it represents a round- - // trip, and we only want to use a one-way trip. - // - maxahead = frame_send_rate + (resp_time * Session.DesiredFrameRate) / (2 * TIMER_SECOND); - - // - // Now, we have to round 'maxahead' so it's an even multiple of our - // send rate. It also must be at least thrice the FrameSendRate. - // (Isn't "thrice" a cool word?) - // - maxahead = ((maxahead + fudge - 1) / frame_send_rate) * frame_send_rate; - maxahead = std::max(maxahead, (int)frame_send_rate * 3); - maxahead = std::min(maxahead, frame_send_rate * ((frame_send_rate + 249) / frame_send_rate)); - - ev.Type = EventClass::TIMING; - ev.Data.Timing.DesiredFrameRate = Session.DesiredFrameRate; - ev.Data.Timing.MaxAhead = maxahead + (Scen->Special.IsFogOfWar ? 10 : 0); - ev.Data.Timing.FrameSendRate = frame_send_rate; - - OutList.push_back(ev); - - // - // Adjust my connection retry timing. These values set the retry timeout - // to just over one round-trip time, the 'maxretries' to -1, and the - // connection timeout to allow for about 4 retries. - // - if (Session.Players.Count() == 1 && resp_time == 0) { - resp_time = TIMER_SECOND / 2; - } - net->Set_Timing (resp_time + TIMER_SECOND / 6, -1, std::max(2 * TIMER_SECOND, (resp_time*8) + TIMER_SECOND / 4), false); + event.Type = EventClass::TIMING; + event.Data.Timing.DesiredFrameRate = desired_frame_rate; + event.Data.Timing.MaxAhead = evaluation.Settings.MaxAhead; + event.Data.Timing.FrameSendRate = evaluation.Settings.FrameSendRate; + OutList.push_back(event); } -/*************************************************************************** - * Generate_Process_Time_Event -- Generates a PROCESS_TIME event * - * * - * INPUT: * - * net ptr to connection manager * - * * - * OUTPUT: * - * none. * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 07/02/1996 BRR : Created. * - *=========================================================================*/ -static void Generate_Process_Time_Event(ConnManClass *net) +/// Queues the local process-time and worst-RTT report. +static void Generate_Network_Report_Event(ConnManClass *net) { - EventClass ev; - int avgticks; - unsigned int resp_time; // connection response time, in ticks - - // - // Measure the current connection response time. This time will be in - // 60ths of a second, and represents full round-trip time of a packet. - // To convert to one-way packet time, divide by 2; to convert to game - // frames, ....uh.... - // - resp_time = net->Response_Time(); - - // - // Adjust my connection retry timing. These values set the retry timeout - // to just over one round-trip time, the 'maxretries' to -1, and the - // connection timeout to allow for about 4 retries. - // - switch (Session.LatencyFudge) { - case 0: - DebugString("Response time = %d\n", resp_time); - break; - case 1: - resp_time += resp_time >> 1; - DebugString("Response time = %d\n", resp_time); - break; - case 2: - resp_time *= 2; - DebugString("Response time = %d\n", resp_time); - break; - case 3: - resp_time *= 3; - DebugString("Response time = %d\n", resp_time); - break; - } - net->Set_Timing (resp_time + TIMER_SECOND / 6, -1, std::max(2 * TIMER_SECOND, (resp_time * 8) + TIMER_SECOND / 4), false); - - if (IsMono) { - MonoClass::Enable(); - Mono_Set_Cursor(0,23); - Mono_Printf("Processing Ticks:%03d Frames:%03d\n", Session.ProcessTicks,Session.ProcessFrames); - MonoClass::Disable(); + if (Session.ProcessFrames <= 0) { + return; } - avgticks = Session.ProcessTicks / Session.ProcessFrames; + int const average_process_milliseconds = std::clamp(Session.ProcessTicks / Session.ProcessFrames, 0, + static_cast(NetTiming::MAXIMUM_PROCESS_MILLISECONDS)); + std::optional const worst_round_trip = net->Worst_Local_Round_Trip_MS(); - ev.Type = EventClass::PROCESS_TIME; - ev.Data.ProcessTime.AverageTicks = avgticks; - OutList.push_back(ev); + EventClass event; + memset(&event, 0, sizeof(event)); + event.Type = EventClass::NETWORK_REPORT; + event.Data.NetworkReport.AverageProcessMilliseconds = static_cast(average_process_milliseconds); + event.Data.NetworkReport.WorstRoundTripMilliseconds = !worst_round_trip || *worst_round_trip >= EventClass::NETWORK_RTT_UNAVAILABLE + ? EventClass::NETWORK_RTT_UNAVAILABLE : static_cast(*worst_round_trip); + OutList.push_back(event); Session.ProcessTicks = 0; Session.ProcessFrames = 0; - - if (Session.Type == GAME_INTERNET && (Frame & 0x3FF) == 0) { - net->Reset_Response_Time(false); - } } @@ -1987,26 +1754,37 @@ static void Send_FrameSync(ConnManClass *net, int cmd_count) static RetcodeType Process_Receive_Packet(ConnManClass *net, char *multi_packet_buf, int id, int packetlen, FrameSyncStruct *their, BasicTimerClass *timer) { - EventClass *event; - int index; RetcodeType retcode = RC_NORMAL; - int i; - int frame; + NetPacket::Encoding const encoding = Session.CommProtocol == COMM_PROTOCOL_SINGLE_NO_COMP + ? NetPacket::Encoding::UNCOMPRESSED : NetPacket::Encoding::COMPRESSED; + std::span const packet(reinterpret_cast(multi_packet_buf), packetlen > 0 ? static_cast(packetlen) : 0); + // Validate the complete packet before mutating peer state or DoList. + NetPacket::DecodeResult decoded = NetPacket::Decode_Event_Packet(packet, encoding, id); + if (!decoded.Succeeded() || !decoded.HasEnvelope) { + Record_Network_Packet_Drop(decoded.Succeeded() ? NetPacket::DecodeError::INVALID_PREFIX : decoded.Failure.Code); + return(RC_NORMAL); + } - //------------------------------------------------------------------------ - // Get an event ptr to the incoming message - //------------------------------------------------------------------------ - event = (EventClass *)multi_packet_buf; + EventClass const & event = decoded.Envelope; + std::optional const reported_frame = NetPacket::Compute_Reported_Frame(event.Frame, event.Data.FrameInfo.Delay, Frame, NetTiming::MAXIMUM_MAX_AHEAD); + if (!reported_frame) { + Record_Network_Packet_Drop(NetPacket::DecodeError::INVALID_FRAME_ARITHMETIC); + return(RC_NORMAL); + } //------------------------------------------------------------------------ // Get the index of the sender //------------------------------------------------------------------------ - index = net->Connection_Index(id); + int const index = net->Connection_Index(id); + if (index < 0 || index >= net->Num_Connections()) { + Record_Network_Packet_Drop(NetPacket::DecodeError::INVALID_CONNECTION); + return(RC_NORMAL); + } //------------------------------------------------------------------------ // Compute the other player's frame # (at the time this packet was sent) //------------------------------------------------------------------------ - frame = (event->Frame - event->Data.FrameInfo.Delay); + int const frame = static_cast(*reported_frame); if (their[index].frame < frame) { //..................................................................... @@ -2032,29 +1810,31 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, // Extract the other player's CommandCount. This count will include // the commands in this packet, if there are any. //------------------------------------------------------------------------ - if (event->Data.FrameInfo.CommandCount > their[index].sent) { + if (event.Data.FrameInfo.CommandCount > their[index].sent) { - if ( abs((int)(their[index].sent - event->Data.FrameInfo.CommandCount)) > 500) { + unsigned int const command_count_delta = static_cast(event.Data.FrameInfo.CommandCount) - their[index].sent; + if (command_count_delta > 500) { FILE *fp; fp = fopen("badcount.txt","wt"); if (fp) { - fprintf(fp,"Event Type:%s\n",EventClass::EventNames[event->Type]); + fprintf(fp,"Event Type:%s\n",EventClass::EventNames[event.Type]); fprintf(fp,"Frame:%d ID:%d IsExec:%d\n", - event->Frame, - event->ID, - event->IsExecuted); - if (event->Type != EventClass::FRAMEINFO) { + event.Frame, + event.ID, + event.IsExecuted); + if (event.Type != EventClass::FRAMEINFO) { fprintf(fp,"!!!!!!!!! bad bug, bad bug !!!!!!!!!\n");//fprintf(fp,"Wrong Event Type!\n"); } else { fprintf(fp,"CRC:%x CommandCount:%d Delay:%d\n", - event->Data.FrameInfo.CRC, - event->Data.FrameInfo.CommandCount, - event->Data.FrameInfo.Delay); + event.Data.FrameInfo.CRC, + event.Data.FrameInfo.CommandCount, + event.Data.FrameInfo.Delay); } + fclose(fp); } } - their[index].sent = event->Data.FrameInfo.CommandCount; + their[index].sent = event.Data.FrameInfo.CommandCount; } //------------------------------------------------------------------------ @@ -2063,28 +1843,29 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, // - Increment our commands-received counter by the number of non- // FRAMEINFO packets received //------------------------------------------------------------------------ - if (event->Type != EventClass::FRAMESYNC) { - //..................................................................... - // Break up the packet into its component events. - //..................................................................... - i = Breakup_Receive_Packet( multi_packet_buf, packetlen); - //..................................................................... - // Compute the actual # commands in the packet by subtracting off the - // FRAMEINFO event - //..................................................................... - if ( (event->Type==EventClass::FRAMEINFO) && (i > 0)) { - i--; + if (event.Type != EventClass::FRAMESYNC) { + for (NetPacket::DecodedEvent const & source : decoded.Events) { + EventClass queued = source.Event; + if (queued.Type == EventClass::ADDPLAYER) { + queued.Data.Variable.Pointer = NULL; + if (!source.AddPlayerData.empty()) { + queued.Data.Variable.Pointer = new char[source.AddPlayerData.size()]; + memcpy(queued.Data.Variable.Pointer, source.AddPlayerData.data(), source.AddPlayerData.size()); + } + } + DoList.push_back(queued); } - their[index].recv += (i & 0xFFFF); /// This mask should not be necessary. + std::size_t const commands = decoded.Events.empty() ? 0 : decoded.Events.size() - 1; + their[index].recv += static_cast(commands); } //------------------------------------------------------------------------ // If the event was a FRAMESYNC packet, there will be no commands to add, // but we must check the ScenarioCRC value. //------------------------------------------------------------------------ - else if (event->Type == EventClass::FRAMESYNC) { - if (event->Data.FrameInfo.CRC != ScenarioCRC) { + else if (event.Type == EventClass::FRAMESYNC) { + if (event.Data.FrameInfo.CRC != ScenarioCRC) { return(RC_SCENARIO_MISMATCH); } their[index].timing = *timer; @@ -2417,7 +2198,96 @@ void Draw_Sync_Bars(HWND window) } } -void Cast_Kick_Vote(int kicker, int kickee); +bool Cast_Kick_Vote(int kicker, int kickee); + + +/// Finds an active session player by stable ID. +static NodeNameType * Current_Player_From_ID(int player) +{ + if (player < 0 || player >= MAX_PLAYERS) { + return(NULL); + } + + for (int index = 0; index < Session.Players.Count(); index++) { + NodeNameType * current = Session.Players[index]; + if (current != NULL && current->Player.ID == player) { + return(current); + } + } + return(NULL); +} + + +/// Tests whether a player has already cast a counted kick vote. +static bool Kick_Vote_Already_Cast(int kicker, int kickee) +{ + if (kicker < 0 || kicker >= MAX_PLAYERS || kickee < 0 || kickee >= MAX_PLAYERS) { + return(false); + } + + int const votes = Session.KickVoteCount[kickee]; + if (votes < 0 || votes > MAX_PLAYERS) { + return(false); + } + for (int index = 0; index < votes; index++) { + if (Session.KickVoteWho[kickee][index] == kicker) { + return(true); + } + } + return(false); +} + + +/// Tests whether the pending queue already contains a kick vote. +static bool Kick_Proposal_Already_Pending(int kicker, int kickee) +{ + for (int index = 0; index < Session.KickProposals.Count(); index++) { + GlobalPacketType const * proposal = Session.KickProposals[index]; + if (proposal != NULL && proposal->Kick.KickerID == static_cast(kicker) && + proposal->Kick.KickeeID == static_cast(kickee)) { + return(true); + } + } + return(false); +} + + +/// Removes a departing player from pending and counted kick votes. +void Forget_Kick_Player(int player) +{ + if (player < 0 || player >= MAX_PLAYERS) { + return; + } + + for (int index = Session.KickProposals.Count() - 1; index >= 0; index--) { + GlobalPacketType * proposal = Session.KickProposals[index]; + if (proposal == NULL || proposal->Kick.KickerID == static_cast(player) || + proposal->Kick.KickeeID == static_cast(player)) { + delete proposal; + Session.KickProposals.Delete_Index(index); + } + } + + for (int target = 0; target < MAX_PLAYERS; target++) { + int retained[MAX_PLAYERS]; + int retained_count = 0; + int const votes = Session.KickVoteCount[target]; + if (target != player && votes >= 0 && votes <= MAX_PLAYERS) { + for (int index = 0; index < votes; index++) { + int const voter = Session.KickVoteWho[target][index]; + if (voter != player && Current_Player_From_ID(voter) != NULL) { + retained[retained_count++] = voter; + } + } + } + + memset(Session.KickVoteWho[target], 0xFF, sizeof(Session.KickVoteWho[target])); + if (retained_count > 0) { + memcpy(Session.KickVoteWho[target], retained, retained_count * sizeof(retained[0])); + } + Session.KickVoteCount[target] = retained_count; + } +} /// /// Trims the message list box and scrolls it to the end. @@ -2466,71 +2336,93 @@ void Propose_Kick_Player(HWND window, int id) return; } + int const kicker = Session.Players[0]->Player.ID; + int const kickee = Session.Players[id]->Player.ID; + if (Current_Player_From_ID(kicker) == NULL || Current_Player_From_ID(kickee) == NULL || Kick_Vote_Already_Cast(kicker, kickee)) { + return; + } + GlobalPacketType gpacket; - gpacket.Command = NET_PROPOSE_KICK; - strncpy(gpacket.Name, Session.Players[0]->Name, ARRAY_SIZE(gpacket.Name)); - gpacket.Kick.KickerID = Session.Players[0]->Player.ID; - gpacket.Kick.KickeeID = Session.Players[id]->Player.ID; + NetGlobal::Initialize_Packet(gpacket, NET_PROPOSE_KICK); + strncpy(gpacket.Name, Session.Players[0]->Name, ARRAY_SIZE(gpacket.Name) - 1); + gpacket.Name[ARRAY_SIZE(gpacket.Name) - 1] = '\0'; + gpacket.Kick.KickerID = static_cast(kicker); + gpacket.Kick.KickeeID = static_cast(kickee); for (int i = 1; i < Session.Players.Count(); i++) { DebugString("Sending kick proposal to %s\n", Session.Players[i]->Name); Ipx.Send_Global_Message(&gpacket, sizeof(gpacket), 1, &Session.Players[i]->Address); } - Cast_Kick_Vote(Session.Players[0]->Player.ID, Session.Players[id]->Player.ID); + Cast_Kick_Vote(kicker, kickee); } -/// -/// Handles a kick proposal arriving from another player. -/// The packet is copied and queued up on the session, so that the wait-for-players loop -/// can act on the proposal when it next gets the chance. -/// -/// The global packet that carries the kick proposal. -void Kick_Packet_Received(GlobalPacketType & packet, IPXAddressClass & address) +/// Queues a bounded, canonical kick proposal from a session member. +NetGlobal::DecodeError Kick_Packet_Received(int kicker, int kickee) { - GlobalPacketType *newpacket = new GlobalPacketType; - memcpy(newpacket, &packet, sizeof(*newpacket)); - Session.KickProposals.Add(newpacket); + NodeNameType * kicker_player = Current_Player_From_ID(kicker); + NodeNameType * kickee_player = Current_Player_From_ID(kickee); + if (kicker_player == NULL || kickee_player == NULL) { + return(NetGlobal::DecodeError::INVALID_KICK_PLAYER); + } + if (kicker == kickee) { + return(NetGlobal::DecodeError::SELF_KICK); + } + if (Kick_Vote_Already_Cast(kicker, kickee) || Kick_Proposal_Already_Pending(kicker, kickee)) { + return(NetGlobal::DecodeError::DUPLICATE_KICK_PROPOSAL); + } + if (Session.KickProposals.Count() >= MAX_PLAYERS * MAX_PLAYERS) { + return(NetGlobal::DecodeError::KICK_PROPOSAL_QUEUE_FULL); + } + + GlobalPacketType * newpacket = new GlobalPacketType; + NetGlobal::Initialize_Packet(*newpacket, NET_PROPOSE_KICK); + strncpy(newpacket->Name, kicker_player->Name, ARRAY_SIZE(newpacket->Name) - 1); + newpacket->Name[ARRAY_SIZE(newpacket->Name) - 1] = '\0'; + newpacket->Kick.KickerID = static_cast(kicker); + newpacket->Kick.KickeeID = static_cast(kickee); + if (!Session.KickProposals.Add(newpacket)) { + delete newpacket; + return(NetGlobal::DecodeError::KICK_PROPOSAL_QUEUE_FULL); + } + + return(NetGlobal::DecodeError::NONE); } -/// -/// Records a vote to kick a player out of the game. -/// A player gets only the one vote against any given victim, so a repeat vote is quietly -/// discarded. In a network game the vote is announced in the reconnect dialog's message -/// list, so everyone can see who wants whom gone. -/// -/// Player ID of the one casting the vote. -/// Player ID of the one being voted against. -void Cast_Kick_Vote(int kicker, int kickee) +/// Records one bounded, deduplicated kick vote. +bool Cast_Kick_Vote(int kicker, int kickee) { char buffer[256]; + NodeNameType * kicker_player = Current_Player_From_ID(kicker); + NodeNameType * kickee_player = Current_Player_From_ID(kickee); + if (kicker_player == NULL || kickee_player == NULL || kicker == kickee) { + return(false); + } + if (Kick_Vote_Already_Cast(kicker, kickee)) { + return(false); + } int votes = Session.KickVoteCount[kickee]; - for (int i = 0; i < votes; i++) { - if (Session.KickVoteWho[kickee][i] == kicker) { - return; - } + if (votes < 0 || votes >= MAX_PLAYERS) { + return(false); } Session.KickVoteWho[kickee][votes] = kicker; Session.KickVoteCount[kickee]++; if (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET) { - char const * kicker_name = Ipx.Connection_Name(kicker); - if (kicker_name == NULL) { - kicker_name = Session.Players[0]->Name; - } - - DebugString("Player %s votes to kick player %s from the game\n", kicker_name, Ipx.Connection_Name(kickee)); - sprintf(buffer, Fetch_String(TXT_RECONNECT_KICK_RECEIVED), kicker_name, Ipx.Connection_Name(kickee)); + DebugString("Player %s votes to kick player %s from the game\n", kicker_player->Name, kickee_player->Name); + snprintf(buffer, sizeof(buffer), Fetch_String(TXT_RECONNECT_KICK_RECEIVED), kicker_player->Name, kickee_player->Name); HWND topwindow = WS_Top_Window(); HWND listbox = GetDlgItem(topwindow, IDC_DISCONNECT_MESSAGES); ListBox_AddString(listbox, buffer); ListBox_Trim(listbox); } + + return(true); } @@ -2724,10 +2616,9 @@ void Kick_Player_Now(ConnManClass *net, int kickee, FrameSyncStruct * their, boo for (int i = kickee; i < net->Num_Connections() - 1; i++) { their[i] = their[i+1]; - Session.KickVoteCount[i] = Session.KickVoteCount[i+1]; - memcpy(Session.KickVoteWho[i], Session.KickVoteWho[i+1], sizeof(Session.KickVoteWho[i])); } + Forget_Kick_Player(id); Destroy_Connection(id, error); } @@ -3352,305 +3243,6 @@ static int Add_Compressed_Events(void *buf, int bufsize, int frame_delay, } // end of Add_Compressed_Events -/*************************************************************************** - * Breakup_Receive_Packet -- Splits a big packet into little ones. * - * * - * INPUT: * - * buf buffer to break up * - * bufsize length of buffer * - * * - * OUTPUT: * - * # events added to queue, -1 if fatal error (queue is full) * - * (return value includes any FRAMEINFO packets encountered; * - * FRAMESYNC's are ignored) * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 11/21/1995 BRR : Created. * - *=========================================================================*/ -static int Breakup_Receive_Packet(void *buf, int bufsize ) -{ - int count = 0; - - /* - ** is there enough leftover for another record - */ - switch (Session.CommProtocol) { - case (COMM_PROTOCOL_SINGLE_NO_COMP): - count = Extract_Uncompressed_Events(buf, bufsize); - break; - - default: - count = Extract_Compressed_Events(buf, bufsize); - break; - } - - return(count); - -} /* end of Breakup_Receive_Packet */ - - -/*************************************************************************** - * Extract_Uncompressed_Events -- extracts events from a packet * - * * - * INPUT: * - * buf buffer containing events to extract * - * bufsize length of 'buf' * - * * - * OUTPUT: * - * # events extracted * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 11/21/1995 DRD : Created. * - *=========================================================================*/ -static int Extract_Uncompressed_Events(void *buf, int bufsize) -{ - int count = 0; - int pos = 0; - int leftover = bufsize; - EventClass *event; - - //------------------------------------------------------------------------ - // Loop until there are no more events in the packet - //------------------------------------------------------------------------ - while (leftover >= sizeof(EventClass) ) { - - event = (EventClass *)(((char *)buf) + pos); - - //..................................................................... - // add event to the DoList, only if it's not a FRAMESYNC - // (but FRAMEINFO's do get added.) - //..................................................................... - if (event->Type != EventClass::FRAMESYNC) { - event->IsExecuted = 0; - - //.................................................................. - // Special processing for variable-sized events - //.................................................................. - if (event->Type == EventClass::ADDPLAYER) { - event->Data.Variable.Pointer = new char[event->Data.Variable.Size]; - memcpy (event->Data.Variable.Pointer, - ((char *)buf) + sizeof(EventClass), - event->Data.Variable.Size); - - pos += event->Data.Variable.Size; - leftover -= event->Data.Variable.Size; - } - - DoList.push_back( *event ); - - //.................................................................. - // Keep count of how many events we add to the queue - //.................................................................. - count++; - } - - //..................................................................... - // Point to the next position in the buffer; decrement our 'leftover' - //..................................................................... - pos += sizeof(EventClass); - leftover -= sizeof(EventClass); - } - - return(count); - -} // end of Extract_Uncompressed_Events - - -/*************************************************************************** - * Extract_Compressed_Events -- extracts events from a packet * - * * - * INPUT: * - * buf buffer containing events to extract * - * bufsize length of 'buf' * - * * - * OUTPUT: * - * # events extracted * - * * - * WARNINGS: * - * none. * - * * - * HISTORY: * - * 11/21/1995 DRD : Created. * - *=========================================================================*/ -static int Extract_Compressed_Events(void *buf, int bufsize) -{ - int pos = 0; // current buffer parsing position - int leftover = bufsize; // # bytes left to process - EventClass *event; // event ptr for parsing buffer - int count = 0; // # events processed - int datasize = 0; // size of data to copy - EventClass eventdata; // stores Frame, ID, etc - unsigned char numunits = 0; // # units stored in compressed MegaMissions - - //------------------------------------------------------------------------ - // Clear work event structure - //------------------------------------------------------------------------ - memset (&eventdata, 0, sizeof(EventClass)); - - //------------------------------------------------------------------------ - // Assume the first event is a FRAMEINFO event - // Init 'datasize' to the amount of data to copy, minus the EventType value - // For the 1st packet only, this will include all info before the Data - // union, plus the size of the FrameInfo structure, minus the EventType size. - //------------------------------------------------------------------------ - datasize = (offsetof(EventClass, Data) + - size_of(EventClass, Data.FrameInfo)) - size_of(EventClass, Type); - event = (EventClass *)(((char *)buf) + pos); - - while ((unsigned)leftover >= (datasize + size_of(EventClass, Type)) ) { - - //..................................................................... - // add event to the DoList, only if it's not a FRAMESYNC - // (but FRAMEINFO's do get added.) - //..................................................................... - if (event->Type != EventClass::FRAMESYNC) { - //.................................................................. - // initialize the common data from the FRAMEINFO event - // keeping IsExecuted 0 - //.................................................................. - if (event->Type == EventClass::FRAMEINFO) { - eventdata.Frame = event->Frame; - eventdata.ID = event->ID; - - //............................................................... - // Adjust position past the common data - //............................................................... - pos += (offsetof(EventClass, Data) - - size_of(EventClass, Type)); - leftover -= (offsetof(EventClass, Data) - - size_of(EventClass, Type)); - } - //.................................................................. - // if MEGAMISSION event get the number of units (events to generate) - //.................................................................. - else if (event->Type == EventClass::MEGAMISSION) { - numunits = *(((unsigned char *)buf) + pos + sizeof(eventdata.Type)); - pos += sizeof(numunits); - leftover -= sizeof(numunits); - } - - //.................................................................. - // clear the union data portion of the event - //.................................................................. - memset (&eventdata.Data, 0, sizeof(eventdata.Data)); - eventdata.Type = event->Type; - datasize = EventClass::EventLength[ eventdata.Type ]; - - switch (eventdata.Type) { - case (EventClass::RESPONSE_TIME): - memcpy ( &eventdata.Data.FrameInfo.Delay, - ((char *)buf) + pos + size_of(EventClass, Type), - datasize ); - break; - - case (EventClass::ADDPLAYER): - - memcpy ( &eventdata.Data.Variable.Size, - ((char *)buf) + pos + size_of(EventClass, Type), - datasize ); - - eventdata.Data.Variable.Pointer = - new char[eventdata.Data.Variable.Size]; - memcpy (eventdata.Data.Variable.Pointer, - ((char *)buf) + pos + size_of(EventClass, Type) + datasize, - eventdata.Data.Variable.Size); - - pos += eventdata.Data.Variable.Size; - leftover -= eventdata.Data.Variable.Size; - - break; - - case (EventClass::MEGAMISSION): - memcpy ( &eventdata.Data.MegaMission, - ((char *)buf) + pos + size_of(EventClass, Type), - datasize ); - - if (numunits > 1) { - pos += (datasize + size_of(EventClass, Type)); - leftover -= (datasize + size_of(EventClass, Type)); - datasize = sizeof(eventdata.Data.MegaMission.Whom); - - while (numunits) { - - DoList.push_back( eventdata ); - - //...................................................... - // Keep count of how many events we add to the queue - //...................................................... - count++; - numunits--; - memcpy ( &eventdata.Data.MegaMission.Whom, - ((char *)buf) + pos, datasize ); - - //...................................................... - // if one unit left fall thru to normal code - //...................................................... - if (numunits == 1) { - datasize -= size_of(EventClass, Type); - break; - } - else { - pos += datasize; - leftover -= datasize; - } - } - } - break; - - default: - memcpy ( &eventdata.Data, - ((char *)buf) + pos + size_of(EventClass, Type), - datasize ); - break; - } - - DoList.push_back( eventdata ); - - //.................................................................. - // Keep count of how many events we add to the queue - //.................................................................. - count++; - - pos += (datasize + size_of(EventClass, Type)); - leftover -= (datasize + size_of(EventClass, Type)); - - if (leftover) { - event = (EventClass *)(((char *)buf) + pos); - datasize = EventClass::EventLength[ event->Type ]; - if (event->Type == EventClass::MEGAMISSION) { - datasize += sizeof(numunits); - } - } - } - //..................................................................... - // FRAMESYNC event: This >should< be the only event in the buffer, - // and it will be uncompressed. - //..................................................................... - else { - pos += (datasize + size_of(EventClass, Type)); - leftover -= (datasize + size_of(EventClass, Type)); - event = (EventClass *)(((char *)buf) + pos); - - //.................................................................. - // size of FRAMESYNC event - EventType size - //.................................................................. - datasize = (offsetof(EventClass, Data) + - size_of(EventClass, Data.FrameInfo)) - - size_of(EventClass, Type); - } - } - - return(count); - -} // end of Extract_Compressed_Events - - /*************************************************************************** * Execute_DoList -- Executes commands from the DoList * * * diff --git a/code/queue.h b/code/queue.h index cd9101c8..9fa284d5 100644 --- a/code/queue.h +++ b/code/queue.h @@ -50,9 +50,14 @@ void Add_CRC(unsigned int *crc, unsigned int val); void Wait_For_End_Of_Queue(void); -class IPXAddressClass; -struct GlobalPacketType; -void Kick_Packet_Received(GlobalPacketType & packet, IPXAddressClass & address); +namespace NetGlobal +{ + enum class DecodeError; +} + +NetGlobal::DecodeError Kick_Packet_Received(int kicker, int kickee); + +void Forget_Kick_Player(int player); extern BasicTimerClass SentFrameSyncTimer; extern int SentFrameSyncCount; diff --git a/code/sendfile.cpp b/code/sendfile.cpp index a8d65664..118af61f 100644 --- a/code/sendfile.cpp +++ b/code/sendfile.cpp @@ -77,8 +77,8 @@ bool Get_File_From_Host(char *return_name, bool show_progress) unsigned int file_length = 0; - GlobalPacketType net_send_packet; - GlobalPacketType net_receive_packet; + GlobalPacketType net_send_packet = {}; + GlobalPacketType net_receive_packet = {}; unsigned short product_id; IPXAddressClass sender_address; @@ -116,8 +116,7 @@ bool Get_File_From_Host(char *return_name, bool show_progress) do { Call_Back(); int receive_packet_length = sizeof (net_receive_packet); - if (Ipx.Get_Global_Message (&net_receive_packet, &receive_packet_length, - &sender_address, &product_id)){ + if (Ipx.Get_Global_Message (&net_receive_packet, sizeof(net_receive_packet), &receive_packet_length, &sender_address, &product_id)){ //DebugString ("RA95 - Got packet from host\n"); if (net_receive_packet.Command == NET_FILE_INFO && sender_address == Session.HostAddress) { @@ -244,8 +243,7 @@ bool Receive_Remote_File ( char *file_name, unsigned int file_length, bool show_ Call_Back(); int receive_packet_length = sizeof (RemoteFileTransferType); - if (Ipx.Get_Global_Message (receive_packet, &receive_packet_length, - &sender_address, &product_id)) { + if (Ipx.Get_Global_Message (receive_packet, sizeof(*receive_packet), &receive_packet_length, &sender_address, &product_id)) { if (receive_packet->Command == NET_FILE_CHUNK && sender_address == Session.HostAddress){ @@ -339,9 +337,9 @@ bool Send_Remote_File ( char const *file_name, bool send_to_all, bool show_progr /// RemoteFileTransferType would overrun a packet, so the buffer stays raw /// and is written through a reference. char send_packet[200]; - GlobalPacketType net_file_info; + GlobalPacketType net_file_info = {}; - GlobalPacketType net_receive_packet; + GlobalPacketType net_receive_packet = {}; CCFileClass send_file (file_name); @@ -407,7 +405,7 @@ bool Send_Remote_File ( char const *file_name, bool send_to_all, bool show_progr do { Call_Back(); net_packetlen = sizeof (net_receive_packet); - if (Ipx.Get_Global_Message (&net_receive_packet, &net_packetlen, &sender_address, &product_id)) { + if (Ipx.Get_Global_Message (&net_receive_packet, sizeof(net_receive_packet), &net_packetlen, &sender_address, &product_id)) { if (net_receive_packet.Command == NET_FILE_INFO_ACK) { acks++; } diff --git a/code/session.cpp b/code/session.cpp index da274270..6dfde868 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -185,16 +185,14 @@ SessionClass::SessionClass(void) MaxPlayers = MAX_PLAYERS; NumPlayers = 0; - FrameSendRate = DEFAULT_FRAME_SEND_RATE; - - MaxAhead = FrameSendRate * 3; + NetTiming::TimingSettings const initial_timing = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG); + FrameSendRate = initial_timing.FrameSendRate; + MaxAhead = initial_timing.MaxAhead; MaxMaxAhead = MaxAhead; + Reset_Network_Timing(0); memset(ConnectionStats, 0, sizeof(ConnectionStats)); - PrecalcMaxAhead = 0; - PrecalcDesiredFrameRate = 0; - ShowInternetDebug = false; LoadGame = 0; @@ -333,7 +331,6 @@ int SessionClass::Create_Connections(void) if (Session.Type != GAME_IPX && Session.Type != GAME_INTERNET) { return(0); } - //------------------------------------------------------------------------ // Loop through all entries in 'Players' //------------------------------------------------------------------------ @@ -370,6 +367,8 @@ int SessionClass::Create_Connections(void) } } + Reset_Network_Timing(Frame >= 0 ? static_cast(Frame) : 0u); + DebugString("Leaving Create_Connections\n"); return(1); @@ -430,6 +429,216 @@ bool SessionClass::Am_I_Master(void) } // end of Am_I_Master +/// Returns the player ID authorized to issue timing updates. +int SessionClass::Master_Player_ID(void) const +{ + if (MasterPlayerID >= 0 && Is_Network_Player_ID(MasterPlayerID)) { + return(MasterPlayerID); + } + + if (MasterPlayerName[0] != '\0') { + for (int i = 0; i < Players.Count(); i++) { + if (Players[i] != NULL && Is_Network_Player_ID(Players[i]->Player.ID) && stricmp(Players[i]->Name, MasterPlayerName) == 0) { + return(Players[i]->Player.ID); + } + } + } + + for (int i = 0; i < Houses.Count(); i++) { + HouseClass const * house = Houses[i]; + if (house != NULL && house->IsHuman && Is_Network_Player_ID(house->HeapID)) { + return(house->HeapID); + } + } + + return(-1); +} + + +/// Tests whether a player ID still belongs to the network session. +bool SessionClass::Is_Network_Player_ID(int id) const +{ + return(Is_Network_Timing_Player_Active(id)); +} + + +/// Tests synchronized timing-roster membership. +bool SessionClass::Is_Network_Timing_Player_Active(int id) const +{ + return(id >= 0 && id < static_cast(NetTiming::MAX_TIMING_PLAYERS) && NetworkTimingReports.Is_Player_Active(id)); +} + + +/// Starts a fresh adaptive-timing census from the synchronized roster. +void SessionClass::Reset_Network_Timing(unsigned int frame) +{ + if (CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { + NetTiming::TimingSettings const initial = NetTiming::Settings_For_Rung(NetTiming::INITIAL_TIMING_RUNG); + FrameSendRate = initial.FrameSendRate; + MaxAhead = initial.MaxAhead; + MaxMaxAhead = MaxAhead; + } + NetworkTimingReports.Reset(); + NetworkTimingPolicy.Reset(frame); + PendingNetworkTiming.reset(); + NetworkTimingPolicyOwner = -1; + + for (int i = 0; i < Players.Count(); i++) { + int const id = Players[i] != NULL ? Players[i]->Player.ID : -1; + if (id >= 0 && id < (int)NetTiming::MAX_TIMING_PLAYERS) { + NetworkTimingReports.Set_Player_Active(id, true, frame); + } + } + Prepare_Network_Timing_Master(Master_Player_ID(), frame); +} + + +/// Validates and records a seated player's synchronized timing report. +bool SessionClass::Record_Network_Report(int id, unsigned int process_milliseconds, unsigned int round_trip_milliseconds, unsigned int frame) +{ + std::optional round_trip; + if (round_trip_milliseconds != EventClass::NETWORK_RTT_UNAVAILABLE) { + round_trip = round_trip_milliseconds; + } + if (!NetworkTimingReports.Record_Report(id, process_milliseconds, round_trip, frame)) { + return(false); + } + + for (int i = 0; i < Players.Count(); i++) { + if (Players[i] != NULL && Players[i]->Player.ID == id) { + Players[i]->Player.ProcessTime = process_milliseconds; + break; + } + } + return(true); +} + + +/// Removes a departed player from the timing census. +void SessionClass::Remove_Network_Timing_Player(int id, unsigned int frame) +{ + if (Is_Network_Timing_Player_Active(id)) { + NetworkTimingReports.Set_Player_Active(id, false, frame); + Prepare_Network_Timing_Master(Master_Player_ID(), frame); + } +} + + +/// Returns a freshness-aware census of seated players. +NetTiming::TimingCensus SessionClass::Network_Timing_Census(unsigned int frame) +{ + return(NetworkTimingReports.Inspect(frame)); +} + + +/// Evaluates the adaptive-timing policy against the current census. +NetTiming::TimingEvaluation SessionClass::Evaluate_Network_Timing(NetTiming::TimingCensus const & census, unsigned int target_fps, unsigned int frame) +{ + return(NetworkTimingPolicy.Evaluate(census, target_fps, frame)); +} + + +/// Returns the synchronized target behind any active transition. +NetTiming::TimingSettings SessionClass::Network_Timing_Target(void) const +{ + return(PendingNetworkTiming ? PendingNetworkTiming->Timing.Plan.Settings : NetTiming::TimingSettings{FrameSendRate, MaxAhead}); +} + + +/// Rebases adaptive policy state when deterministic timing authority changes. +void SessionClass::Prepare_Network_Timing_Master(int master_id, unsigned int frame) +{ + if (master_id == NetworkTimingPolicyOwner) { + return; + } + if (NetworkTimingPolicyOwner >= 0 && master_id >= 0) { + NetworkTimingPolicy.Reset_From(Network_Timing_Target(), frame); + } + NetworkTimingPolicyOwner = master_id; +} + + +/// Reconciles a legacy response-time update with adaptive state. +void SessionClass::Apply_Network_Response_Time(unsigned int max_ahead, unsigned int event_frame) +{ + PendingNetworkTiming.reset(); + MaxAhead = max_ahead; + MaxMaxAhead = std::max(MaxMaxAhead, static_cast(MaxAhead)); + if (CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { + NetworkTimingPolicy.Reset_From({FrameSendRate, MaxAhead}, event_frame); + } +} + + +/// Applies a timing increase or safely stages a decrease. +NetTiming::ScheduleResult SessionClass::Schedule_Network_Timing(NetTiming::TimingSettings settings, unsigned int desired_frame_rate, unsigned int event_frame) +{ + if (desired_frame_rate == 0 || desired_frame_rate > 60 || !NetTiming::Timing_Settings_Are_Valid(settings)) { + return(NetTiming::ScheduleResult::Rejected); + } + + NetTiming::TimingSettings const current{FrameSendRate, MaxAhead}; + if (!NetTiming::Timing_Transition_Source_Is_Valid(current)) { + return(NetTiming::ScheduleResult::Rejected); + } + + if (PendingNetworkTiming && settings == PendingNetworkTiming->Timing.Plan.Settings) { + PendingNetworkTiming->DesiredFrameRate = desired_frame_rate; + if (PendingNetworkTiming->Timing.Activated) { + DesiredFrameRate = desired_frame_rate; + } + return(NetTiming::ScheduleResult::Staged); + } + + std::optional const staged = NetTiming::Stage_Timing_Update(current, settings, event_frame); + if (!staged) { + return(NetTiming::ScheduleResult::Rejected); + } + if (staged->Deferred) { + NetworkTimingTransition transition; + transition.Timing.Plan = *staged; + transition.DesiredFrameRate = desired_frame_rate; + PendingNetworkTiming = transition; + return(NetTiming::ScheduleResult::Staged); + } + + PendingNetworkTiming.reset(); + DesiredFrameRate = desired_frame_rate; + FrameSendRate = settings.FrameSendRate; + MaxAhead = settings.MaxAhead; + MaxMaxAhead = std::max(MaxMaxAhead, (int)MaxAhead); + return(NetTiming::ScheduleResult::Applied); +} + + +/// Advances a deterministic drain/catch-up timing transition. +bool SessionClass::Advance_Network_Timing(unsigned int frame) +{ + if (!PendingNetworkTiming) { + return(false); + } + + NetworkTimingTransition & transition = *PendingNetworkTiming; + bool const was_activated = transition.Timing.Activated; + std::optional const advance = NetTiming::Advance_Timing_Transition( + transition.Timing, {FrameSendRate, MaxAhead}, frame); + if (!advance || !advance->Changed) { + return(false); + } + + if (!was_activated && transition.Timing.Activated) { + DesiredFrameRate = transition.DesiredFrameRate; + } + FrameSendRate = advance->Settings.FrameSendRate; + MaxAhead = advance->Settings.MaxAhead; + MaxMaxAhead = std::max(MaxMaxAhead, (int)MaxAhead); + if (advance->Complete) { + PendingNetworkTiming.reset(); + } + return(true); +} + + /*************************************************************************** * SessionClass::Read_MultiPlayer_Settings -- reads settings INI * * * diff --git a/code/session.h b/code/session.h index a6b2928a..f79deeae 100644 --- a/code/session.h +++ b/code/session.h @@ -38,6 +38,7 @@ #include "house.h" /// needed for HOUSE_NAME_MAX #include "ipxaddr.h" #include "msglist.h" +#include "nettiming.h" #include "special.h" #include "sun.h" /// needed for MAX_PLAYERS #include "typelist.h" @@ -426,6 +427,7 @@ struct MPStatsType { IPXAddressClass Address; /// Address these stats were gathered from. }; + //--------------------------------------------------------------------------- // Class Definition //--------------------------------------------------------------------------- @@ -435,6 +437,12 @@ class SessionClass // Public interface //------------------------------------------------------------------------ public: + struct NetworkTimingTransition + { + NetTiming::TimingTransitionState Timing; + unsigned int DesiredFrameRate = 30; + }; + //..................................................................... // Constructor/Destructor //..................................................................... @@ -460,6 +468,19 @@ class SessionClass //..................................................................... int Create_Connections(void); bool Am_I_Master(void); + int Master_Player_ID(void) const; + bool Is_Network_Player_ID(int id) const; + bool Is_Network_Timing_Player_Active(int id) const; + void Reset_Network_Timing(unsigned int frame); + bool Record_Network_Report(int id, unsigned int process_milliseconds, unsigned int round_trip_milliseconds, unsigned int frame); + void Remove_Network_Timing_Player(int id, unsigned int frame); + NetTiming::TimingCensus Network_Timing_Census(unsigned int frame); + NetTiming::TimingEvaluation Evaluate_Network_Timing(NetTiming::TimingCensus const & census, unsigned int target_fps, unsigned int frame); + NetTiming::TimingSettings Network_Timing_Target(void) const; + void Prepare_Network_Timing_Master(int master_id, unsigned int frame); + void Apply_Network_Response_Time(unsigned int max_ahead, unsigned int event_frame); + NetTiming::ScheduleResult Schedule_Network_Timing(NetTiming::TimingSettings settings, unsigned int desired_frame_rate, unsigned int event_frame); + bool Advance_Network_Timing(unsigned int frame); unsigned int Compute_Unique_ID(void); void Update_Progress(int percent); void Init_Fixed_Alliances(void); @@ -530,6 +551,10 @@ class SessionClass //..................................................................... unsigned int MaxAhead; unsigned int FrameSendRate; + NetTiming::TimingReportCensus NetworkTimingReports; + NetTiming::BalancedTimingPolicy NetworkTimingPolicy; + std::optional PendingNetworkTiming; + int NetworkTimingPolicyOwner; int DesiredFrameRate; @@ -543,14 +568,6 @@ class SessionClass */ int MaxMaxAhead; - /* - * These are the frame timings Westwood Online worked out from the players' connection - * speeds. While either is non-zero the host sends them out instead of measuring the - * connections itself, and clears both once it has. - */ - int PrecalcMaxAhead; - int PrecalcDesiredFrameRate; - /* * These are the network statistics gathered for each player over the course of the * game. They feed the network diagnostics display and the sync bug report. @@ -691,11 +708,7 @@ class SessionClass */ int PlayerLatency[MAX_PLAYERS]; - /* - * This scales up the measured connection response time when the frame timing is - * computed (0 - 3), buying tolerance of a laggy link at the cost of responsiveness. - */ - int LatencyFudge; + int LatencyFudge; // Legacy synchronized option retained for event and replay compatibility. //..................................................................... // For finding Sync Bugs diff --git a/code/wsproto.cpp b/code/wsproto.cpp index aa0a185d..77ba4f44 100644 --- a/code/wsproto.cpp +++ b/code/wsproto.cpp @@ -58,12 +58,30 @@ #include "dbgprint.h" #include "globals.h" #include "keyboard.h" +#include "netadmit.h" #include "vector.h" #include #include +#include -extern void Add_CRC(unsigned int *crc, unsigned int val); +namespace { + +/// Names a stable transport rejection reason. +char const * Packet_Drop_Name(WinsockInterfaceClass::PacketDropReasonType reason) +{ + switch (reason) { + case WinsockInterfaceClass::WS_DROP_RECEIVE_TOO_SHORT: return("udp-too-short"); + case WinsockInterfaceClass::WS_DROP_RECEIVE_TOO_LARGE: return("udp-too-large"); + case WinsockInterfaceClass::WS_DROP_BAD_CRC: return("udp-bad-crc"); + case WinsockInterfaceClass::WS_DROP_READ_BUFFER_TOO_SMALL: return("transport-output-too-small"); + case WinsockInterfaceClass::WS_DROP_SEND_LENGTH: return("transport-send-length"); + case WinsockInterfaceClass::WS_DROP_SEND_ADDRESS: return("transport-send-address"); + default: return("transport-unknown"); + } +} + +} /*********************************************************************************************** * WIC::WinsockInterfaceClass -- constructor for the WinsockInterfaceClass * @@ -100,6 +118,7 @@ Socket(INVALID_SOCKET) InBufferArrayPos = 0; OutBufferArrayPos = 0; + memset(PacketDrops, 0, sizeof(PacketDrops)); DebugString("WinsockInterface constructed\n"); @@ -408,70 +427,42 @@ void WinsockInterfaceClass::Build_Packet_CRC(WinsockBufferType * packet) fw_assert (packet->InUse); fw_assert (packet->BufferLen); - packet->CRC = 0; - - unsigned int *crc_ptr = &(packet->CRC); - unsigned int *packetptr = (unsigned int*) &(packet->Buffer[0]); + packet->CRC = Calculate_Packet_CRC(packet->Buffer, packet->BufferLen); +} - for (int i=0 ; iBufferLen/4 ; i++) { - Add_CRC (crc_ptr, *packetptr++); - } - int leftover = packet->BufferLen & 3; - if (leftover) { - unsigned int val = *packetptr; - val = val & (0xffffffff >> ((4-leftover) << 3)); - Add_CRC (crc_ptr, val); +/// Calculates the transport checksum. +unsigned int WinsockInterfaceClass::Calculate_Packet_CRC(void const * buffer, int buffer_len) const +{ + if (buffer == NULL || buffer_len <= 0) { + return(0); } + return(NetAdmission::Calculate_Datagram_CRC(std::span(static_cast(buffer), static_cast(buffer_len)))); } -/*********************************************************************************************** - * WIC::Passes_CRC_Check -- Checks the CRC for a packet * - * * - * * - * * - * INPUT: ptr to packet * - * * - * OUTPUT: true if packet passes CRC check * - * * - * WARNINGS: None * - * * - * HISTORY: * - * 10/5/99 1:26PM ST : Created * - *=============================================================================================*/ -bool WinsockInterfaceClass::Passes_CRC_Check(WinsockBufferType * packet) +/// Returns the number of transport packets rejected for one stable reason. +unsigned int WinsockInterfaceClass::Dropped_Packets(PacketDropReasonType reason) const { - fw_assert (packet->InUse); - fw_assert (packet->BufferLen < WS_INTERNET_BUFFER_LEN); - - if (packet->BufferLen >= WS_INTERNET_BUFFER_LEN) { - return(false); + if (reason < 0 || reason >= WS_DROP_COUNT) { + return(0); } - unsigned int crc = 0; + return(PacketDrops[reason]); +} - unsigned int *crc_ptr = &crc; - unsigned int *packetptr = (unsigned int*) &(packet->Buffer[0]); - for (int i=0 ; iBufferLen/4 ; i++) { - Add_CRC (crc_ptr, *packetptr++); - } - - int leftover = packet->BufferLen & 3; - if (leftover) { - unsigned int val = *packetptr; - val = val & (0xffffffff >> ((4-leftover) << 3)); - Add_CRC (crc_ptr, val); +/// Records a transport rejection and rate-limits its diagnostic. +void WinsockInterfaceClass::Record_Packet_Drop(PacketDropReasonType reason) +{ + if (reason < 0 || reason >= WS_DROP_COUNT) { + return; } - if (crc == packet->CRC) { - return(true); + unsigned int count = ++PacketDrops[reason]; + if (count == 1 || (count & (count - 1)) == 0) { + DebugString("Network packet drop [%s]: %u\n", Packet_Drop_Name(reason), count); } - - fw_assert (crc == packet->CRC); - DebugString("Error in Winsock packet CRC\n"); - return(false); } @@ -601,7 +592,6 @@ void *WinsockInterfaceClass::Get_New_In_Buffer(void) *=============================================================================================*/ int WinsockInterfaceClass::Read(void *buffer, int &buffer_len, void *address, int &address_len) { - address_len = address_len; /* ** Call the message loop in case there are any outstanding winsock READ messages. */ @@ -624,8 +614,22 @@ int WinsockInterfaceClass::Read(void *buffer, int &buffer_len, void *address, in fw_assert(packet->InUse); - fw_assert( buffer_len >= packet->BufferLen ); - assert ( address_len >= sizeof (packet->Address) ); + int buffer_capacity = buffer_len; + int address_capacity = address_len; + if (buffer == NULL || address == NULL || packet->BufferLen <= 0 || packet->BufferLen > WS_INTERNET_BUFFER_LEN || + buffer_capacity < packet->BufferLen || address_capacity < (int)sizeof(packet->Address)) { + InBuffers.Delete_Index(packetnum); + if (packet->IsAllocated) { + delete packet; + } else { + packet->InUse = false; + InBuffersUsed--; + } + buffer_len = 0; + address_len = 0; + Record_Packet_Drop(WS_DROP_READ_BUFFER_TOO_SMALL); + return(0); + } /* ** Copy the data and the address it came from into the supplied buffers. @@ -637,6 +641,7 @@ int WinsockInterfaceClass::Read(void *buffer, int &buffer_len, void *address, in ** Return the length of the packet in buffer_len. */ buffer_len = packet->BufferLen; + address_len = sizeof(packet->Address); /* ** Delete the temporary storage for the packet now that it is being passed to the game. @@ -671,11 +676,23 @@ int WinsockInterfaceClass::Read(void *buffer, int &buffer_len, void *address, in *=============================================================================================*/ void WinsockInterfaceClass::WriteTo(void *buffer, int buffer_len, void *address, int address_len) { + if (buffer == NULL || buffer_len <= 0 || buffer_len > WS_INTERNET_BUFFER_LEN) { + Record_Packet_Drop(WS_DROP_SEND_LENGTH); + return; + } + if (address == NULL || address_len <= 0 || address_len > (int)sizeof(WinsockBufferType::Address)) { + Record_Packet_Drop(WS_DROP_SEND_ADDRESS); + return; + } + /* ** Create a temporary holding area for the packet. */ WinsockBufferType *packet = (WinsockBufferType*) Get_New_Out_Buffer(); fw_assert (packet != NULL); + if (packet == NULL) { + return; + } /* ** Copy the packet into the holding buffer. @@ -722,11 +739,19 @@ void WinsockInterfaceClass::WriteTo(void *buffer, int buffer_len, void *address, *=============================================================================================*/ void WinsockInterfaceClass::Broadcast (void *buffer, int buffer_len) { + if (buffer == NULL || buffer_len <= 0 || buffer_len > WS_INTERNET_BUFFER_LEN) { + Record_Packet_Drop(WS_DROP_SEND_LENGTH); + return; + } + /* ** Create a temporary holding area for the packet. */ WinsockBufferType *packet = (WinsockBufferType*) Get_New_Out_Buffer(); fw_assert(packet != NULL); + if (packet == NULL) { + return; + } /* ** Copy the packet into the holding buffer. diff --git a/code/wsproto.h b/code/wsproto.h index ce2e02e7..5ed17053 100644 --- a/code/wsproto.h +++ b/code/wsproto.h @@ -87,6 +87,15 @@ enum ProtocolEnum { class WinsockInterfaceClass { public: + enum PacketDropReasonType { + WS_DROP_RECEIVE_TOO_SHORT, + WS_DROP_RECEIVE_TOO_LARGE, + WS_DROP_BAD_CRC, + WS_DROP_READ_BUFFER_TOO_SMALL, + WS_DROP_SEND_LENGTH, + WS_DROP_SEND_ADDRESS, + WS_DROP_COUNT + }; WinsockInterfaceClass(void); virtual ~WinsockInterfaceClass(void); @@ -149,6 +158,7 @@ class WinsockInterfaceClass { }; inline ConnectStatusEnum Get_Connection_Status(void) {return(ConnectStatus);} + unsigned int Dropped_Packets(PacketDropReasonType reason) const; protected: @@ -176,7 +186,8 @@ class WinsockInterfaceClass { ** Packet CRCs. */ virtual void Build_Packet_CRC(WinsockBufferType *packet); - virtual bool Passes_CRC_Check(WinsockBufferType *packet); + unsigned int Calculate_Packet_CRC(void const *buffer, int buffer_len) const; + void Record_Packet_Drop(PacketDropReasonType reason); /* ** Array of buffers to temporarily store incoming and outgoing packets. @@ -226,4 +237,5 @@ class WinsockInterfaceClass { ** Current connection status. */ ConnectStatusEnum ConnectStatus; + unsigned int PacketDrops[WS_DROP_COUNT]; }; diff --git a/code/wspudp.cpp b/code/wspudp.cpp index b0341fc8..d90f0afc 100644 --- a/code/wspudp.cpp +++ b/code/wspudp.cpp @@ -46,6 +46,7 @@ #include "dbgprint.h" #include "misc.h" #include "msgloop.h" +#include "netadmit.h" #include "vector.h" #include @@ -152,9 +153,8 @@ int UDPInterfaceClass::Send_To(const char *buffer, int buffer_len, sockaddr_in * return(SOCKET_ERROR); } - unsigned short *header = reinterpret_cast(tunnelled); - header[0] = TunnelID; - header[1] = destination->sin_port; + unsigned short header[] = { TunnelID, destination->sin_port }; + std::memcpy(tunnelled, header, sizeof(header)); std::memcpy(tunnelled + TUNNEL_HEADER_SIZE, buffer, buffer_len); sockaddr_in server = {}; @@ -187,7 +187,9 @@ int UDPInterfaceClass::Receive_From(char *buffer, int buffer_len, sockaddr_in *s if (rc == SOCKET_ERROR) return(SOCKET_ERROR); - const unsigned short *header = reinterpret_cast(tunnelled); + unsigned short header[2]; + if (rc < (int)sizeof(header)) return(SOCKET_ERROR); + std::memcpy(header, tunnelled, sizeof(header)); // Anything too short to carry a header, or addressed to somebody else, is not ours. if (rc <= TUNNEL_HEADER_SIZE || header[1] != TunnelID) { @@ -459,12 +461,20 @@ void UDPInterfaceClass::Register_Local_Addresses() *=============================================================================================*/ void UDPInterfaceClass::Broadcast (void *buffer, int buffer_len) { + if (buffer == NULL || buffer_len <= 0 || buffer_len > WS_INTERNET_BUFFER_LEN) { + Record_Packet_Drop(WS_DROP_SEND_LENGTH); + return; + } + for ( int i=0 ; i const datagram(reinterpret_cast(ReceiveBuffer), rc > 0 ? static_cast(rc) : 0); + NetAdmission::DatagramResult const admission = NetAdmission::Admit_Datagram(datagram, WS_INTERNET_BUFFER_LEN); + if (!admission.Succeeded()) { + switch (admission.ErrorCode) { + case NetAdmission::Error::DATAGRAM_TOO_LARGE: + Record_Packet_Drop(WS_DROP_RECEIVE_TOO_LARGE); + break; + case NetAdmission::Error::BAD_CRC: + Record_Packet_Drop(WS_DROP_BAD_CRC); + break; + default: + Record_Packet_Drop(WS_DROP_RECEIVE_TOO_SHORT); + break; + } + return(0); + } + + { /* ** Make sure this packet didn't come from us. If it did then throw it away. @@ -572,32 +596,12 @@ int UDPInterfaceClass::Message_Handler(HWND, UINT message, UINT, LONG lParam) ** Create a new buffer and store this packet in it. */ packet = (WinsockBufferType *)Get_New_In_Buffer(); - packet->BufferLen = rc - sizeof(packet->CRC); - - // A datagram this long is not ours; truncating it lets the CRC check reject it. - if (packet->BufferLen > (int)sizeof(packet->Buffer)) { - packet->BufferLen = (int)sizeof(packet->Buffer); + if (packet == NULL) { + return(0); } - - packet->CRC = *((unsigned int*) (&ReceiveBuffer[0])); - memcpy ( packet->Buffer, ReceiveBuffer + sizeof(packet->CRC), packet->BufferLen); - - /* - ** Make sure the CRC looks right. - */ - if (!Passes_CRC_Check(packet)) { - - /* - ** Bad CRC, throw away the packet. - */ - DebugString("Throwing away malformed packet\n"); - if (packet->IsAllocated) { - delete packet; - } else { - packet->InUse = false; - InBuffersUsed--; - } - } else { + packet->BufferLen = static_cast(admission.Payload.size()); + packet->CRC = admission.WireCRC; + memcpy(packet->Buffer, admission.Payload.data(), admission.Payload.size()); /* ** Copy the address data into the holding buffer address area. @@ -610,9 +614,9 @@ int UDPInterfaceClass::Message_Handler(HWND, UINT message, UINT, LONG lParam) ** Add the holding buffer to the packet list. */ InBuffers.Add (packet); - } } return(0); + } /* diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md new file mode 100644 index 00000000..d6a9989b --- /dev/null +++ b/manual/changes/adaptive-network-timing.md @@ -0,0 +1,32 @@ +--- +title: Adapt multiplayer timing to every connection +category: performance +release: 0.2.0 +targets: +- type: system + id: network-synchronization + effect: added +credit: +- ZivDero +--- + +Each connection measures its own round trip and retry timing. Compressed games +start with a two-frame send period and six-frame look-ahead, then calibrate early +from the whole match. Complete data can select the measured target directly; +missing initial measurements fall back to a three-frame period and nine-frame look-ahead. + +Reductions drain the old scheduling horizon and step down at aligned send +boundaries. A successor master inherits the synchronized target and change +cooldown before restarting improvement hysteresis. Later recovery remains +available for the whole match. + +The disabled WOL Connection slider shows the synchronized Fast, Normal, Poor, +or Bad tier, and the message list announces tier changes. Game speed remains a +separate setting. Adaptive timing uses measured RTT directly; the legacy +`LATENCYFUDGE` event remains in the recording layout but is no longer emitted +or used by the adaptive policy. + +Timing reports extend the network and multiplayer-recording event stream, so +players and recordings require the same OpenTS snapshot. Existing event IDs +and packet layouts are unchanged, fog of war adds no timing offset, and there +is no configuration to migrate. diff --git a/manual/changes/network-packet-validation.md b/manual/changes/network-packet-validation.md new file mode 100644 index 00000000..3782784e --- /dev/null +++ b/manual/changes/network-packet-validation.md @@ -0,0 +1,23 @@ +--- +title: Reject malformed network packets +category: fix +release: 0.2.0 +targets: [] +credit: +- ZivDero +--- + +Malformed, oversized, truncated, and misattributed network packets are rejected +before they change peer state or enter the simulation queue. + +Private events inherit the delivering connection's identity. Covered object +commands require the object to still belong to that sender, and network timing +is master-only. Player removal remains a trusted-peer control; redundant events +keep simultaneous departures from stranding a human house. + +In-game global controls require one unique roster endpoint, preferring an exact +IP/port match over one same-IP zero-port fallback. Roster identity supplies chat +and kick attribution. This is not authentication or complete cheat prevention, +and unknown private endpoints no longer rewrite roster addresses. Packet +layouts and event IDs are unchanged. All players must use the same OpenTS +snapshot. diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md new file mode 100644 index 00000000..b5ad0436 --- /dev/null +++ b/manual/content/systems/network-synchronization.md @@ -0,0 +1,83 @@ +--- +title: Network synchronization +summary: Keeps every machine on the same simulation frame while adapting command delay and retransmission timing to the measured links between the players. +category: multiplayer-networking +keys: [] +--- + +Network games exchange commands rather than copies of the game state. Each +command names the simulation frame on which every machine executes it, and a +machine waits when advancing would carry it too far beyond a player whose +commands have not arrived. The look-ahead distance is therefore both the time +available for delivery and the delay before a player's command takes effect. + +## Packet admission + +Every synchronized-event packet passes through bounded transport, connection, +and event decoders. Its envelope is the leading `FRAMEINFO` or sole +`FRAMESYNC`, which carries sender and frame information. The whole event stream +must validate before anything enters the simulation queue. + +Compressed events inherit the delivering connection's identity. Power, +archive-target, repair, primary-factory, mission, idle, deploy, scatter, and +sell events also require their object to still belong to that sender. Missing +or destroyed objects remain no-ops; captured objects are rejected. + +Network timing is master-only. Player removal remains a trusted-peer control: +each survivor emits it for a lost connection, and repeated removal is harmless. +This prevents one departing authority from stranding another departed house. + +Public discovery remains public. In-game chat, progress, sign-off, ready, and +kick controls require one unique roster endpoint: an exact IP/port match, or +one same-IP entry whose stored port is zero. Roster identity supplies chat and +kick attribution. Checksums and endpoint matching detect damage and attribute +traffic, but do not authenticate participants or pin addresses. +Unknown private endpoints are discarded rather than changing a roster address. + +## Link measurement and retransmission + +Each connection estimates its own round trip. Only first-transmission +acknowledgements contribute samples; private gameplay retries use exponential +backoff. The global lobby channel retains its fixed retry cadence. One slow link +therefore does not set every connection's retry interval. + +## Match timing + +Every player reports process time and optional worst-local RTT as one record. +Reports expire after 512 frames. Missing initial RTT has that long to appear; +missing or stale established RTT selects `10/250` immediately. Stale process +data retains the last synchronized frame rate, and removal clears the player's +report. RTT aggregation covers current private links, so a removed link cannot +poison a later report while its synchronized removal is pending. + +Compressed matches bootstrap with a two-frame send period and six-frame look-ahead. +Players report 32 and 64 frames after a match starts or resumes, then every 128 frames. The master evaluates after 64 frames and, if calibration is incomplete, +again after 128. The first complete census selects its target directly with 20% headroom; missing initial measurements at the second evaluation fall back to a +three-frame period and nine-frame look-ahead. + +After bootstrap, the master evaluates every 256 frames. Worse conditions apply immediately. Improvement needs three evaluations with 20% headroom and a +cooldown, moving one rung at a time. + +A reduction activates after the old horizon drains, on a frame aligned to both +send periods. It switches to the new rate with temporary look-ahead, then drops +one new send period at each boundary. Replacement targets rebase this process. +A successor master inherits the target, clears improvement evidence, and starts +a cooldown. + +The disabled Connection slider in the WOL Options menu shows the synchronized +target: send rates 1–2 are Fast, 3–5 Normal, 6–8 Poor, and 9–10 Bad. An +extended look-ahead is also Bad. The message list reports each tier change. +The separate Speed slider still controls game speed, including speed zero as +60 FPS. + +Adaptive timing uses measured RTT directly; the 20% improvement headroom is its +only extra margin. The legacy `LATENCYFUDGE` event and session field remain for +recording compatibility, but the menu no longer emits it and the adaptive +policy does not consume it. + +## Compatibility + +Network events and recorded multiplayer commands include the timing reports. +All players must use the same OpenTS snapshot, and a recording should be played +by the snapshot that wrote it. The former manual margin was not saved as a +player setting, so there is nothing to migrate. diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5de329d7..039febdc 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,3 +1,5 @@ add_subdirectory(gamedirs) add_subdirectory(logstress) add_subdirectory(cpudetect) +add_subdirectory(netpacket) +add_subdirectory(nettiming) diff --git a/tests/netpacket/CMakeLists.txt b/tests/netpacket/CMakeLists.txt new file mode 100644 index 00000000..76645d8a --- /dev/null +++ b/tests/netpacket/CMakeLists.txt @@ -0,0 +1,32 @@ +# The packet decoder is compiled directly into this asset-free contract harness so malformed +# wire data can be exercised without starting the engine or opening a network session. +add_executable(NetContract + "${CMAKE_CURRENT_SOURCE_DIR}/netcontract.cpp" + "${CMAKE_SOURCE_DIR}/code/_event.cpp" + "${CMAKE_SOURCE_DIR}/code/netadmit.cpp" + "${CMAKE_SOURCE_DIR}/code/netglobal.cpp" + "${CMAKE_SOURCE_DIR}/code/netpacket.cpp" + "${CMAKE_SOURCE_DIR}/code/netreader.cpp" +) + +target_compile_features(NetContract PRIVATE cxx_std_20) + +target_include_directories(NetContract PRIVATE + "${CMAKE_SOURCE_DIR}/code" + "${OPENTS_GENERATED_DIR}" +) + +add_dependencies(NetContract OpenTSBuildStamp) + +target_compile_definitions(NetContract PRIVATE WIN32 _WINDOWS _MBCS) + +target_compile_options(NetContract PRIVATE + $<$:/MTd /EHsc /Zc:__cplusplus> + $<$:/MT /EHsc /Zc:__cplusplus> +) + +set_target_properties(NetContract PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) + +add_test(NAME netpacket COMMAND NetContract) diff --git a/tests/netpacket/netcontract.cpp b/tests/netpacket/netcontract.cpp new file mode 100644 index 00000000..f5e74dbf --- /dev/null +++ b/tests/netpacket/netcontract.cpp @@ -0,0 +1,830 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + +// Exercises the network event contract without starting the engine or loading game data. + +#include "netadmit.h" +#include "netpacket.h" +#include "netreader.h" +#include "netglobal.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace { + +using Bytes = std::vector; +using VariableDataType = decltype(std::declval().Data.Variable); +using NetworkReportType = decltype(std::declval().Data.NetworkReport); + +constexpr int Sender = 3; +constexpr int Frame = 120; +constexpr std::size_t DataOffset = offsetof(EventClass, Data); +constexpr std::size_t FrameOffset = offsetof(EventClass, Frame); +constexpr std::size_t EnvelopeSize = DataOffset + sizeof(std::declval().Data.FrameInfo); +constexpr std::size_t FrameDelayOffset = DataOffset + offsetof(decltype(std::declval().Data.FrameInfo), Delay); +constexpr std::size_t VariableSizeOffset = offsetof(VariableDataType, Size); +constexpr std::size_t MegaWhomSize = sizeof(std::declval().Data.MegaMission.Whom); + +int Failures = 0; + + +void Check(bool condition, char const * what) +{ + std::printf("%-72s %s\n", what, condition ? "ok" : "FAILED"); + if (!condition) { + Failures++; + } +} + + +void Check_Error( + NetPacket::DecodeResult const & result, + NetPacket::DecodeError expected, + char const * what) +{ + bool const matches = !result.Succeeded() && result.Failure.Code == expected && result.Events.empty(); + Check(matches, what); + if (!matches) { + std::printf(" got %s at %zu\n", NetPacket::Error_Name(result.Failure.Code), result.Failure.Offset); + } +} + + +template +void Append_Value(Bytes & bytes, T const & value) +{ + std::byte const * first = reinterpret_cast(&value); + bytes.insert(bytes.end(), first, first + sizeof(value)); +} + + +void Append_Bytes(Bytes & bytes, std::span value) +{ + bytes.insert(bytes.end(), value.begin(), value.end()); +} + + +template +void Write_Value(Bytes & bytes, std::size_t offset, T const & value) +{ + std::memcpy(bytes.data() + offset, &value, sizeof(value)); +} + + +Bytes Full_Event(std::uint8_t type, int sender = Sender, int frame = Frame) +{ + EventClass event; + std::memset(&event, 0, sizeof(event)); + event.Type = type; + event.Frame = frame; + event.ID = sender; + event.Data.FrameInfo.CRC = 0x12345678; + event.Data.FrameInfo.CommandCount = 23; + event.Data.FrameInfo.Delay = 4; + + Bytes bytes(sizeof(event)); + std::memcpy(bytes.data(), &event, sizeof(event)); + return(bytes); +} + + +Bytes Envelope(std::uint8_t type, int sender = Sender) +{ + Bytes bytes = Full_Event(type, sender); + bytes.resize(EnvelopeSize); + return(bytes); +} + + +Bytes Compressed_Packet(void) +{ + return(Envelope(EventClass::FRAMEINFO)); +} + + +void Add_Compressed_Event(Bytes & packet, std::uint8_t type, std::span data = {}) +{ + packet.push_back(static_cast(type)); + Append_Bytes(packet, data); +} + + +void Test_Reader(void) +{ + Bytes bytes; + std::uint16_t const first = 0x1234; + std::uint32_t const second = 0x89ABCDEF; + Append_Value(bytes, first); + Append_Value(bytes, second); + + NetPacket::Reader reader(bytes); + auto got_first = reader.Read_Value(); + Check(got_first && *got_first == first, "reader copies a fixed-width value"); + Check(reader.Offset() == sizeof(first), "reader reports its consumed offset"); + + std::size_t const before_failure = reader.Offset(); + Check(!reader.Take(bytes.size()), "reader refuses a span larger than the remainder"); + Check(reader.Offset() == before_failure, "a failed read does not advance the cursor"); + + auto got_second = reader.Read_Value(); + Check(got_second && *got_second == second, "reader resumes after a failed read"); + Check(reader.Empty(), "reader reports an exhausted packet"); + Check(reader.Take(0).has_value(), "reader can take an empty span at the end"); +} + + +void Test_Event_Contract(void) +{ + Check(EventClass::EventLength[EventClass::NETWORK_REPORT] == sizeof(NetworkReportType), + "NETWORK_REPORT uses its current payload shape"); + Check(std::strcmp(EventClass::EventNames[EventClass::NETWORK_REPORT], "NETWORK_REPORT") == 0, + "NETWORK_REPORT has a diagnostic name"); + Check(EventClass::NETWORK_RTT_UNAVAILABLE == UINT16_MAX, "the unavailable RTT sentinel is uint16 max"); +} + + +void Test_Envelope_Rules(void) +{ + Check_Error( + NetPacket::Decode_Event_Packet({}, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::EMPTY_PACKET, + "an empty compressed packet is rejected"); + + Bytes invalid_prefix{static_cast(EventClass::GAMESPEED)}; + Check_Error( + NetPacket::Decode_Event_Packet(invalid_prefix, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::INVALID_PREFIX, + "a compressed packet must begin with FRAMEINFO or FRAMESYNC"); + + Bytes unknown{static_cast(0xFF)}; + Check_Error( + NetPacket::Decode_Event_Packet(unknown, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::INVALID_EVENT_TYPE, + "an unknown prefix type is rejected before table lookup"); + + Bytes complete = Compressed_Packet(); + for (std::size_t size = 1; size < complete.size(); size++) { + Bytes truncated(complete.begin(), complete.begin() + size); + NetPacket::DecodeResult result = NetPacket::Decode_Event_Packet(truncated, NetPacket::Encoding::COMPRESSED, Sender); + if (result.Failure.Code != NetPacket::DecodeError::TRUNCATED_ENVELOPE || !result.Events.empty()) { + Check(false, "every incomplete compressed envelope is rejected transactionally"); + break; + } + if (size + 1 == complete.size()) { + Check(true, "every incomplete compressed envelope is rejected transactionally"); + } + } + + NetPacket::DecodeResult header = NetPacket::Decode_Event_Packet(complete, NetPacket::Encoding::COMPRESSED, Sender); + Check(header.Succeeded() && header.Events.size() == 1, "a complete FRAMEINFO-only packet decodes"); + if (header.Succeeded() && header.Events.size() == 1) { + Check(header.Events[0].Event.Type == EventClass::FRAMEINFO, "FRAMEINFO is retained for the execution queue"); + Check(header.Events[0].Event.ID == Sender && header.Events[0].Event.Frame == Frame, + "FRAMEINFO carries the canonical sender and frame"); + Check(header.Events[0].Event.Data.FrameInfo.CRC == 0x12345678, + "FRAMEINFO carries its complete data prefix"); + } + + Check_Error( + NetPacket::Decode_Event_Packet(complete, NetPacket::Encoding::COMPRESSED, Sender + 1), + NetPacket::DecodeError::SENDER_MISMATCH, + "the envelope sender must match the demultiplexer sender"); + + for (NetPacket::Encoding encoding : {NetPacket::Encoding::COMPRESSED, NetPacket::Encoding::UNCOMPRESSED}) { + Bytes framesync = Envelope(EventClass::FRAMESYNC); + NetPacket::DecodeResult result = NetPacket::Decode_Event_Packet(framesync, encoding, Sender); + Check(result.Succeeded() && result.Events.empty() && result.HasEnvelope + && result.Envelope.Type == EventClass::FRAMESYNC, + "a sole short FRAMESYNC is accepted, exposed, and not queued"); + + framesync.push_back(std::byte{0}); + Check_Error( + NetPacket::Decode_Event_Packet(framesync, encoding, Sender), + NetPacket::DecodeError::FRAMESYNC_NOT_ALONE, + "FRAMESYNC rejects every trailing byte"); + } + + Bytes nested = Compressed_Packet(); + Add_Compressed_Event(nested, EventClass::FRAMEINFO); + Check_Error( + NetPacket::Decode_Event_Packet(nested, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::NESTED_ENVELOPE, + "a compressed packet rejects a nested envelope"); + + Bytes short_uncompressed = Envelope(EventClass::FRAMEINFO); + Check_Error( + NetPacket::Decode_Event_Packet(short_uncompressed, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::TRUNCATED_ENVELOPE, + "an uncompressed FRAMEINFO must carry the complete full event"); +} + + +void Test_Frame_Arithmetic(void) +{ + Bytes negative = Compressed_Packet(); + int const negative_frame = -1; + Write_Value(negative, FrameOffset, negative_frame); + Check_Error(NetPacket::Decode_Event_Packet(negative, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::INVALID_FRAME_ARITHMETIC, "a negative FRAMEINFO frame is rejected transactionally"); + + Bytes underflow = Compressed_Packet(); + int const early_frame = 3; + std::uint8_t const excessive_delay = 4; + Write_Value(underflow, FrameOffset, early_frame); + Write_Value(underflow, FrameDelayOffset, excessive_delay); + Check_Error(NetPacket::Decode_Event_Packet(underflow, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::INVALID_FRAME_ARITHMETIC, "a FRAMEINFO delay larger than its frame is rejected"); + + Bytes minimum = Envelope(EventClass::FRAMESYNC); + int const minimum_frame = (std::numeric_limits::min)(); + Write_Value(minimum, FrameOffset, minimum_frame); + Check_Error(NetPacket::Decode_Event_Packet(minimum, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::INVALID_FRAME_ARITHMETIC, "the minimum signed FRAMESYNC frame cannot overflow subtraction"); + + Check(NetPacket::Compute_Reported_Frame(4, 4) == 0, "a delay equal to its frame reports frame zero"); + Check(!NetPacket::Compute_Reported_Frame(3, 4), "checked sender-frame subtraction rejects underflow"); + Check(NetPacket::Compute_Reported_Frame(350, 0, 100, 250) == 350, "the maximum 250-frame sender lead is accepted"); + Check(!NetPacket::Compute_Reported_Frame(351, 0, 100, 250), "an excessive future sender frame is rejected"); + Check(NetPacket::Compute_Reported_Frame((std::numeric_limits::max)(), 0, (std::numeric_limits::max)(), 250).has_value(), + "receiver lead arithmetic remains safe at the signed-frame limit"); +} + + +Bytes Valid_Compressed_Event(std::uint8_t type) +{ + Bytes packet = Compressed_Packet(); + packet.push_back(static_cast(type)); + + if (type == EventClass::MEGAMISSION) { + packet.push_back(std::byte{1}); + } else if (type == EventClass::ADDPLAYER) { + std::uint32_t const size = 0; + Append_Value(packet, size); + } + + if (type != EventClass::ADDPLAYER) { + packet.insert(packet.end(), EventClass::EventLength[type], std::byte{0}); + } + return(packet); +} + + +void Test_Full_Compressed_Table(void) +{ + for (int index = 0; index < EventClass::LAST_EVENT; index++) { + std::uint8_t const type = static_cast(index); + if (type == EventClass::FRAMEINFO || type == EventClass::FRAMESYNC) { + continue; + } + + Bytes packet = Valid_Compressed_Event(type); + NetPacket::DecodeResult result = NetPacket::Decode_Event_Packet(packet, NetPacket::Encoding::COMPRESSED, Sender); + + char label[96]; + std::snprintf(label, sizeof(label), "compressed event %-18s decodes at its exact length", EventClass::EventNames[type]); + Check(result.Succeeded() && result.Events.size() == 2 && result.Events[1].Event.Type == type, label); + + std::size_t const variable_bytes = type == EventClass::MEGAMISSION ? 1 : 0; + std::size_t const required = EventClass::EventLength[type] + variable_bytes; + if (required == 0) { + continue; + } + + packet.pop_back(); + NetPacket::DecodeError expected = NetPacket::DecodeError::TRUNCATED_EVENT; + if (type == EventClass::ADDPLAYER) { + expected = NetPacket::DecodeError::TRUNCATED_ADDPLAYER; + } else if (type == EventClass::MEGAMISSION) { + expected = NetPacket::DecodeError::TRUNCATED_MEGAMISSION; + } + + std::snprintf(label, sizeof(label), "compressed event %-18s rejects one byte short", EventClass::EventNames[type]); + Check_Error(NetPacket::Decode_Event_Packet(packet, NetPacket::Encoding::COMPRESSED, Sender), expected, label); + } + + Bytes response = Compressed_Packet(); + std::byte const delay{42}; + Add_Compressed_Event(response, EventClass::RESPONSE_TIME, std::span(&delay, 1)); + NetPacket::DecodeResult decoded_response = NetPacket::Decode_Event_Packet(response, NetPacket::Encoding::COMPRESSED, Sender); + Check(decoded_response.Succeeded() && decoded_response.Events.size() == 2 + && decoded_response.Events[1].Event.Data.FrameInfo.Delay == 42, + "RESPONSE_TIME materializes its byte at FrameInfo.Delay"); + + Bytes report = Compressed_Packet(); + std::uint16_t const average = 17; + std::uint16_t const worst = 240; + Bytes report_data; + Append_Value(report_data, average); + Append_Value(report_data, worst); + Add_Compressed_Event(report, EventClass::NETWORK_REPORT, report_data); + NetPacket::DecodeResult decoded_report = NetPacket::Decode_Event_Packet(report, NetPacket::Encoding::COMPRESSED, Sender); + Check(decoded_report.Succeeded() && decoded_report.Events.size() == 2 + && decoded_report.Events[1].Event.Data.NetworkReport.AverageProcessMilliseconds == average + && decoded_report.Events[1].Event.Data.NetworkReport.WorstRoundTripMilliseconds == worst, + "NETWORK_REPORT preserves both millisecond fields"); +} + + +void Test_Mega_Mission(void) +{ + std::size_t const data_size = EventClass::EventLength[EventClass::MEGAMISSION]; + Bytes mission(data_size, std::byte{0}); + std::array const whom1{1, 101}; + std::array const whom2{2, 202}; + std::array const whom3{3, 303}; + std::memcpy(mission.data(), whom1.data(), sizeof(whom1)); + std::uint32_t const marker = 0xA1B2C3D4; + std::memcpy(mission.data() + MegaWhomSize, &marker, sizeof(marker)); + + Bytes packet = Compressed_Packet(); + packet.push_back(static_cast(EventClass::MEGAMISSION)); + packet.push_back(std::byte{3}); + Append_Bytes(packet, mission); + Append_Value(packet, whom2); + Append_Value(packet, whom3); + + NetPacket::DecodeResult result = NetPacket::Decode_Event_Packet(packet, NetPacket::Encoding::COMPRESSED, Sender); + Check(result.Succeeded() && result.Events.size() == 4, "a three-unit MEGAMISSION expands to three events"); + if (result.Succeeded() && result.Events.size() == 4) { + Check(std::memcmp(&result.Events[1].Event.Data.MegaMission.Whom, whom1.data(), sizeof(whom1)) == 0, + "the first MEGAMISSION keeps its full record"); + Check(std::memcmp(&result.Events[2].Event.Data.MegaMission.Whom, whom2.data(), sizeof(whom2)) == 0, + "the second MEGAMISSION substitutes its Whom field"); + Check(std::memcmp(&result.Events[3].Event.Data.MegaMission.Whom, whom3.data(), sizeof(whom3)) == 0, + "the third MEGAMISSION substitutes its Whom field"); + Check(std::memcmp( + reinterpret_cast(&result.Events[2].Event.Data.MegaMission) + MegaWhomSize, + mission.data() + MegaWhomSize, + mission.size() - MegaWhomSize) == 0, + "repeated MEGAMISSION events inherit mission, target, and destination"); + } + + Bytes zero = Compressed_Packet(); + zero.push_back(static_cast(EventClass::MEGAMISSION)); + zero.push_back(std::byte{0}); + zero.insert(zero.end(), data_size, std::byte{0}); + Check_Error( + NetPacket::Decode_Event_Packet(zero, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::ZERO_MEGAMISSION_COUNT, + "a zero MEGAMISSION count is rejected"); + + packet.pop_back(); + Check_Error( + NetPacket::Decode_Event_Packet(packet, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::TRUNCATED_MEGAMISSION, + "a truncated repeated MEGAMISSION rejects the whole packet"); +} + + +void Check_Add_Player(NetPacket::DecodeResult const & result, char const * what) +{ + bool valid = result.Succeeded() && result.Events.size() == 2; + if (valid) { + NetPacket::DecodedEvent const & event = result.Events[1]; + valid = event.Event.Type == EventClass::ADDPLAYER + && event.Event.Data.Variable.Size == 3 + && event.AddPlayerData.size() == 3 + && event.Event.Data.Variable.Pointer == event.AddPlayerData.data() + && std::to_integer(event.AddPlayerData[0]) == 0x11 + && std::to_integer(event.AddPlayerData[2]) == 0x33; + } + Check(valid, what); +} + + +void Test_Add_Player(void) +{ + Bytes payload{std::byte{0x11}, std::byte{0x22}, std::byte{0x33}}; + Bytes compressed = Compressed_Packet(); + compressed.push_back(static_cast(EventClass::ADDPLAYER)); + std::uint32_t const size = static_cast(payload.size()); + Append_Value(compressed, size); + Append_Bytes(compressed, payload); + + NetPacket::DecodeResult result = NetPacket::Decode_Event_Packet(compressed, NetPacket::Encoding::COMPRESSED, Sender); + Check_Add_Player(result, "compressed ADDPLAYER owns and binds its variable data"); + + NetPacket::DecodeResult copied = result; + Check_Add_Player(copied, "copying a decoded packet rebinds ADDPLAYER to the copied bytes"); + Check(copied.Events.size() == 2 && result.Events.size() == 2 + && copied.Events[1].Event.Data.Variable.Pointer != result.Events[1].Event.Data.Variable.Pointer, + "copied ADDPLAYER data does not point into the original result"); + + Bytes truncated = compressed; + truncated.pop_back(); + Check_Error( + NetPacket::Decode_Event_Packet(truncated, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::TRUNCATED_ADDPLAYER, + "compressed ADDPLAYER rejects a payload shorter than its declared size"); + + Bytes transactional = Compressed_Packet(); + std::uint32_t const value = 7; + Bytes value_data; + Append_Value(value_data, value); + Add_Compressed_Event(transactional, EventClass::GAMESPEED, value_data); + transactional.push_back(static_cast(EventClass::ADDPLAYER)); + Append_Value(transactional, size); + transactional.push_back(std::byte{0x11}); + Check_Error( + NetPacket::Decode_Event_Packet(transactional, NetPacket::Encoding::COMPRESSED, Sender), + NetPacket::DecodeError::TRUNCATED_ADDPLAYER, + "a late ADDPLAYER error discards every previously decoded event"); + + Bytes uncompressed = Full_Event(EventClass::FRAMEINFO); + Bytes add = Full_Event(EventClass::ADDPLAYER); + Write_Value(add, DataOffset + VariableSizeOffset, size); + Append_Bytes(uncompressed, add); + Append_Bytes(uncompressed, payload); + Check_Add_Player( + NetPacket::Decode_Event_Packet(uncompressed, NetPacket::Encoding::UNCOMPRESSED, Sender), + "uncompressed ADDPLAYER owns and binds its variable data"); + + uncompressed.pop_back(); + Check_Error( + NetPacket::Decode_Event_Packet(uncompressed, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::TRUNCATED_ADDPLAYER, + "uncompressed ADDPLAYER rejects a truncated owned payload"); +} + + +void Test_Uncompressed(void) +{ + Bytes packet = Full_Event(EventClass::FRAMEINFO); + Bytes speed = Full_Event(EventClass::GAMESPEED, Sender, Frame + 6); + int const value = 5; + Write_Value(speed, DataOffset, value); + Append_Bytes(packet, speed); + + NetPacket::DecodeResult result = NetPacket::Decode_Event_Packet(packet, NetPacket::Encoding::UNCOMPRESSED, Sender); + Check(result.Succeeded() && result.Events.size() == 2, "two complete uncompressed events decode transactionally"); + if (result.Succeeded() && result.Events.size() == 2) { + Check(result.Events[1].Event.Frame == Frame + 6 && result.Events[1].Event.Data.General.Value == value, + "an uncompressed event keeps its own common and data fields"); + Check(!result.Events[1].Event.IsExecuted, "received events are always materialized unexecuted"); + } + + Bytes wrong_sender = Full_Event(EventClass::FRAMEINFO); + Append_Bytes(wrong_sender, Full_Event(EventClass::GAMESPEED, Sender + 1)); + Check_Error( + NetPacket::Decode_Event_Packet(wrong_sender, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::SENDER_MISMATCH, + "every uncompressed event is bound to the demultiplexer sender"); + + Bytes nested = Full_Event(EventClass::FRAMEINFO); + Append_Bytes(nested, Full_Event(EventClass::FRAMEINFO)); + Check_Error( + NetPacket::Decode_Event_Packet(nested, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::NESTED_ENVELOPE, + "an uncompressed packet rejects a nested envelope"); + + Bytes unknown = Full_Event(EventClass::FRAMEINFO); + Append_Bytes(unknown, Full_Event(0xFF)); + Check_Error( + NetPacket::Decode_Event_Packet(unknown, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::INVALID_EVENT_TYPE, + "an uncompressed unknown type is rejected before table lookup"); + + Bytes trailing = packet; + trailing.push_back(std::byte{0}); + Check_Error( + NetPacket::Decode_Event_Packet(trailing, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::TRAILING_BYTES, + "an uncompressed packet rejects a trailing partial record"); +} + + +Bytes Datagram(Bytes const & payload) +{ + Bytes datagram; + std::uint32_t const crc = NetAdmission::Calculate_Datagram_CRC(payload); + Append_Value(datagram, crc); + Append_Bytes(datagram, payload); + return(datagram); +} + + +Bytes Connection_Packet(std::size_t header_size, std::uint8_t code, std::size_t payload_size) +{ + Bytes packet(header_size + payload_size, std::byte{0}); + std::uint16_t const magic = 0xCAFE; + std::uint32_t const packet_id = 0x12345678; + Write_Value(packet, 0, magic); + Write_Value(packet, sizeof(magic), code); + Write_Value(packet, sizeof(magic) + sizeof(code), packet_id); + return(packet); +} + + +void Check_Admission_Error(NetAdmission::Error actual, NetAdmission::Error expected, char const * what) +{ + Check(actual == expected, what); + if (actual != expected) { + std::printf(" got %s\n", NetAdmission::Error_Name(actual)); + } +} + + +void Test_Datagram_Admission(void) +{ + for (std::size_t size = 0; size <= sizeof(std::uint32_t); size++) { + Bytes short_datagram(size, std::byte{0}); + Check_Admission_Error(NetAdmission::Admit_Datagram(short_datagram).ErrorCode, + NetAdmission::Error::DATAGRAM_TOO_SHORT, + "every CRC-only or shorter datagram is rejected"); + } + + for (std::size_t payload_size : {1u, 2u, 3u, 4u, 5u, 767u, 768u}) { + Bytes payload(payload_size, std::byte{0x5A}); + NetAdmission::DatagramResult const admission = NetAdmission::Admit_Datagram(Datagram(payload)); + Check(admission.Succeeded() && admission.Payload.size() == payload_size, + "every legal datagram word/capacity boundary is accepted intact"); + } + + Bytes oversized_payload(NetAdmission::DATAGRAM_PAYLOAD_CAPACITY + 1, std::byte{0x33}); + Check_Admission_Error(NetAdmission::Admit_Datagram(Datagram(oversized_payload)).ErrorCode, + NetAdmission::Error::DATAGRAM_TOO_LARGE, + "a 769-byte transport payload is rejected rather than truncated"); + + Bytes damaged = Datagram(Bytes{std::byte{1}, std::byte{2}, std::byte{3}}); + damaged.back() ^= std::byte{0x80}; + Check_Admission_Error(NetAdmission::Admit_Datagram(damaged).ErrorCode, + NetAdmission::Error::BAD_CRC, "a damaged transport payload fails CRC admission"); + + Bytes aligned = Datagram(Bytes{std::byte{9}, std::byte{8}, std::byte{7}, std::byte{6}}); + Bytes unaligned(1, std::byte{0}); + Append_Bytes(unaligned, aligned); + NetAdmission::DatagramResult const admitted_unaligned = NetAdmission::Admit_Datagram( + std::span(unaligned).subspan(1)); + Check(admitted_unaligned.Succeeded() && admitted_unaligned.Payload.size() == 4, + "an unaligned datagram is decoded with copied packed reads"); +} + + +void Test_Connection_Admission(void) +{ + for (std::size_t size = 0; size < NetAdmission::PRIVATE_HEADER_SIZE; size++) { + Bytes packet(size, std::byte{0}); + Check_Admission_Error(NetAdmission::Admit_Connection_Packet( + packet, NetAdmission::PRIVATE_HEADER_SIZE, 64).ErrorCode, + NetAdmission::Error::HEADER_TOO_SHORT, + "every incomplete seven-byte private header is rejected"); + } + for (std::size_t size = 0; size < NetAdmission::GLOBAL_HEADER_SIZE; size++) { + Bytes packet(size, std::byte{0}); + Check_Admission_Error(NetAdmission::Admit_Connection_Packet( + packet, NetAdmission::GLOBAL_HEADER_SIZE, 64).ErrorCode, + NetAdmission::Error::HEADER_TOO_SHORT, + "every incomplete nine-byte global header is rejected"); + } + + for (std::size_t header_size : {NetAdmission::PRIVATE_HEADER_SIZE, NetAdmission::GLOBAL_HEADER_SIZE}) { + Bytes ack = Connection_Packet(header_size, + static_cast(NetAdmission::PacketCode::ACK), 0); + NetAdmission::ConnectionResult admitted = NetAdmission::Admit_Connection_Packet(ack, header_size, ack.size()); + Check(admitted.Succeeded() && admitted.Magic == 0xCAFE + && admitted.PacketID == 0x12345678 && admitted.Payload.empty(), + "an exact private/global ACK header is admitted and decoded"); + + ack.push_back(std::byte{0}); + Check_Admission_Error(NetAdmission::Admit_Connection_Packet(ack, header_size, ack.size()).ErrorCode, + NetAdmission::Error::INVALID_PACKET_LENGTH, + "an ACK with application bytes is rejected"); + + Bytes empty_data = Connection_Packet(header_size, + static_cast(NetAdmission::PacketCode::DATA_ACK), 0); + Check_Admission_Error(NetAdmission::Admit_Connection_Packet( + empty_data, header_size, empty_data.size()).ErrorCode, + NetAdmission::Error::INVALID_PACKET_LENGTH, + "a data header without application payload is rejected"); + + Bytes data = Connection_Packet(header_size, + static_cast(NetAdmission::PacketCode::DATA_NOACK), 1); + admitted = NetAdmission::Admit_Connection_Packet(data, header_size, data.size()); + Check(admitted.Succeeded() && admitted.Payload.size() == 1, + "the minimum one-byte application payload is accepted"); + Check_Admission_Error(NetAdmission::Validate_Destination(admitted.Payload, 0), + NetAdmission::Error::DESTINATION_TOO_SMALL, + "a destination overflow is rejected before copying"); + Check_Admission_Error(NetAdmission::Validate_Destination(admitted.Payload, 1), + NetAdmission::Error::NONE, + "an exact-capacity destination accepts the payload"); + + Check_Admission_Error(NetAdmission::Admit_Connection_Packet( + data, header_size, data.size() - 1).ErrorCode, + NetAdmission::Error::PACKET_TOO_LARGE, + "a message above its connection capacity is rejected"); + } + + Bytes invalid_code = Connection_Packet(NetAdmission::PRIVATE_HEADER_SIZE, + static_cast(NetAdmission::PacketCode::COUNT), 1); + Check_Admission_Error(NetAdmission::Admit_Connection_Packet( + invalid_code, NetAdmission::PRIVATE_HEADER_SIZE, invalid_code.size()).ErrorCode, + NetAdmission::Error::INVALID_PACKET_CODE, + "a packet code outside DATA/ACK is rejected"); + + Bytes aligned = Connection_Packet(NetAdmission::PRIVATE_HEADER_SIZE, + static_cast(NetAdmission::PacketCode::DATA_ACK), 1); + Bytes unaligned(1, std::byte{0}); + Append_Bytes(unaligned, aligned); + NetAdmission::ConnectionResult const admitted_unaligned = NetAdmission::Admit_Connection_Packet( + std::span(unaligned).subspan(1), NetAdmission::PRIVATE_HEADER_SIZE, aligned.size()); + Check(admitted_unaligned.Succeeded() && admitted_unaligned.PacketID == 0x12345678, + "an unaligned reliable-message header is decoded with memcpy"); +} + + +GlobalPacketType Global_Packet(NetCommandType command) +{ + GlobalPacketType packet = {}; + packet.Command = command; + packet.Name[0] = '\0'; + packet.Message.Buf[0] = '\0'; + return(packet); +} + + +NetGlobal::ValidationContext Member_Context(void) +{ + NetGlobal::ValidationContext context; + context.SenderIsMember = true; + context.SenderPlayerID = 2; + context.SenderPlayerColor = 3; + context.ActivePlayers[2] = true; + context.ActivePlayers[5] = true; + return(context); +} + + +void Check_Global_Error( + GlobalPacketType const & packet, + std::size_t length, + NetGlobal::ValidationContext const & context, + NetGlobal::DecodeError expected, + char const * what) +{ + NetGlobal::DecodeError const actual = NetGlobal::Validate_In_Game_Packet(packet, length, context); + Check(actual == expected, what); + if (actual != expected) { + std::printf(" got %s\n", NetGlobal::Error_Name(actual)); + } +} + + +void Test_Global_Packets(void) +{ + constexpr std::size_t packet_size = sizeof(GlobalPacketType); + NetGlobal::ValidationContext member = Member_Context(); + NetGlobal::ValidationContext outsider; + GlobalPacketType packet = Global_Packet(NET_QUERY_GAME); + GlobalPacketType poisoned; + std::memset(&poisoned, 0xA5, sizeof(poisoned)); + NetGlobal::Initialize_Packet(poisoned, NET_PROPOSE_KICK); + NetCommandType const initialized_command = NET_PROPOSE_KICK; + std::byte const * initialized_bytes = reinterpret_cast(&poisoned); + std::byte const * command_bytes = reinterpret_cast(&initialized_command); + bool fully_initialized = true; + for (std::size_t index = 0; index < sizeof(poisoned); index++) { + std::byte const expected = index < sizeof(initialized_command) + ? command_bytes[index] : std::byte{0}; + fully_initialized = fully_initialized && initialized_bytes[index] == expected; + } + + Check(fully_initialized, + "global packet initialization overwrites poison across the current packet shape"); + + std::array endpoints{{{0x01020304, 1000}, {0x01020304, 2000}, {0x01020304, 0}}}; + NetGlobal::EndpointResolution resolution = NetGlobal::Resolve_Sender({0x01020304, 2000}, endpoints); + Check(resolution.Error == NetGlobal::DecodeError::NONE && resolution.Match == NetGlobal::EndpointMatch::EXACT && resolution.RosterIndex == 1, + "an exact IP and port selects the matching same-NAT player"); + resolution = NetGlobal::Resolve_Sender({0x01020304, 3000}, endpoints); + Check(resolution.Error == NetGlobal::DecodeError::NONE && resolution.Match == NetGlobal::EndpointMatch::ZERO_PORT && resolution.RosterIndex == 2, + "one zero-port roster entry provides the legacy same-IP fallback"); + std::array duplicate_exact{{{0x01020304, 1000}, {0x01020304, 1000}}}; + Check(NetGlobal::Resolve_Sender({0x01020304, 1000}, duplicate_exact).Error == NetGlobal::DecodeError::AMBIGUOUS_SENDER, + "duplicate exact endpoints are rejected as ambiguous"); + std::array duplicate_wildcard{{{0x01020304, 0}, {0x01020304, 0}}}; + Check(NetGlobal::Resolve_Sender({0x01020304, 1000}, duplicate_wildcard).Error == NetGlobal::DecodeError::AMBIGUOUS_SENDER, + "multiple zero-port candidates on one IP are rejected as ambiguous"); + Check(NetGlobal::Resolve_Sender({0x05060708, 1000}, endpoints).Error == NetGlobal::DecodeError::SENDER_NOT_MEMBER, + "an unknown endpoint remains outside the session roster"); + Check_Global_Error(packet, packet_size - 1, outsider, NetGlobal::DecodeError::INVALID_LENGTH, + "a short global packet is rejected before dispatch"); + Check_Global_Error(packet, packet_size + 1, outsider, NetGlobal::DecodeError::INVALID_LENGTH, + "an oversized global packet is rejected before dispatch"); + Check_Global_Error(packet, packet_size, outsider, NetGlobal::DecodeError::NONE, + "game discovery remains public during a match"); + + packet = Global_Packet(NET_QUERY_PLAYER); + Check_Global_Error(packet, packet_size, outsider, NetGlobal::DecodeError::NONE, + "player discovery remains public during a match"); + std::memset(packet.Name, 'x', sizeof(packet.Name)); + Check_Global_Error(packet, packet_size, outsider, NetGlobal::DecodeError::UNTERMINATED_NAME, + "player discovery requires a terminated game name"); + + for (NetCommandType command : { + NET_SIGN_OFF, NET_MESSAGE, NET_PROGRESS_REPORT, NET_READY_TO_GO, NET_PROPOSE_KICK}) { + packet = Global_Packet(command); + packet.Kick.KickeeID = 5; + Check_Global_Error(packet, packet_size, outsider, NetGlobal::DecodeError::SENDER_NOT_MEMBER, + "session-control commands reject a source outside Session.Players"); + } + for (NetCommandType command : {NET_SIGN_OFF, NET_READY_TO_GO}) { + packet = Global_Packet(command); + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::NONE, + "sign-off and ready commands accept a matched session member"); + } + + packet = Global_Packet(static_cast(999)); + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::INVALID_COMMAND, + "the in-game callback rejects commands outside its explicit allowlist"); + + packet = Global_Packet(NET_MESSAGE); + std::memset(packet.Name, 'n', sizeof(packet.Name)); + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::UNTERMINATED_NAME, + "chat rejects an unterminated claimed name before ignoring it"); + packet = Global_Packet(NET_MESSAGE); + std::memset(packet.Message.Buf, 'm', sizeof(packet.Message.Buf)); + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::UNTERMINATED_MESSAGE, + "chat rejects an unterminated message body"); + packet = Global_Packet(NET_MESSAGE); + packet.Message.Color = 999; + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::NONE, + "chat ignores the wire color in favor of the matched member's color"); + NetGlobal::ValidationContext bad_color = member; + bad_color.SenderPlayerColor = MAX_MPLAYER_COLORS; + Check_Global_Error(packet, packet_size, bad_color, NetGlobal::DecodeError::INVALID_COLOR, + "chat refuses an invalid canonical session color"); + + packet = Global_Packet(NET_PROGRESS_REPORT); + packet.Progress.Percent = -1; + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::INVALID_PROGRESS, + "progress rejects a negative percentage"); + packet.Progress.Percent = 101; + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::INVALID_PROGRESS, + "progress rejects a percentage above 100"); + packet.Progress.Percent = 100; + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::NONE, + "progress preserves the legal 100-percent edge"); + + packet = Global_Packet(NET_PROPOSE_KICK); + packet.Kick.KickerID = UINT32_MAX; + packet.Kick.KickeeID = 5; + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::NONE, + "kick validation ignores the claimed voter and uses the matched member"); + packet.Kick.KickeeID = 2; + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::SELF_KICK, + "a member cannot vote to kick itself"); + packet.Kick.KickeeID = 7; + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::INVALID_KICK_PLAYER, + "a kick target must be a current session member"); + + NetGlobal::RejectionCounters counters; + NetGlobal::RejectionRecord first = counters.Record(NetGlobal::DecodeError::INVALID_LENGTH); + NetGlobal::RejectionRecord second = counters.Record(NetGlobal::DecodeError::INVALID_LENGTH); + NetGlobal::RejectionRecord third = counters.Record(NetGlobal::DecodeError::INVALID_LENGTH); + NetGlobal::RejectionRecord fourth = counters.Record(NetGlobal::DecodeError::INVALID_LENGTH); + Check(first.Count == 1 && first.ShouldLog, "the first global rejection is reported"); + Check(second.Count == 2 && second.ShouldLog, "the second global rejection is reported"); + Check(third.Count == 3 && !third.ShouldLog, "non-power-of-two global rejections stay quiet"); + Check(fourth.Count == 4 && fourth.ShouldLog, "power-of-two global rejections are reported"); + Check(counters.Count(NetGlobal::DecodeError::INVALID_LENGTH) == 4, + "global rejection counters retain a stable per-error total"); + Check(counters.Record(NetGlobal::DecodeError::NONE).Count == 0, + "successful packets do not enter rejection counters"); +} + +} // namespace + + +int main(void) +{ + Test_Reader(); + Test_Event_Contract(); + Test_Envelope_Rules(); + Test_Frame_Arithmetic(); + Test_Full_Compressed_Table(); + Test_Mega_Mission(); + Test_Add_Player(); + Test_Uncompressed(); + Test_Datagram_Admission(); + Test_Connection_Admission(); + Test_Global_Packets(); + + std::printf("\n%s\n", Failures == 0 ? "All checks passed." : "Some checks FAILED."); + return(Failures == 0 ? 0 : 1); +} diff --git a/tests/nettiming/CMakeLists.txt b/tests/nettiming/CMakeLists.txt new file mode 100644 index 00000000..1b61ab97 --- /dev/null +++ b/tests/nettiming/CMakeLists.txt @@ -0,0 +1,36 @@ +if(CMAKE_SOURCE_DIR STREQUAL CMAKE_CURRENT_SOURCE_DIR) + cmake_minimum_required(VERSION 3.23) + project(NetTimingContract LANGUAGES CXX) + enable_testing() + set(OPENTS_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../..") +else() + set(OPENTS_ROOT "${CMAKE_SOURCE_DIR}") +endif() + +# The timing sources are compiled straight into the harness so their deterministic policy +# can be exercised without the game, its network transport, or proprietary assets. +add_executable(NetTiming + "${CMAKE_CURRENT_SOURCE_DIR}/nettiming.cpp" + "${OPENTS_ROOT}/code/netsemantic.cpp" + "${OPENTS_ROOT}/code/nettime.cpp" + "${OPENTS_ROOT}/code/nettiming.cpp" +) + +target_compile_features(NetTiming PRIVATE cxx_std_20) + +target_include_directories(NetTiming PRIVATE "${OPENTS_ROOT}/code") + +target_compile_definitions(NetTiming PRIVATE WIN32 _WINDOWS _MBCS) + +target_compile_options(NetTiming PRIVATE + $<$:/MTd /EHsc /Zc:__cplusplus> + $<$:/MT /EHsc /Zc:__cplusplus> +) + +target_link_libraries(NetTiming PRIVATE kernel32 winmm) + +set_target_properties(NetTiming PROPERTIES + RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" +) + +add_test(NAME nettiming COMMAND NetTiming) diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp new file mode 100644 index 00000000..add692ef --- /dev/null +++ b/tests/nettiming/nettiming.cpp @@ -0,0 +1,859 @@ +/******************************************************************************* + * O P E N T S + ******************************************************************************* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright 2026 OpenTS contributors + * + * See LICENSE.md for applicable additional terms and warranty disclaimers. + ******************************************************************************/ + + +#include "netsemantic.h" +#include "nettiming.h" + +#include +#include +#include +#include +#include +#include +#include + + +namespace +{ + class FakeClock final : public NetTiming::MillisecondClock + { + public: + NetTiming::Milliseconds Now(void) const override {return(Current);} + void Set(NetTiming::Milliseconds now) {Current = now;} + + private: + NetTiming::Milliseconds Current = 0; + }; + + + class FakeTransport + { + public: + void Send(NetTiming::Milliseconds now) + { + Clock.Set(now); + FirstSend = now; + LastSend = now; + TransmissionCount = 1; + BaseRto = Estimator.Retransmit_Timeout(); + } + + bool Retry(NetTiming::Milliseconds now) + { + Clock.Set(now); + if (!NetTiming::Retransmit_Is_Due(LastSend, now, BaseRto, + TransmissionCount - 1, NetTiming::MINIMUM_CONNECTION_TIMEOUT)) { + return(false); + } + LastSend = now; + TransmissionCount++; + return(true); + } + + bool Acknowledge(NetTiming::Milliseconds now) + { + Clock.Set(now); + return(Estimator.Acknowledge(FirstSend, TransmissionCount, Clock)); + } + + NetTiming::RttEstimator const & Rtt(void) const {return(Estimator);} + + private: + FakeClock Clock; + NetTiming::RttEstimator Estimator; + NetTiming::Milliseconds FirstSend = 0; + NetTiming::Milliseconds LastSend = 0; + NetTiming::Milliseconds BaseRto = NetTiming::MINIMUM_RTO; + unsigned int TransmissionCount = 0; + }; + + + int Failures = 0; + + + template + void Expect_Equal(std::string const & name, Actual const & actual, Expected const & expected) + { + if (actual == expected) { + return; + } + + std::cerr << name << ": expected " << expected << ", got " << actual << '\n'; + Failures++; + } + + + void Expect(std::string const & name, bool condition) + { + if (!condition) { + std::cerr << name << " failed\n"; + Failures++; + } + } + + + void Test_Rtt_Estimator(void) + { + using namespace NetTiming; + + RttEstimator estimator; + Expect("estimator starts empty", !estimator.Has_Sample()); + Expect("first sample accepted", estimator.Add_Sample(100)); + Expect_Equal("first smoothed RTT", estimator.Smoothed_Rtt(), 100u); + Expect_Equal("first variation", estimator.Rtt_Variation(), 50u); + Expect_Equal("first RTO", estimator.Retransmit_Timeout(), 300u); + + Expect("second sample accepted", estimator.Add_Sample(140)); + Expect_Equal("alpha one eighth", estimator.Smoothed_Rtt(), 105u); + Expect_Equal("beta one quarter", estimator.Rtt_Variation(), 48u); + Expect_Equal("updated RTO", estimator.Retransmit_Timeout(), 297u); + + Expect("retransmitted sample rejected", !estimator.Add_Sample(900, true)); + Expect_Equal("Karn keeps smoothed RTT", estimator.Smoothed_Rtt(), 105u); + Expect_Equal("Karn keeps RTO", estimator.Retransmit_Timeout(), 297u); + + RttEstimator minimum; + minimum.Add_Sample(0); + Expect_Equal("minimum RTO clamp", minimum.Retransmit_Timeout(), MINIMUM_RTO); + + RttEstimator maximum; + maximum.Add_Sample(2000); + Expect_Equal("maximum RTO clamp", maximum.Retransmit_Timeout(), MAXIMUM_RTO); + + RttEstimator fast_link; + RttEstimator slow_link; + fast_link.Add_Sample(50); + slow_link.Add_Sample(300); + Expect("unequal links keep independent RTOs", + fast_link.Retransmit_Timeout() < slow_link.Retransmit_Timeout()); + + estimator.Reset(); + Expect("reset clears estimator", !estimator.Has_Sample()); + Expect_Equal("reset restores RTO", estimator.Retransmit_Timeout(), MINIMUM_RTO); + } + + + void Test_Clock_And_Wrap(void) + { + using namespace NetTiming; + + FakeClock clock; + clock.Set(0x00000020u); + RttEstimator estimator; + Expect("wrap sample accepted", estimator.Acknowledge(0xfffffff0u, 1, clock)); + Expect_Equal("wrap elapsed", estimator.Smoothed_Rtt(), 48u); + Expect("retransmitted acknowledgement ignored", !estimator.Acknowledge(0, 2, clock)); + + Expect("wrapped retry due", Retransmit_Is_Due(0xfffffff0u, 0x00000054u, 100, 0)); + Expect("wrapped retry not early", !Retransmit_Is_Due(0xfffffff0u, 0x00000040u, 100, 0)); + } + + + void Test_Retransmit_Backoff(void) + { + using namespace NetTiming; + + Expect_Equal("base retry", Retransmit_Delay(100, 0), 100u); + Expect_Equal("first backoff", Retransmit_Delay(100, 1), 200u); + Expect_Equal("second backoff", Retransmit_Delay(100, 2), 400u); + Expect_Equal("third backoff", Retransmit_Delay(100, 3), 800u); + Expect_Equal("fourth backoff", Retransmit_Delay(100, 4), 1600u); + Expect_Equal("backoff saturation", Retransmit_Delay(100, 20), MAXIMUM_RTO); + Expect_Equal("base clamp", Retransmit_Delay(1, 0), MINIMUM_RTO); + Expect_Equal("connection timeout minimum", Connection_Timeout(0), 2000u); + Expect_Equal("connection timeout follows RTT", Connection_Timeout(500), 4250u); + Expect_Equal("connection timeout ceiling", Connection_Timeout(10000), 30000u); + Expect_Equal("backoff reaches connection timeout", Retransmit_Delay(500, 8, 4250), 4250u); + } + + + void Test_Loss_Jitter_And_Reordering(void) + { + using namespace NetTiming; + + FakeClock clock; + RttEstimator reordered; + clock.Set(1200); + Expect("newer packet ACK samples first", reordered.Acknowledge(1100, 1, clock)); + clock.Set(1300); + Expect("older packet ACK can sample after reordering", reordered.Acknowledge(1000, 1, clock)); + Expect_Equal("reordered samples keep alpha filter", reordered.Smoothed_Rtt(), 125u); + Expect_Equal("reordered samples keep beta filter", reordered.Rtt_Variation(), 88u); + + clock.Set(2000); + Expect("duplicate ambiguous ACK is excluded by Karn", + !reordered.Acknowledge(1500, 2, clock)); + Expect_Equal("ambiguous ACK leaves SRTT unchanged", reordered.Smoothed_Rtt(), 125u); + + RttEstimator jitter; + for (Milliseconds sample : {20u, 400u, 35u, 350u, 40u}) { + jitter.Add_Sample(sample); + } + Expect("jitter raises variation", jitter.Rtt_Variation() > 0); + Expect("jittered RTO remains bounded", jitter.Retransmit_Timeout() >= MINIMUM_RTO + && jitter.Retransmit_Timeout() <= MAXIMUM_RTO); + + Expect("loss does not retransmit before the base RTO", + !Retransmit_Is_Due(1000, 1099, 100, 0, 2000)); + Expect("first loss retransmits at the base RTO", + Retransmit_Is_Due(1000, 1100, 100, 0, 2000)); + Expect("second loss waits for exponential backoff", + !Retransmit_Is_Due(1100, 1299, 100, 1, 2000)); + Expect("second loss retransmits at doubled RTO", + Retransmit_Is_Due(1100, 1300, 100, 1, 2000)); + + FakeTransport clean_transport; + clean_transport.Send(1000); + Expect("fake transport accepts a clean ACK sample", clean_transport.Acknowledge(1080)); + Expect_Equal("fake transport publishes clean RTT", clean_transport.Rtt().Smoothed_Rtt(), 80u); + + FakeTransport lossy_transport; + lossy_transport.Send(1000); + Expect("fake transport retries a lost packet", lossy_transport.Retry(1100)); + Expect("fake transport applies Karn after loss", !lossy_transport.Acknowledge(1180)); + Expect("lossy fake transport has no ambiguous RTT sample", !lossy_transport.Rtt().Has_Sample()); + } + + + void Test_Census(void) + { + using namespace NetTiming; + + TimingReportCensus census; + Expect("activate first peer", census.Set_Player_Active(1, true, 100)); + Expect("activate second peer", census.Set_Player_Active(2, true, 100)); + Expect("reject out of range peer", !census.Set_Player_Active(MAX_TIMING_PLAYERS, true, 100)); + Expect("active membership is queryable", census.Is_Player_Active(1)); + Expect("out of range membership is inactive", !census.Is_Player_Active(MAX_TIMING_PLAYERS)); + Expect("record first peer", census.Record_Report(1, 12, 80, 100)); + Expect("record second peer", census.Record_Report(2, 20, 180, 100)); + Expect("accept RTT above retransmit clamp", census.Record_Report(2, 20, MAXIMUM_RTO + 1, 100)); + Expect("reject process time beyond engine range", !census.Record_Report(2, MAXIMUM_PROCESS_MILLISECONDS + 1, 100, 150)); + Expect("reject RTT beyond wire range", !census.Record_Report(2, 1, MAXIMUM_REPORTED_RTT + 1, 150)); + + TimingCensus result = census.Inspect(200); + Expect_Equal("active peer count", result.ActivePlayers, 2u); + Expect_Equal("fresh process report count", result.FreshProcessReports, 2u); + Expect_Equal("fresh RTT report count", result.FreshRoundTripReports, 2u); + Expect_Equal("worst process time", result.WorstProcessMilliseconds, 20u); + Expect_Equal("unequal links publish worst", result.WorstRoundTrip, MAXIMUM_RTO + 1); + Expect("fresh process census complete", result.ProcessComplete); + Expect("fresh RTT census complete", result.RoundTripComplete); + Expect("fresh census is not conservative", !result.RequiresConservativeTiming); + BalancedTimingPolicy aggregate; + TimingEvaluation const guest_degradation = aggregate.Evaluate(result, 60, 200); + Expect("a guest-to-guest slow path worsens the master policy", guest_degradation.Changed && guest_degradation.Rung == MAXIMUM_TIMING_RUNG); + + result = census.Inspect(100 + REPORT_EXPIRY); + Expect("process reports expire on boundary", !result.ProcessComplete); + Expect("RTT reports expire on boundary", !result.RoundTripComplete); + Expect("established RTT expiry is conservative", result.RequiresConservativeTiming); + Expect_Equal("expired process reports not fresh", result.FreshProcessReports, 0u); + Expect_Equal("expired RTT reports not fresh", result.FreshRoundTripReports, 0u); + Expect_Equal("expired process time excluded", result.WorstProcessMilliseconds, 0u); + + Expect("departed peer removed", census.Set_Player_Active(2, false, 700)); + Expect("remaining peer refreshed", census.Record_Report(1, 15, 90, 700)); + result = census.Inspect(700); + Expect("departure restores complete process census", result.ProcessComplete); + Expect("departure restores complete RTT census", result.RoundTripComplete); + Expect_Equal("departed peer excluded", result.ActivePlayers, 1u); + Expect_Equal("remaining peer wins census", result.WorstRoundTrip, 90u); + + Expect("established unavailable RTT report accepted", census.Record_Report(1, 16, std::nullopt, 701)); + result = census.Inspect(701); + Expect("unavailable RTT retains fresh process time", result.ProcessComplete && result.FreshProcessReports == 1); + Expect("established unavailable RTT is incomplete", !result.RoundTripComplete); + Expect("established unavailable RTT is immediately conservative", result.RequiresConservativeTiming); + + TimingReportCensus grace; + Expect("activate grace peer", grace.Set_Player_Active(3, true, 1000)); + Expect("process-only initial report is accepted", grace.Record_Report(3, 30, std::nullopt, 1000)); + result = grace.Inspect(1000 + REPORT_EXPIRY - 1); + Expect("process-only report remains complete before expiry", result.ProcessComplete); + Expect("missing initial RTT is tolerated before expiry", !result.RequiresConservativeTiming); + result = grace.Inspect(1000 + REPORT_EXPIRY); + Expect("never-valid RTT becomes conservative at exact expiry", result.RequiresConservativeTiming); + Expect("never-valid RTT remains incomplete", !result.RoundTripComplete); + Expect("process data expires with its report", !result.ProcessComplete); + Expect_Equal("stale process data retains synchronized FPS", Select_Desired_Frame_Rate(result, 42, 60), 42u); + TimingCensus fresh_process; + fresh_process.WorstProcessMilliseconds = 50; + Expect_Equal("fresh process data respects game-speed FPS", Select_Desired_Frame_Rate(fresh_process, 42, 15), 15u); + fresh_process.WorstProcessMilliseconds = 0; + Expect_Equal("zero process time permits 60 FPS", Select_Desired_Frame_Rate(fresh_process, 42, 60), 60u); + + TimingReportCensus atomic; + atomic.Set_Player_Active(4, true, 0); + Expect("atomic baseline report accepted", atomic.Record_Report(4, 25, 125, 10)); + Expect("invalid process report rejected atomically", !atomic.Record_Report(4, MAXIMUM_PROCESS_MILLISECONDS + 1, 200, 20)); + Expect("invalid RTT report rejected atomically", !atomic.Record_Report(4, 50, MAXIMUM_REPORTED_RTT + 1, 20)); + result = atomic.Inspect(20); + Expect_Equal("invalid report preserves process time", result.WorstProcessMilliseconds, 25u); + Expect_Equal("invalid report preserves RTT", result.WorstRoundTrip, 125u); + Expect("removing a peer clears its complete report", atomic.Set_Player_Active(4, false, 30)); + Expect_Equal("removed peer no longer contributes", atomic.Inspect(30).ActivePlayers, 0u); + Expect("reactivated peer starts with a clean report", atomic.Set_Player_Active(4, true, 40)); + result = atomic.Inspect(40); + Expect("reactivated peer has no inherited process report", !result.ProcessComplete); + Expect("reactivated peer receives fresh RTT grace", !result.RequiresConservativeTiming); + } + + + void Test_Rungs(void) + { + using namespace NetTiming; + + Expect_Equal("initial FSR", Settings_For_Rung(INITIAL_TIMING_RUNG).FrameSendRate, 2u); + Expect_Equal("initial MaxAhead", Settings_For_Rung(INITIAL_TIMING_RUNG).MaxAhead, 6u); + Expect("default settings match the bootstrap rung", TimingSettings{} == Settings_For_Rung(INITIAL_TIMING_RUNG)); + Expect_Equal("best rung MaxAhead", Settings_For_Rung(1).MaxAhead, 4u); + Expect_Equal("worst rung MaxAhead", Settings_For_Rung(10).MaxAhead, 30u); + Expect("rung settings valid", Timing_Settings_Are_Valid(Settings_For_Rung(10))); + Expect("below-rung minimum invalid", !Timing_Settings_Are_Valid({3, 6})); + Expect("legacy two-period horizon can source a transition", Timing_Transition_Source_Is_Valid({3, 6})); + Expect("unaligned settings invalid", !Timing_Settings_Are_Valid({3, 10})); + + Expect_Equal("zero RTT selects best rung", Select_Timing_Settings(0, 60).FrameSendRate, 1u); + Expect_Equal("100 ms fits best rung", Select_Timing_Settings(100, 60).FrameSendRate, 1u); + Expect_Equal("101 ms advances a rung", Select_Timing_Settings(101, 60).FrameSendRate, 2u); + Expect_Equal("300 ms selects balanced rung", Select_Timing_Settings(300, 60).FrameSendRate, 5u); + TimingSettings const high_rtt = Select_Timing_Settings(2000, 60); + Expect_Equal("two-second RTT selects highest FSR", high_rtt.FrameSendRate, 10u); + Expect_Equal("two-second RTT carries needed aligned MaxAhead", high_rtt.MaxAhead, 70u); + TimingSettings const capped = Select_Timing_Settings(MAXIMUM_REPORTED_RTT, 60); + Expect_Equal("wire-maximum RTT selects highest FSR", capped.FrameSendRate, 10u); + Expect_Equal("highest rung caps at largest aligned horizon", capped.MaxAhead, 250u); + + Expect("alignment rejects zero period", !Align_Max_Ahead(10, 0)); + Expect_Equal("alignment reaches cap", *Align_Max_Ahead(249, 10), 250u); + Expect("alignment rejects over cap", !Align_Max_Ahead(250, 9)); + } + + + void Test_Connection_Quality(void) + { + using namespace NetTiming; + + Expect("rung one reports fast", Connection_Quality_For_Settings(Settings_For_Rung(1)) == ConnectionQuality::Fast); + Expect("rung two reports fast", Connection_Quality_For_Settings(Settings_For_Rung(2)) == ConnectionQuality::Fast); + Expect("rung three reports normal", Connection_Quality_For_Settings(Settings_For_Rung(3)) == ConnectionQuality::Normal); + Expect("rung four reports normal", Connection_Quality_For_Settings(Settings_For_Rung(4)) == ConnectionQuality::Normal); + Expect("rung five reports normal", Connection_Quality_For_Settings(Settings_For_Rung(5)) == ConnectionQuality::Normal); + Expect("rung six reports poor", Connection_Quality_For_Settings(Settings_For_Rung(6)) == ConnectionQuality::Poor); + Expect("rung seven reports poor", Connection_Quality_For_Settings(Settings_For_Rung(7)) == ConnectionQuality::Poor); + Expect("rung eight reports poor", Connection_Quality_For_Settings(Settings_For_Rung(8)) == ConnectionQuality::Poor); + Expect("rung nine reports bad", Connection_Quality_For_Settings(Settings_For_Rung(9)) == ConnectionQuality::Bad); + Expect("rung ten reports bad", Connection_Quality_For_Settings(Settings_For_Rung(10)) == ConnectionQuality::Bad); + Expect("bootstrap settings report fast", Connection_Quality_For_Settings({2, 6}) == ConnectionQuality::Fast); + Expect("fallback settings report normal", Connection_Quality_For_Settings({3, 9}) == ConnectionQuality::Normal); + Expect("extended conservative settings report bad", Connection_Quality_For_Settings({10, 250}) == ConnectionQuality::Bad); + Expect("invalid settings report bad", Connection_Quality_For_Settings({0, 0}) == ConnectionQuality::Bad); + Expect("extended fast-rung horizon reports bad", Connection_Quality_For_Settings({2, 8}) == ConnectionQuality::Bad); + } + + + void Test_Event_Semantics(void) + { + using namespace NetSemantic; + + Expect("zero index is valid", Index_Is_Valid(0, 8)); + Expect("last index is valid", Index_Is_Valid(7, 8)); + Expect("negative index is rejected", !Index_Is_Valid(-1, 8)); + Expect("one-past index is rejected", !Index_Is_Valid(8, 8)); + + Expect("game speed zero remains 60 FPS", Game_Speed_Is_Valid(0)); + Expect("game speed six remains valid", Game_Speed_Is_Valid(6)); + Expect("negative game speed is rejected", !Game_Speed_Is_Valid(-1)); + Expect("game speed seven is rejected", !Game_Speed_Is_Valid(7)); + Expect("latency fudge zero is valid", Latency_Fudge_Is_Valid(0)); + Expect("latency fudge three is valid", Latency_Fudge_Is_Valid(3)); + Expect("latency fudge four is rejected", !Latency_Fudge_Is_Valid(4)); + + Expect("animation sentinel is valid", Animation_Type_Is_Valid(-1, -1, 4)); + Expect("animation last index is valid", Animation_Type_Is_Valid(3, -1, 4)); + Expect("animation one-past index is rejected", !Animation_Type_Is_Valid(4, -1, 4)); + Expect("owner sentinel is valid", Animation_Owner_Is_Valid(-1, -1, 8)); + Expect("owner one-past index is rejected", !Animation_Owner_Is_Valid(8, -1, 8)); + + Expect("resolved master is authorized", Timing_Authority_Is_Valid(2, 2)); + Expect("guest timing authority is rejected", !Timing_Authority_Is_Valid(3, 2)); + Expect("unresolved timing authority is rejected", !Timing_Authority_Is_Valid(2, -1)); + + Expect("matching subject ownership is accepted", Subject_Owner_Is_Valid(3, 3)); + Expect("captured subject ownership is rejected", !Subject_Owner_Is_Valid(3, 4)); + Expect("missing subject owner is rejected", !Subject_Owner_Is_Valid(3, -1)); + + Expect("legacy response-time minimum is accepted", Response_Time_Is_Valid(2, 2, 0, false)); + Expect("legacy response time above the compressed cap is accepted", Response_Time_Is_Valid(255, 2, 0, false)); + Expect("legacy response time below minimum is rejected", !Response_Time_Is_Valid(1, 2, 0, false)); + Expect("compressed response time accepts two aligned periods", Response_Time_Is_Valid(6, 2, 3, true)); + Expect("compressed response time accepts the highest aligned horizon", Response_Time_Is_Valid(250, 2, 10, true)); + Expect("compressed response time accepts the highest rate-three horizon", Response_Time_Is_Valid(249, 2, 3, true)); + Expect("compressed response time rejects wire maximum above the cap", !Response_Time_Is_Valid(255, 2, 3, true)); + Expect("compressed response time rejects an invalid period", !Response_Time_Is_Valid(6, 2, 0, true)); + Expect("compressed response time rejects one period", !Response_Time_Is_Valid(3, 2, 3, true)); + Expect("compressed response time rejects misalignment", !Response_Time_Is_Valid(7, 2, 3, true)); + + std::optional settings = Decode_Timing_Settings(60, 9, 3); + Expect("timing look-ahead decodes directly", settings && *settings == NetTiming::TimingSettings{3, 9}); + Expect("zero desired FPS is rejected", !Decode_Timing_Settings(0, 9, 3)); + Expect("desired FPS above 60 is rejected", !Decode_Timing_Settings(61, 9, 3)); + Expect("zero send period is rejected", !Decode_Timing_Settings(60, 9, 0)); + Expect("below-minimum horizon is rejected", !Decode_Timing_Settings(60, 2, 3)); + Expect("unaligned horizon is rejected", !Decode_Timing_Settings(60, 10, 3)); + Expect("aligned 250-frame horizon is valid", Decode_Timing_Settings(60, 250, 10).has_value()); + Expect("horizon above 250 is rejected", !Decode_Timing_Settings(60, 251, 10)); + } + + + void Record_One(NetTiming::TimingReportCensus & census, NetTiming::Milliseconds rtt, std::uint32_t frame) + { + census.Record_Report(1, 10, rtt, frame); + } + + + void Test_Bootstrap_Cadence(void) + { + using namespace NetTiming; + + Expect("frame zero does not report", !Report_Is_Due(0)); + Expect("bootstrap reports at frame 32", Report_Is_Due(32)); + Expect("bootstrap reports at frame 64", Report_Is_Due(64)); + Expect("bootstrap does not add a frame 96 report", !Report_Is_Due(96)); + Expect("normal reports start at frame 128", Report_Is_Due(128)); + Expect("normal reports continue at frame 256", Report_Is_Due(256)); + Expect("normal reports continue at frame 384", Report_Is_Due(384)); + Expect("off-cadence reports remain disabled", !Report_Is_Due(385)); + + Expect("frame zero does not evaluate", !Evaluation_Is_Due(0)); + Expect("reports alone do not evaluate at frame 32", !Evaluation_Is_Due(32)); + Expect("bootstrap evaluates at frame 64", Evaluation_Is_Due(64)); + Expect("bootstrap evaluates again at frame 128", Evaluation_Is_Due(128)); + Expect("normal evaluations start at frame 256", Evaluation_Is_Due(256)); + Expect("frame 384 is not an evaluation", !Evaluation_Is_Due(384)); + Expect("normal evaluations continue at frame 512", Evaluation_Is_Due(512)); + } + + + void Test_Bootstrap_Policy(void) + { + using namespace NetTiming; + + TimingReportCensus low_reports; + low_reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy low; + Expect("new policy starts in bootstrap", low.Is_Bootstrapping()); + Expect("bootstrap starts at 2/6", low.Current_Settings() == TimingSettings{2, 6}); + TimingEvaluation result = low.Evaluate(low_reports.Inspect(32), 60, 32); + Expect("bootstrap does not evaluate before frame 64", !result.Evaluated); + Record_One(low_reports, 0, 38); + result = low.Evaluate(low_reports.Inspect(64), 60, 64); + Expect("complete low-latency census finishes at frame 64", result.Evaluated && result.Changed && !low.Is_Bootstrapping()); + Expect("low-latency bootstrap jumps directly to 1/4", low.Current_Settings() == TimingSettings{1, 4}); + result = low.Evaluate(low_reports.Inspect(255), 60, 255); + Expect("steady evaluation remains anchored before frame 256", !result.Evaluated); + Record_One(low_reports, 0, 256); + result = low.Evaluate(low_reports.Inspect(256), 60, 256); + Expect("steady evaluation is anchored at frame 256", result.Evaluated && !result.Changed); + + Expect("100 ms would select 1/4 without bootstrap headroom", + Select_Timing_Settings(100, 60, false) == TimingSettings{1, 4}); + Expect("100 ms retains 2/6 with bootstrap headroom", + Select_Timing_Settings(100, 60, true) == TimingSettings{2, 6}); + TimingReportCensus marginal_reports; + marginal_reports.Set_Player_Active(1, true, 0); + Record_One(marginal_reports, 100, 38); + BalancedTimingPolicy marginal; + result = marginal.Evaluate(marginal_reports.Inspect(64), 60, 64); + Expect("marginal bootstrap completes without changing 2/6", result.Evaluated && !result.Changed && !marginal.Is_Bootstrapping()); + + TimingReportCensus high_reports; + high_reports.Set_Player_Active(1, true, 0); + Record_One(high_reports, 2000, 38); + BalancedTimingPolicy high; + result = high.Evaluate(high_reports.Inspect(64), 60, 64); + Expect("high-latency bootstrap worsens directly", result.Changed && high.Current_Settings() == TimingSettings{10, 90}); + + TimingReportCensus delayed_reports; + delayed_reports.Set_Player_Active(1, true, 0); + delayed_reports.Record_Report(1, 10, std::nullopt, 38); + BalancedTimingPolicy delayed; + result = delayed.Evaluate(delayed_reports.Inspect(64), 60, 64); + Expect("incomplete frame 64 census keeps bootstrap open", result.Evaluated && !result.Changed && delayed.Is_Bootstrapping()); + delayed_reports.Record_Report(1, 10, 0, 70); + result = delayed.Evaluate(delayed_reports.Inspect(100), 60, 100); + Expect("completed census waits for frame 128", !result.Evaluated && delayed.Is_Bootstrapping()); + result = delayed.Evaluate(delayed_reports.Inspect(128), 60, 128); + Expect("second bootstrap evaluation accepts a complete census", result.Evaluated && result.Changed && !delayed.Is_Bootstrapping()); + Expect("frame 128 completion selects the measured target", delayed.Current_Settings() == TimingSettings{1, 4}); + + TimingReportCensus incomplete_reports; + incomplete_reports.Set_Player_Active(1, true, 0); + incomplete_reports.Record_Report(1, 10, std::nullopt, 38); + BalancedTimingPolicy incomplete; + incomplete.Evaluate(incomplete_reports.Inspect(64), 60, 64); + incomplete_reports.Record_Report(1, 10, std::nullopt, 70); + result = incomplete.Evaluate(incomplete_reports.Inspect(128), 60, 128); + Expect("incomplete final census falls back immediately", result.Evaluated && result.Changed && !incomplete.Is_Bootstrapping()); + Expect("incomplete bootstrap falls back to 3/9", incomplete.Current_Settings() == TimingSettings{3, 9}); + + TimingReportCensus lost_reports; + lost_reports.Set_Player_Active(1, true, 0); + lost_reports.Set_Player_Active(2, true, 0); + lost_reports.Record_Report(1, 10, 20, 38); + lost_reports.Record_Report(2, 10, std::nullopt, 38); + BalancedTimingPolicy lost; + result = lost.Evaluate(lost_reports.Inspect(64), 60, 64); + Expect("initial missing RTT keeps bootstrap open", result.Evaluated && !result.Changed && lost.Is_Bootstrapping()); + lost_reports.Record_Report(1, 10, std::nullopt, 70); + result = lost.Evaluate(lost_reports.Inspect(128), 60, 128); + Expect("established RTT loss remains immediately conservative", result.Changed && lost.Current_Settings() == TimingSettings{10, 250}); + + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(high_reports, 0, frame); + result = high.Evaluate(high_reports.Inspect(frame), 60, frame); + } + Expect("bootstrap cooldown leaves only two good evaluations by frame 768", !result.Changed && high.Good_Evaluations() == 2); + Record_One(high_reports, 0, 1024); + result = high.Evaluate(high_reports.Inspect(1024), 60, 1024); + Expect("normal hysteresis resumes after bootstrap cooldown", result.Changed && high.Current_Settings() == TimingSettings{9, 27}); + + high.Reset(); + Expect("reset starts a new bootstrap", high.Is_Bootstrapping()); + Expect("reset restores 2/6", high.Current_Settings() == TimingSettings{2, 6}); + + BalancedTimingPolicy handoff; + handoff.Reset_From({10, 70}, 0); + Expect("handoff does not regain bootstrap", !handoff.Is_Bootstrapping()); + Record_One(high_reports, 0, 64); + result = handoff.Evaluate(high_reports.Inspect(64), 60, 64); + Expect("handoff ignores bootstrap evaluation", !result.Evaluated && handoff.Current_Settings() == TimingSettings{10, 70}); + + TimingReportCensus resumed_reports; + resumed_reports.Set_Player_Active(1, true, 1024); + BalancedTimingPolicy resumed; + resumed.Reset(1024); + Expect_Equal("resumed bootstrap records its cadence origin", resumed.Cadence_Origin(), 1024u); + result = resumed.Evaluate(resumed_reports.Inspect(1056), 60, 1056); + Expect("resumed bootstrap does not evaluate after only 32 frames", !result.Evaluated); + Record_One(resumed_reports, 0, 1062); + result = resumed.Evaluate(resumed_reports.Inspect(1088), 60, 1088); + Expect("resumed bootstrap evaluates after 64 frames", result.Evaluated && result.Changed && !resumed.Is_Bootstrapping()); + Expect("resumed bootstrap selects its measured target", resumed.Current_Settings() == TimingSettings{1, 4}); + } + + + void Test_Hysteresis_And_Cooldown(void) + { + using namespace NetTiming; + + TimingReportCensus reports; + reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy policy; + policy.Reset_From({3, 9}, 0); + + Record_One(reports, 0, 256); + TimingEvaluation result = policy.Evaluate(reports.Inspect(256), 60, 256); + Expect("first good evaluation does not change", !result.Changed); + Record_One(reports, 0, 512); + result = policy.Evaluate(reports.Inspect(512), 60, 512); + Expect("second good evaluation does not change", !result.Changed); + Record_One(reports, 0, 768); + result = policy.Evaluate(reports.Inspect(768), 60, 768); + Expect("third good evaluation improves one rung", result.Changed); + Expect_Equal("one-rung improvement", policy.Current_Rung(), 2u); + + Record_One(reports, 0, 800); + result = policy.Evaluate(reports.Inspect(800), 60, 800); + Expect("evaluation interval enforced", !result.Evaluated); + Expect_Equal("cooldown leaves rung", policy.Current_Rung(), 2u); + + BalancedTimingPolicy headroom; + headroom.Reset_From({3, 9}, 0); + TimingReportCensus edge; + edge.Set_Player_Active(1, true, 0); + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(edge, 120, frame); + headroom.Evaluate(edge.Inspect(frame), 60, frame); + } + Expect_Equal("20 percent headroom blocks marginal improvement", headroom.Current_Rung(), 3u); + + Record_One(reports, 2000, 1024); + result = policy.Evaluate(reports.Inspect(1024), 60, 1024); + Expect("worsening is immediate", result.Changed); + Expect_Equal("worsening reaches required rung", policy.Current_Rung(), 10u); + Expect_Equal("highest rung retains measured horizon", + policy.Current_Settings().MaxAhead, 70u); + + for (std::uint32_t frame : {1280u, 1536u, 1792u}) { + Record_One(reports, 1300, frame); + result = policy.Evaluate(reports.Inspect(frame), 60, frame); + } + Expect("same-rung horizon reduction uses hysteresis", result.Changed); + Expect_Equal("same-rung horizon retains aligned need", + policy.Current_Settings().MaxAhead, 50u); + } + + + void Test_Stale_And_Long_Term_Recovery(void) + { + using namespace NetTiming; + + TimingReportCensus stale; + stale.Set_Player_Active(1, true, 0); + BalancedTimingPolicy stale_policy; + stale_policy.Reset_From({3, 9}, 0); + TimingEvaluation result = stale_policy.Evaluate(stale.Inspect(0), 60, 0); + Expect("startup waits for a complete census", !result.Changed); + Expect_Equal("startup keeps initial rung", stale_policy.Current_Rung(), 3u); + + stale.Record_Report(1, 10, 100, 256); + stale_policy.Evaluate(stale.Inspect(256), 60, 256); + result = stale_policy.Evaluate(stale.Inspect(256 + REPORT_EXPIRY), 60, 256 + REPORT_EXPIRY); + Expect("established stale report worsens policy", result.Changed); + Expect_Equal("established stale report chooses worst rung", stale_policy.Current_Rung(), 10u); + Expect_Equal("established stale report chooses conservative horizon", stale_policy.Current_Settings().MaxAhead, MAXIMUM_MAX_AHEAD); + + stale.Set_Player_Active(1, false, 1024); + for (std::uint32_t frame : {1024u, 1280u, 1536u}) { + stale_policy.Evaluate(stale.Inspect(frame), 60, frame); + } + Expect_Equal("departed peer allows recovery", stale_policy.Current_Rung(), 9u); + + TimingReportCensus reports; + reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy policy; + policy.Reset_From({3, 9}, 0); + std::uint32_t frame = EVALUATION_INTERVAL; + + auto evaluate = [&](Milliseconds rtt) { + Record_One(reports, rtt, frame); + policy.Evaluate(reports.Inspect(frame), 60, frame); + frame += EVALUATION_INTERVAL; + }; + + for (int cycle = 0; cycle < 5; cycle++) { + evaluate(2000); + evaluate(0); + evaluate(0); + evaluate(0); + } + Expect_Equal("repeated degradation and recovery remains stable", policy.Current_Rung(), 9u); + evaluate(0); + evaluate(0); + evaluate(0); + Expect_Equal("recovery remains possible after more than eight changes", policy.Current_Rung(), 8u); + } + + + void Test_Master_Handoff_State(void) + { + using namespace NetTiming; + + TimingReportCensus reports; + reports.Set_Player_Active(1, true, 1000); + BalancedTimingPolicy policy; + policy.Reset_From({10, 70}, 1000); + Expect("handoff restores authoritative settings", policy.Current_Settings() == TimingSettings{10, 70}); + Expect_Equal("handoff discards improvement evidence", policy.Good_Evaluations(), 0u); + + Record_One(reports, 0, 1000); + TimingEvaluation result = policy.Evaluate(reports.Inspect(1000), 60, 1000); + Expect("handoff starts an evaluation cooldown", !result.Evaluated); + Record_One(reports, 0, 1256); + result = policy.Evaluate(reports.Inspect(1256), 60, 1256); + Expect("one good evaluation preserves the handoff target", result.Evaluated && !result.Changed && policy.Current_Settings() == TimingSettings{10, 70}); + + TimingReportCensus recovery_reports; + recovery_reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy recover; + recover.Reset_From({10, 250}, 0); + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(recovery_reports, 0, frame); + result = recover.Evaluate(recovery_reports.Inspect(frame), 60, frame); + } + Expect("10/250 improves one rung after hysteresis", result.Changed && recover.Current_Settings() == TimingSettings{9, 27}); + + TimingReportCensus same_rung_reports; + same_rung_reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy same_rung; + same_rung.Reset_From({10, 70}, 0); + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(same_rung_reports, 1300, frame); + result = same_rung.Evaluate(same_rung_reports.Inspect(frame), 60, frame); + } + Expect("10/70 catches up toward 10/50 after hysteresis", result.Changed && same_rung.Current_Settings() == TimingSettings{10, 50}); + + TimingReportCensus legacy_reports; + legacy_reports.Set_Player_Active(1, true, 0); + legacy_reports.Record_Report(1, 10, 200, 256); + BalancedTimingPolicy legacy; + legacy.Reset_From({3, 6}, 0); + result = legacy.Evaluate(legacy_reports.Inspect(256), 60, 256); + Expect("adaptive policy recovers from a legacy two-period horizon", result.Changed && legacy.Current_Settings() == TimingSettings{3, 9}); + } + + + void Test_Staged_Decrease(void) + { + using namespace NetTiming; + + std::optional staged = Stage_Timing_Update({3, 9}, {1, 4}, 100); + Expect("decrease stages", staged && staged->Deferred); + Expect_Equal("old horizon and periods align", staged->ActivationFrame, 111u); + Expect_Equal("activation preserves most of the old horizon", staged->InitialMaxAhead, 6u); + Expect("staged update not early", !Timing_Update_Is_Due(110, staged->ActivationFrame)); + Expect("staged update due", Timing_Update_Is_Due(111, staged->ActivationFrame)); + Expect_Equal("first catch-up step removes one new period", *Next_Transition_Max_Ahead({1, 6}, {1, 4}), 5u); + Expect_Equal("second catch-up step reaches target", *Next_Transition_Max_Ahead({1, 5}, {1, 4}), 4u); + Expect_Equal("catch-up stays at target", *Next_Transition_Max_Ahead({1, 4}, {1, 4}), 4u); + + staged = Stage_Timing_Update({3, 9}, {2, 6}, 100); + Expect_Equal("both periods use LCM", staged->ActivationFrame, 114u); + Expect_Equal("adjacent decrease activates at target horizon", staged->InitialMaxAhead, 6u); + + staged = Stage_Timing_Update({10, 250}, {9, 27}, 100); + Expect_Equal("wide decrease aligns activation to both periods", staged->ActivationFrame, 360u); + Expect_Equal("wide decrease preserves a safe initial horizon", staged->InitialMaxAhead, 243u); + Expect_Equal("wide catch-up removes one new period", *Next_Transition_Max_Ahead({9, 243}, {9, 27}), 234u); + + staged = Stage_Timing_Update({10, 70}, {10, 50}, 100); + Expect_Equal("same-rate decrease drains at old horizon", staged->ActivationFrame, 170u); + Expect_Equal("same-rate decrease keeps one intermediate period", staged->InitialMaxAhead, 60u); + Expect_Equal("same-rate catch-up reaches requested horizon", *Next_Transition_Max_Ahead({10, 60}, {10, 50}), 50u); + + staged = Stage_Timing_Update({9, 234}, {8, 24}, 360); + Expect("replacement decrease restages from effective settings", staged && staged->Deferred); + Expect_Equal("replacement decrease safely rebases its horizon", staged->InitialMaxAhead, 232u); + + std::optional immediate = Stage_Timing_Update({1, 4}, {5, 15}, 100); + Expect("worsening applies immediately", immediate && !immediate->Deferred); + Expect_Equal("immediate frame", immediate->ActivationFrame, 100u); + Expect_Equal("immediate update uses requested horizon", immediate->InitialMaxAhead, 15u); + staged = immediate; + Expect("an immediate worse update replaces a pending decrease", staged && !staged->Deferred && staged->Settings == TimingSettings{5, 15}); + + immediate = Stage_Timing_Update({9, 234}, {10, 250}, 360); + Expect("conservative update cancels catch-up immediately", immediate && !immediate->Deferred && immediate->InitialMaxAhead == 250); + + Expect("zero-period staging rejected", !Stage_Timing_Update({0, 9}, {1, 4}, 100)); + Expect("unaligned staging rejected", !Stage_Timing_Update({3, 10}, {1, 4}, 100)); + std::optional const legacy_recovery = Stage_Timing_Update({3, 6}, {3, 9}, 100); + Expect("legacy response horizon can recover immediately", legacy_recovery && !legacy_recovery->Deferred); + Expect("overflowing staging rejected", !Stage_Timing_Update({10, 30}, {9, 27}, (std::numeric_limits::max)() - 10)); + Expect("catch-up rejects mismatched send periods", !Next_Transition_Max_Ahead({9, 243}, {8, 24})); + Expect("catch-up rejects invalid effective settings", !Next_Transition_Max_Ahead({9, 242}, {9, 27})); + } + + + struct TransitionTrace + { + std::vector> Changes; + std::vector CommandTargets; + + bool operator==(TransitionTrace const &) const = default; + }; + + + TransitionTrace Run_Transition(NetTiming::TimingSettings current, NetTiming::TimingSettings requested, std::uint32_t event_frame, std::uint32_t final_frame) + { + TransitionTrace trace; + std::optional const plan = NetTiming::Stage_Timing_Update(current, requested, event_frame); + if (!plan || !plan->Deferred) { + return(trace); + } + + NetTiming::TimingTransitionState transition{*plan}; + std::uint32_t const first_frame = event_frame - event_frame % current.FrameSendRate; + for (std::uint32_t frame = first_frame; frame <= final_frame; frame++) { + std::optional const advance = NetTiming::Advance_Timing_Transition(transition, current, frame); + if (!advance) { + trace.CommandTargets.clear(); + return(trace); + } + if (advance->Changed) { + current = advance->Settings; + trace.Changes.emplace_back(frame, current); + } + if (frame % current.FrameSendRate == 0) { + trace.CommandTargets.push_back(static_cast(frame) + current.MaxAhead); + } + if (advance->Complete) { + break; + } + } + return(trace); + } + + + void Test_Transition_Sequences(void) + { + using namespace NetTiming; + + for (std::pair const & transition : { + std::pair{TimingSettings{10, 250}, TimingSettings{9, 27}}, + std::pair{TimingSettings{10, 70}, TimingSettings{10, 50}}, + std::pair{TimingSettings{3, 9}, TimingSettings{2, 6}}, + std::pair{TimingSettings{2, 6}, TimingSettings{1, 4}}}) { + TransitionTrace const live = Run_Transition(transition.first, transition.second, 100, 700); + TransitionTrace const replay = Run_Transition(transition.first, transition.second, 100, 700); + Expect("live and replay transition steps are identical", live == replay); + Expect("a transition reaches its requested settings", !live.Changes.empty() && live.Changes.back().second == transition.second); + bool nondecreasing = !live.CommandTargets.empty(); + for (std::size_t index = 1; index < live.CommandTargets.size(); index++) { + nondecreasing = nondecreasing && live.CommandTargets[index] >= live.CommandTargets[index - 1]; + } + Expect("transition command targets never move backward", nondecreasing); + } + + std::optional const plan = Stage_Timing_Update({10, 250}, {9, 27}, 100); + TimingTransitionState state{*plan}; + TimingSettings current{10, 250}; + for (std::uint32_t frame = 100; frame <= 369; frame++) { + std::optional const advance = Advance_Timing_Transition(state, current, frame); + if (advance && advance->Changed) { + current = advance->Settings; + } + } + std::optional const replacement = Stage_Timing_Update(current, {8, 24}, 369); + Expect("an active catch-up can be safely replaced", replacement && replacement->Deferred && replacement->InitialMaxAhead >= current.MaxAhead - current.FrameSendRate); + std::optional const conservative = Stage_Timing_Update(current, {10, 250}, 369); + Expect("a fully conservative replacement applies immediately", conservative && !conservative->Deferred); + } +} + + +int main(void) +{ + Test_Rtt_Estimator(); + Test_Clock_And_Wrap(); + Test_Retransmit_Backoff(); + Test_Loss_Jitter_And_Reordering(); + Test_Census(); + Test_Rungs(); + Test_Connection_Quality(); + Test_Event_Semantics(); + Test_Bootstrap_Cadence(); + Test_Bootstrap_Policy(); + Test_Hysteresis_And_Cooldown(); + Test_Stale_And_Long_Term_Recovery(); + Test_Master_Handoff_State(); + Test_Staged_Decrease(); + Test_Transition_Sequences(); + + if (Failures != 0) { + std::cerr << Failures << " network timing checks failed\n"; + return(1); + } + + std::cout << "All network timing checks passed\n"; + return(0); +}