From 6c1b2414e79c793fe322321f6db27aa150df63e2 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 16:13:14 +0300 Subject: [PATCH 01/13] Add a bounded network packet decoder --- code/_event.cpp | 98 +++++ code/event.cpp | 83 ----- code/event.h | 9 + code/netpacket.cpp | 616 ++++++++++++++++++++++++++++++++ code/netpacket.h | 89 +++++ code/netreader.cpp | 51 +++ code/netreader.h | 47 +++ tests/CMakeLists.txt | 1 + tests/netpacket/CMakeLists.txt | 27 ++ tests/netpacket/netcontract.cpp | 491 +++++++++++++++++++++++++ 10 files changed, 1429 insertions(+), 83 deletions(-) create mode 100644 code/_event.cpp create mode 100644 code/netpacket.cpp create mode 100644 code/netpacket.h create mode 100644 code/netreader.cpp create mode 100644 code/netreader.h create mode 100644 tests/netpacket/CMakeLists.txt create mode 100644 tests/netpacket/netcontract.cpp diff --git a/code/_event.cpp b/code/_event.cpp new file mode 100644 index 0000000..6bbe8a1 --- /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/event.cpp b/code/event.cpp index 2466ea5..4f96caa 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -76,89 +76,6 @@ #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", -}; - - /*********************************************************************************************** * EventClass::EventClass -- Constructs event to transfer special flags. * * * diff --git a/code/event.h b/code/event.h index 5a3aaf5..6436a0e 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/netpacket.cpp b/code/netpacket.cpp new file mode 100644 index 0000000..7341834 --- /dev/null +++ b/code/netpacket.cpp @@ -0,0 +1,616 @@ +/******************************************************************************* + * 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 { + +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_SENDER_OFFSET = offsetof(EventClass, ID); +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. +NetPacketDecodeResult Failed(NetPacketDecodeError code, std::size_t offset, std::uint8_t event_type = NET_PACKET_NO_EVENT_TYPE) +{ + NetPacketDecodeResult 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(NetReaderClass & reader, PacketEnvelope & envelope, NetPacketDecodeFailure & 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 = NetPacketDecodeError::TRUNCATED_ENVELOPE; + failure.Offset = offset; + failure.EventType = type.value_or(NET_PACKET_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); +} + + +/// 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, NetPacketDecodeFailure & failure) +{ + length = EventClass::EventLength[type]; + if (length <= EVENT_DATA_SIZE) { + return(true); + } + + failure.Code = NetPacketDecodeError::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(NetReaderClass & reader, PendingEvent & event, std::size_t event_offset, NetPacketDecodeFailure & failure) +{ + auto size = reader.Read_Value(); + if (!size) { + failure.Code = NetPacketDecodeError::TRUNCATED_ADDPLAYER; + failure.Offset = event_offset; + failure.EventType = EventClass::ADDPLAYER; + return(false); + } + + auto data = reader.Take(*size); + if (!data) { + failure.Code = NetPacketDecodeError::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(NetReaderClass & reader, int frame, int sender, std::size_t event_offset, + std::vector & events, NetPacketDecodeFailure & failure) +{ + auto count = reader.Read_Value(); + if (!count) { + failure.Code = NetPacketDecodeError::TRUNCATED_MEGAMISSION; + failure.Offset = event_offset; + failure.EventType = EventClass::MEGAMISSION; + return(false); + } + if (*count == 0) { + failure.Code = NetPacketDecodeError::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 = NetPacketDecodeError::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 = NetPacketDecodeError::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. +NetPacketDecodeResult Materialize(std::vector pending) +{ + NetPacketDecodeResult 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. +NetPacketDecodeResult Materialize_Frame_Sync(PacketEnvelope const & envelope) +{ + NetPacketDecodeResult result = Materialize({Pending_From_Envelope(envelope)}); + result.Events.clear(); + return(result); +} + + +/// Decodes a compressed event packet transactionally. +NetPacketDecodeResult Decode_Compressed(std::span packet, int expected_sender) +{ + NetReaderClass reader(packet); + std::uint8_t const first_type = std::to_integer(packet.front()); + + if (!Is_Known_Event(first_type)) { + return(Failed(NetPacketDecodeError::INVALID_EVENT_TYPE, 0, first_type)); + } + if (!Is_Envelope(first_type)) { + return(Failed(NetPacketDecodeError::INVALID_PREFIX, 0, first_type)); + } + + PacketEnvelope envelope; + NetPacketDecodeFailure failure; + if (!Read_Envelope(reader, envelope, failure)) { + NetPacketDecodeResult result; + result.Failure = failure; + return(result); + } + if (envelope.Sender != expected_sender) { + return(Failed(NetPacketDecodeError::SENDER_MISMATCH, EVENT_SENDER_OFFSET, envelope.Type)); + } + if (envelope.Type == EventClass::FRAMESYNC) { + if (!reader.Empty()) { + return(Failed(NetPacketDecodeError::FRAMESYNC_NOT_ALONE, reader.Offset(), envelope.Type)); + } + return(Materialize_Frame_Sync(envelope)); + } + + // 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(NetPacketDecodeError::TRUNCATED_EVENT, event_offset)); + } + + std::uint8_t const type = *type_value; + if (!Is_Known_Event(type)) { + return(Failed(NetPacketDecodeError::INVALID_EVENT_TYPE, event_offset, type)); + } + if (Is_Envelope(type)) { + return(Failed(NetPacketDecodeError::NESTED_ENVELOPE, event_offset, type)); + } + + if (type == EventClass::MEGAMISSION) { + if (!Read_Compressed_Mega_Mission(reader, envelope.Frame, envelope.Sender, event_offset, events, failure)) { + NetPacketDecodeResult 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(NetPacketDecodeError::INVALID_EVENT_LENGTH, event_offset, type)); + } + if (!Read_Add_Player(reader, event, event_offset, failure)) { + NetPacketDecodeResult 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)) { + NetPacketDecodeResult result; + result.Failure = failure; + return(result); + } + auto data = reader.Take(data_length); + if (!data) { + return(Failed(NetPacketDecodeError::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, NetPacketDecodeFailure & failure) +{ + NetReaderClass 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 = NetPacketDecodeError::TRUNCATED_EVENT; + failure.Offset = event_offset; + failure.EventType = type.value_or(NET_PACKET_NO_EVENT_TYPE); + return(false); + } + if (!Is_Known_Event(*type)) { + failure.Code = NetPacketDecodeError::INVALID_EVENT_TYPE; + failure.Offset = event_offset; + failure.EventType = *type; + return(false); + } + if (*sender != expected_sender) { + failure.Code = NetPacketDecodeError::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. +NetPacketDecodeResult 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(NetPacketDecodeError::INVALID_EVENT_TYPE, 0, first_type)); + } + if (!Is_Envelope(first_type)) { + return(Failed(NetPacketDecodeError::INVALID_PREFIX, 0, first_type)); + } + + if (first_type == EventClass::FRAMESYNC) { + NetReaderClass reader(packet); + PacketEnvelope envelope; + NetPacketDecodeFailure failure; + if (!Read_Envelope(reader, envelope, failure)) { + NetPacketDecodeResult result; + result.Failure = failure; + return(result); + } + if (envelope.Sender != expected_sender) { + return(Failed(NetPacketDecodeError::SENDER_MISMATCH, EVENT_SENDER_OFFSET, envelope.Type)); + } + if (!reader.Empty()) { + return(Failed(NetPacketDecodeError::FRAMESYNC_NOT_ALONE, reader.Offset(), envelope.Type)); + } + return(Materialize_Frame_Sync(envelope)); + } + if (packet.size() < sizeof(EventClass)) { + return(Failed(NetPacketDecodeError::TRUNCATED_ENVELOPE, 0, first_type)); + } + + NetReaderClass reader(packet); + std::vector events; + NetPacketDecodeFailure failure; + + while (!reader.Empty()) { + std::size_t const event_offset = reader.Offset(); + if (reader.Remaining() < sizeof(EventClass)) { + return(Failed(NetPacketDecodeError::TRAILING_BYTES, event_offset)); + } + + auto bytes = reader.Take(sizeof(EventClass)); + PendingEvent event; + if (!Read_Full_Event(*bytes, event, event_offset, expected_sender, failure)) { + NetPacketDecodeResult result; + result.Failure = failure; + return(result); + } + + if (events.empty()) { + if (event.Type != EventClass::FRAMEINFO) { + return(Failed(NetPacketDecodeError::INVALID_PREFIX, event_offset, event.Type)); + } + } else if (Is_Envelope(event.Type)) { + return(Failed(NetPacketDecodeError::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(NetPacketDecodeError::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. +NetDecodedEvent::NetDecodedEvent(void) noexcept +{ + std::memset(&Event, 0, sizeof(Event)); +} + + +/// Owns one decoded event and its optional variable payload. +NetDecodedEvent::NetDecodedEvent(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. +NetDecodedEvent::NetDecodedEvent(NetDecodedEvent const & other) + : Event(other.Event), AddPlayerData(other.AddPlayerData) +{ + Bind_AddPlayer_Data(); +} + + +/// Moves a decoded event and repairs both payload pointers. +NetDecodedEvent::NetDecodedEvent(NetDecodedEvent && 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. +NetDecodedEvent & NetDecodedEvent::operator=(NetDecodedEvent 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. +NetDecodedEvent & NetDecodedEvent::operator=(NetDecodedEvent && 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 NetDecodedEvent::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 NetPacketDecodeResult::Succeeded(void) const noexcept +{ + return(Failure.Code == NetPacketDecodeError::NONE); +} + + +/// Decodes a complete event packet using its negotiated encoding. +NetPacketDecodeResult Decode_Event_Packet(std::span packet, NetPacketEncoding encoding, int expected_sender) +{ + if (packet.empty()) { + return(Failed(NetPacketDecodeError::EMPTY_PACKET, 0)); + } + + switch (encoding) { + case NetPacketEncoding::UNCOMPRESSED: + return(Decode_Uncompressed(packet, expected_sender)); + + case NetPacketEncoding::COMPRESSED: + return(Decode_Compressed(packet, expected_sender)); + } + + return(Failed(NetPacketDecodeError::INVALID_PREFIX, 0)); +} + + +/// Returns a stable packet-decode error name. +char const * Net_Packet_Error_Name(NetPacketDecodeError error) noexcept +{ + switch (error) { + case NetPacketDecodeError::NONE: return("none"); + case NetPacketDecodeError::EMPTY_PACKET: return("empty packet"); + case NetPacketDecodeError::INVALID_EVENT_TYPE: return("invalid event type"); + case NetPacketDecodeError::INVALID_PREFIX: return("invalid packet prefix"); + case NetPacketDecodeError::TRUNCATED_ENVELOPE: return("truncated packet envelope"); + case NetPacketDecodeError::FRAMESYNC_NOT_ALONE: return("framesync is not alone"); + case NetPacketDecodeError::NESTED_ENVELOPE: return("nested packet envelope"); + case NetPacketDecodeError::SENDER_MISMATCH: return("sender identity mismatch"); + case NetPacketDecodeError::INVALID_EVENT_LENGTH: return("invalid event length"); + case NetPacketDecodeError::TRUNCATED_EVENT: return("truncated event"); + case NetPacketDecodeError::ZERO_MEGAMISSION_COUNT: return("zero megamission count"); + case NetPacketDecodeError::TRUNCATED_MEGAMISSION: return("truncated megamission"); + case NetPacketDecodeError::TRUNCATED_ADDPLAYER: return("truncated add-player data"); + case NetPacketDecodeError::TRAILING_BYTES: return("trailing packet bytes"); + case NetPacketDecodeError::INVALID_CONNECTION: return("invalid connection"); + case NetPacketDecodeError::COUNT: break; + } + + return("unknown packet error"); +} diff --git a/code/netpacket.h b/code/netpacket.h new file mode 100644 index 0000000..aebff08 --- /dev/null +++ b/code/netpacket.h @@ -0,0 +1,89 @@ +/******************************************************************************* + * 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 + + +enum class NetPacketEncoding +{ + UNCOMPRESSED, + COMPRESSED, +}; + + +enum class NetPacketDecodeError +{ + NONE, + EMPTY_PACKET, + INVALID_EVENT_TYPE, + INVALID_PREFIX, + TRUNCATED_ENVELOPE, + FRAMESYNC_NOT_ALONE, + NESTED_ENVELOPE, + SENDER_MISMATCH, + INVALID_EVENT_LENGTH, + TRUNCATED_EVENT, + ZERO_MEGAMISSION_COUNT, + TRUNCATED_MEGAMISSION, + TRUNCATED_ADDPLAYER, + TRAILING_BYTES, + INVALID_CONNECTION, + COUNT, +}; + + +constexpr std::uint8_t NET_PACKET_NO_EVENT_TYPE = UINT8_MAX; + + +struct NetPacketDecodeFailure +{ + NetPacketDecodeError Code = NetPacketDecodeError::NONE; + std::size_t Offset = 0; + std::uint8_t EventType = NET_PACKET_NO_EVENT_TYPE; +}; + + +struct NetDecodedEvent +{ + NetDecodedEvent(void) noexcept; + NetDecodedEvent(EventClass const & event, std::vector add_player_data) noexcept; + NetDecodedEvent(NetDecodedEvent const & other); + NetDecodedEvent(NetDecodedEvent && other) noexcept; + NetDecodedEvent & operator=(NetDecodedEvent const & other); + NetDecodedEvent & operator=(NetDecodedEvent && other) noexcept; + + EventClass Event; + std::vector AddPlayerData; + + private: + void Bind_AddPlayer_Data(void) noexcept; +}; + + +struct NetPacketDecodeResult +{ + bool Succeeded(void) const noexcept; + + NetPacketDecodeFailure Failure; + EventClass Envelope; + bool HasEnvelope = false; + std::vector Events; +}; + + +NetPacketDecodeResult Decode_Event_Packet(std::span packet, NetPacketEncoding encoding, int expected_sender); + +char const * Net_Packet_Error_Name(NetPacketDecodeError error) noexcept; diff --git a/code/netreader.cpp b/code/netreader.cpp new file mode 100644 index 0000000..7af55f1 --- /dev/null +++ b/code/netreader.cpp @@ -0,0 +1,51 @@ +/******************************************************************************* + * 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" + + +/// Starts a bounded read over packet bytes. +NetReaderClass::NetReaderClass(std::span data) noexcept + : Data(data), Position(0) +{ +} + + +/// Returns the current read offset. +std::size_t NetReaderClass::Offset(void) const noexcept +{ + return(Position); +} + + +/// Returns the unread byte count. +std::size_t NetReaderClass::Remaining(void) const noexcept +{ + return(Data.size() - Position); +} + + +/// Checks whether all packet bytes were consumed. +bool NetReaderClass::Empty(void) const noexcept +{ + return(Remaining() == 0); +} + + +/// Advances over a bounded span of packet bytes. +std::optional> NetReaderClass::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 0000000..7236df9 --- /dev/null +++ b/code/netreader.h @@ -0,0 +1,47 @@ +/******************************************************************************* + * 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 + + +class NetReaderClass +{ + public: + explicit NetReaderClass(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/tests/CMakeLists.txt b/tests/CMakeLists.txt index 5de329d..12d3dd5 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -1,3 +1,4 @@ add_subdirectory(gamedirs) add_subdirectory(logstress) add_subdirectory(cpudetect) +add_subdirectory(netpacket) diff --git a/tests/netpacket/CMakeLists.txt b/tests/netpacket/CMakeLists.txt new file mode 100644 index 0000000..e406e49 --- /dev/null +++ b/tests/netpacket/CMakeLists.txt @@ -0,0 +1,27 @@ +# 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/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" +) + +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 0000000..af7e452 --- /dev/null +++ b/tests/netpacket/netcontract.cpp @@ -0,0 +1,491 @@ +/******************************************************************************* + * 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 "netpacket.h" +#include "netreader.h" + +#include +#include +#include +#include +#include +#include +#include +#include + + +namespace { + +using Bytes = std::vector; +using VariableDataType = decltype(std::declval().Data.Variable); + +constexpr int Sender = 3; +constexpr int Frame = 120; +constexpr std::size_t DataOffset = offsetof(EventClass, Data); +constexpr std::size_t EnvelopeSize = DataOffset + sizeof(std::declval().Data.FrameInfo); +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( + NetPacketDecodeResult const & result, + NetPacketDecodeError 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", Net_Packet_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); + + NetReaderClass 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_Layout(void) +{ + Check(EventClass::LATENCYFUDGE == 35, "the last inherited event keeps numeric ID 35"); + Check(EventClass::NETWORK_REPORT == 36, "NETWORK_REPORT is appended as numeric ID 36"); + Check(EventClass::LAST_EVENT == 37, "LAST_EVENT advances without renumbering old events"); + Check(EventClass::EventLength[EventClass::NETWORK_REPORT] == 4, "NETWORK_REPORT has a four-byte wire payload"); + 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"); + Check(sizeof(EventClass) == 46 && EnvelopeSize == 17, "full and envelope event layouts match the legacy wire"); +} + + +void Test_Envelope_Rules(void) +{ + Check_Error( + Decode_Event_Packet({}, NetPacketEncoding::COMPRESSED, Sender), + NetPacketDecodeError::EMPTY_PACKET, + "an empty compressed packet is rejected"); + + Bytes invalid_prefix{static_cast(EventClass::GAMESPEED)}; + Check_Error( + Decode_Event_Packet(invalid_prefix, NetPacketEncoding::COMPRESSED, Sender), + NetPacketDecodeError::INVALID_PREFIX, + "a compressed packet must begin with FRAMEINFO or FRAMESYNC"); + + Bytes unknown{static_cast(0xFF)}; + Check_Error( + Decode_Event_Packet(unknown, NetPacketEncoding::COMPRESSED, Sender), + NetPacketDecodeError::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); + NetPacketDecodeResult result = Decode_Event_Packet(truncated, NetPacketEncoding::COMPRESSED, Sender); + if (result.Failure.Code != NetPacketDecodeError::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"); + } + } + + NetPacketDecodeResult header = Decode_Event_Packet(complete, NetPacketEncoding::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( + Decode_Event_Packet(complete, NetPacketEncoding::COMPRESSED, Sender + 1), + NetPacketDecodeError::SENDER_MISMATCH, + "the envelope sender must match the demultiplexer sender"); + + for (NetPacketEncoding encoding : {NetPacketEncoding::COMPRESSED, NetPacketEncoding::UNCOMPRESSED}) { + Bytes framesync = Envelope(EventClass::FRAMESYNC); + NetPacketDecodeResult result = 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( + Decode_Event_Packet(framesync, encoding, Sender), + NetPacketDecodeError::FRAMESYNC_NOT_ALONE, + "FRAMESYNC rejects every trailing byte"); + } + + Bytes nested = Compressed_Packet(); + Add_Compressed_Event(nested, EventClass::FRAMEINFO); + Check_Error( + Decode_Event_Packet(nested, NetPacketEncoding::COMPRESSED, Sender), + NetPacketDecodeError::NESTED_ENVELOPE, + "a compressed packet rejects a nested envelope"); + + Bytes short_uncompressed = Envelope(EventClass::FRAMEINFO); + Check_Error( + Decode_Event_Packet(short_uncompressed, NetPacketEncoding::UNCOMPRESSED, Sender), + NetPacketDecodeError::TRUNCATED_ENVELOPE, + "an uncompressed FRAMEINFO must carry the complete full event"); +} + + +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); + NetPacketDecodeResult result = Decode_Event_Packet(packet, NetPacketEncoding::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(); + NetPacketDecodeError expected = NetPacketDecodeError::TRUNCATED_EVENT; + if (type == EventClass::ADDPLAYER) { + expected = NetPacketDecodeError::TRUNCATED_ADDPLAYER; + } else if (type == EventClass::MEGAMISSION) { + expected = NetPacketDecodeError::TRUNCATED_MEGAMISSION; + } + + std::snprintf(label, sizeof(label), "compressed event %-18s rejects one byte short", EventClass::EventNames[type]); + Check_Error(Decode_Event_Packet(packet, NetPacketEncoding::COMPRESSED, Sender), expected, label); + } + + Bytes response = Compressed_Packet(); + std::byte const delay{42}; + Add_Compressed_Event(response, EventClass::RESPONSE_TIME, std::span(&delay, 1)); + NetPacketDecodeResult decoded_response = Decode_Event_Packet(response, NetPacketEncoding::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); + NetPacketDecodeResult decoded_report = Decode_Event_Packet(report, NetPacketEncoding::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); + + NetPacketDecodeResult result = Decode_Event_Packet(packet, NetPacketEncoding::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( + Decode_Event_Packet(zero, NetPacketEncoding::COMPRESSED, Sender), + NetPacketDecodeError::ZERO_MEGAMISSION_COUNT, + "a zero MEGAMISSION count is rejected"); + + packet.pop_back(); + Check_Error( + Decode_Event_Packet(packet, NetPacketEncoding::COMPRESSED, Sender), + NetPacketDecodeError::TRUNCATED_MEGAMISSION, + "a truncated repeated MEGAMISSION rejects the whole packet"); +} + + +void Check_Add_Player(NetPacketDecodeResult const & result, char const * what) +{ + bool valid = result.Succeeded() && result.Events.size() == 2; + if (valid) { + NetDecodedEvent 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); + + NetPacketDecodeResult result = Decode_Event_Packet(compressed, NetPacketEncoding::COMPRESSED, Sender); + Check_Add_Player(result, "compressed ADDPLAYER owns and binds its variable data"); + + NetPacketDecodeResult 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( + Decode_Event_Packet(truncated, NetPacketEncoding::COMPRESSED, Sender), + NetPacketDecodeError::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( + Decode_Event_Packet(transactional, NetPacketEncoding::COMPRESSED, Sender), + NetPacketDecodeError::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( + Decode_Event_Packet(uncompressed, NetPacketEncoding::UNCOMPRESSED, Sender), + "uncompressed ADDPLAYER owns and binds its variable data"); + + uncompressed.pop_back(); + Check_Error( + Decode_Event_Packet(uncompressed, NetPacketEncoding::UNCOMPRESSED, Sender), + NetPacketDecodeError::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); + + NetPacketDecodeResult result = Decode_Event_Packet(packet, NetPacketEncoding::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( + Decode_Event_Packet(wrong_sender, NetPacketEncoding::UNCOMPRESSED, Sender), + NetPacketDecodeError::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( + Decode_Event_Packet(nested, NetPacketEncoding::UNCOMPRESSED, Sender), + NetPacketDecodeError::NESTED_ENVELOPE, + "an uncompressed packet rejects a nested envelope"); + + Bytes unknown = Full_Event(EventClass::FRAMEINFO); + Append_Bytes(unknown, Full_Event(0xFF)); + Check_Error( + Decode_Event_Packet(unknown, NetPacketEncoding::UNCOMPRESSED, Sender), + NetPacketDecodeError::INVALID_EVENT_TYPE, + "an uncompressed unknown type is rejected before table lookup"); + + Bytes trailing = packet; + trailing.push_back(std::byte{0}); + Check_Error( + Decode_Event_Packet(trailing, NetPacketEncoding::UNCOMPRESSED, Sender), + NetPacketDecodeError::TRAILING_BYTES, + "an uncompressed packet rejects a trailing partial record"); +} + +} // namespace + + +int main(void) +{ + Test_Reader(); + Test_Layout(); + Test_Envelope_Rules(); + Test_Full_Compressed_Table(); + Test_Mega_Mission(); + Test_Add_Player(); + Test_Uncompressed(); + + std::printf("\n%s\n", Failures == 0 ? "All checks passed." : "Some checks FAILED."); + return(Failures == 0 ? 0 : 1); +} From 796003a820162e5fe9b1b5ecd45c684a21636bba Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 16:50:48 +0300 Subject: [PATCH 02/13] Harden network packet admission --- code/connect.cpp | 208 +++++++++-- code/connect.h | 35 +- code/connmgr.h | 2 +- code/conquer.cpp | 192 +++++----- code/ipxgconn.cpp | 95 +++-- code/ipxgconn.h | 15 +- code/ipxmgr.cpp | 41 ++- code/ipxmgr.h | 6 +- code/mainloop.cpp | 1 + code/netadmit.cpp | 136 +++++++ code/netadmit.h | 76 ++++ code/netdlg2.cpp | 31 +- code/netglobal.cpp | 177 +++++++++ code/netglobal.h | 75 ++++ code/netshare.cpp | 12 +- code/queue.cpp | 627 +++++++++++++------------------- code/queue.h | 8 +- code/sendfile.cpp | 16 +- code/wsproto.cpp | 132 +++++-- code/wsproto.h | 13 + code/wspudp.cpp | 74 ++-- tests/netpacket/CMakeLists.txt | 5 + tests/netpacket/netcontract.cpp | 302 ++++++++++++++- 23 files changed, 1610 insertions(+), 669 deletions(-) create mode 100644 code/netadmit.cpp create mode 100644 code/netadmit.h create mode 100644 code/netglobal.cpp create mode 100644 code/netglobal.h diff --git a/code/connect.cpp b/code/connect.cpp index 028634b..8248f13 100644 --- a/code/connect.cpp +++ b/code/connect.cpp @@ -129,6 +129,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,6 +187,7 @@ void ConnectionClass::Init (void) PercentLost = 0; MissedOverall = 0; MissedMagic = 0; + memset(DroppedPackets, 0, sizeof(DroppedPackets)); LastSeqID = 0xffffffff; LastReadID = 0xffffffff; @@ -219,6 +221,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 +287,34 @@ int ConnectionClass::Send_Packet (void * buf, int buflen, int ack_req) *=========================================================================*/ int ConnectionClass::Receive_Packet (void * buf, int buflen) { + CommHeaderType packet_header; // 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)}; + } + NetConnectionAdmission const admission = Admit_Connection_Packet( + packet_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + if (!admission.Succeeded()) { + Record_Admission_Drop(admission.Error, 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 +335,19 @@ 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)}; + } + NetConnectionAdmission const entry = 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 +410,19 @@ 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)}; + } + NetConnectionAdmission const entry = 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 +473,22 @@ 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)}; + } + NetConnectionAdmission const entry = 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 +519,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 +531,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 +555,58 @@ 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)}; + } + NetConnectionAdmission const admission = Admit_Connection_Packet( + entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + if (!admission.Succeeded()) { + rec_entry->IsRead = 1; + Record_Admission_Drop(admission.Error, 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); + NetAdmissionError const destination = Validate_Network_Destination( + admission.Payload, static_cast(capacity)); + if (destination != NetAdmissionError::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); + NetAdmissionError const destination = Validate_Network_Destination( + admission.Payload, static_cast(capacity)); + if (destination != NetAdmissionError::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 +617,81 @@ int ConnectionClass::Get_Packet (void * buf, int *buflen) } /* end of Get_Packet */ +namespace { + +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 the number of packets rejected for one stable admission reason. +unsigned int ConnectionClass::Dropped_Packets(PacketDropReasonType reason) const +{ + if (reason < 0 || reason >= CONNECTION_DROP_COUNT) { + return(0); + } + + return(DroppedPackets[reason]); +} + + +/// Records and rate-limits diagnostics for one rejected packet. +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 one shared admission failure to the connection's stable drop counters. +void ConnectionClass::Record_Admission_Drop(NetAdmissionError error, unsigned char code) +{ + switch (error) { + case NetAdmissionError::HEADER_TOO_SHORT: + case NetAdmissionError::DATAGRAM_TOO_SHORT: + Record_Packet_Drop(CONNECTION_DROP_SHORT_HEADER); + break; + case NetAdmissionError::PACKET_TOO_LARGE: + case NetAdmissionError::DATAGRAM_TOO_LARGE: + Record_Packet_Drop(CONNECTION_DROP_OVERSIZED_DATA); + break; + case NetAdmissionError::INVALID_PACKET_CODE: + Record_Packet_Drop(CONNECTION_DROP_INVALID_CODE); + break; + case NetAdmissionError::INVALID_PACKET_LENGTH: + Record_Packet_Drop(code == PACKET_ACK + ? CONNECTION_DROP_INVALID_LENGTH : CONNECTION_DROP_EMPTY_DATA); + break; + case NetAdmissionError::DESTINATION_TOO_SMALL: + Record_Packet_Drop(CONNECTION_DROP_OUTPUT_TOO_SMALL); + break; + case NetAdmissionError::BAD_CRC: + Record_Packet_Drop(CONNECTION_DROP_INVALID_LENGTH); + break; + case NetAdmissionError::NONE: + case NetAdmissionError::COUNT: + break; + } +} + + /*************************************************************************** * ConnectionClass::Service -- main polling routine; services packets * * * diff --git a/code/connect.h b/code/connect.h index a7e8f23..8270f29 100644 --- a/code/connect.h +++ b/code/connect.h @@ -97,6 +97,7 @@ ********************************* Includes ********************************** */ #include "combuf.h" +#include "netadmit.h" /* ********************************** Defines ********************************** @@ -114,9 +115,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,10 +134,10 @@ 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(NetPacketCode::DATA_ACK), // this is a data packet requiring an ACK + PACKET_DATA_NOACK = static_cast(NetPacketCode::DATA_NOACK), // this is a data packet not requiring an ACK + PACKET_ACK = static_cast(NetPacketCode::ACK), // this is an ACK for a packet + PACKET_COUNT = static_cast(NetPacketCode::COUNT) // for computational purposes }; /*..................................................................... @@ -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 @@ -192,6 +193,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. @@ -216,6 +229,11 @@ class ConnectionClass .....................................................................*/ virtual int Send(char *buf, int buflen, void *extrabuf, int extralen) = 0; + /// Returns whether this channel represents one peer with adaptive link timing. + virtual bool Adaptive_Timing_Enabled(void) const {return(true);} + void Record_Packet_Drop(PacketDropReasonType reason); + /// Maps a shared admission failure to this connection's stable counter. + void Record_Admission_Drop(NetAdmissionError error, unsigned char code); /* * This is the number of times a packet had to be transmitted again because no ACK @@ -242,6 +260,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. diff --git a/code/connmgr.h b/code/connmgr.h index e816c4a..82ef04e 100644 --- a/code/connmgr.h +++ b/code/connmgr.h @@ -97,7 +97,7 @@ class ConnManClass .....................................................................*/ 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, + virtual int Get_Private_Message (void *buf, int capacity, int *buflen, int *conn_id) = 0; /*..................................................................... diff --git a/code/conquer.cpp b/code/conquer.cpp index 7ace5dd..653d5d8 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" @@ -523,6 +524,54 @@ bool MapGen_Call_Back(void) } +static NetGlobalRejectionCounters GlobalPacketRejections; + + +/// Records a rejected global packet. +static void Record_Global_Packet_Rejection(NetGlobalDecodeError error) +{ + NetGlobalRejectionRecord const record = GlobalPacketRejections.Record(error); + if (record.ShouldLog) { + DebugString("In-game global packet drop [%s]: %u\n", Net_Global_Error_Name(error), record.Count); + } +} + + +/// Resolves a registered packet source. +static NodeNameType * Session_Member_From_Address(IPXAddressClass & address, int & player_index) +{ + player_index = -1; + for (int index = 0; index < Session.Players.Count(); index++) { + NodeNameType * player = Session.Players[index]; + if (player != NULL && player->Address == address) { + player_index = index; + return(player); + } + } + return(NULL); +} + + +/// Builds the membership facts used to validate a global packet. +static NetGlobalValidationContext Global_Validation_Context(NodeNameType const * sender) +{ + NetGlobalValidationContext 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 +589,68 @@ 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; + NodeNameType * sender = Session_Member_From_Address(Session.GAddress, sender_index); + NetGlobalValidationContext const context = Global_Validation_Context(sender); + NetGlobalDecodeError error = Validate_In_Game_Global(Session.GPacket, Session.GPacketlen, context); - 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 != NetGlobalDecodeError::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 != NetGlobalDecodeError::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); + } + + Map.Flag_To_Redraw(GS_REDRAW_ALL); + 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(NetGlobalDecodeError::INVALID_COMMAND); + break; } } } diff --git a/code/ipxgconn.cpp b/code/ipxgconn.cpp index f861d1f..b823660 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)}; + } + NetConnectionAdmission const packet = Admit_Connection_Packet(packet_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); + if (!packet.Succeeded()) { + Record_Admission_Drop(packet.Error, 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)}; + } + NetConnectionAdmission const entry = 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)}; + } + NetConnectionAdmission const admission = Admit_Connection_Packet(entry_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); + if (!admission.Succeeded()) { + rec_entry->IsRead = 1; + Record_Admission_Drop(admission.Error, 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); + NetAdmissionError const destination = Validate_Network_Destination(admission.Payload, static_cast(capacity)); + if (destination != NetAdmissionError::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 c0012fd..6cd0e6b 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 e8e0cfc..8fe5d52 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,25 @@ 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,7 +1038,7 @@ 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; @@ -1043,19 +1055,22 @@ int IPXManagerClass::Service(void) ** 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) { + int frame_info_size = sizeof(CommHeaderType) + offsetof(EventClass, Data) + + size_of(EventClass, Data.FrameInfo); + if (Frame > 8 && packetlen >= frame_info_size) { 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)); + unsigned char event_type; + int id; + memcpy(&event_type, temp_receive_buffer + sizeof(CommHeaderType), sizeof(event_type)); + memcpy(&id, temp_receive_buffer + sizeof(CommHeaderType) + offsetof(EventClass, ID), sizeof(id)); /* ** 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; - + if (event_type == EventClass::FRAMESYNC) { assert (id != PlayerPtr->ID); for ( int i=1 ; iPlayer.ID == id) { diff --git a/code/ipxmgr.h b/code/ipxmgr.h index e2cd899..f7b4e32 100644 --- a/code/ipxmgr.h +++ b/code/ipxmgr.h @@ -198,12 +198,12 @@ class IPXManagerClass : public ConnManClass .....................................................................*/ 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, + 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 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 +214,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 @@ -304,6 +305,7 @@ class IPXManagerClass : public ConnManClass .....................................................................*/ int SendOverflows; int ReceiveOverflows; + int ReceiveDiscards; int BadConnection; }; diff --git a/code/mainloop.cpp b/code/mainloop.cpp index ee46616..22cbf4a 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 0000000..80d3562 --- /dev/null +++ b/code/netadmit.cpp @@ -0,0 +1,136 @@ +/******************************************************************************* + * 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 { + +constexpr std::size_t CRC_SIZE = sizeof(std::uint32_t); +constexpr std::size_t COMMON_HEADER_SIZE = NET_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_Network_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. +NetDatagramAdmission Admit_Network_Datagram(std::span datagram, std::size_t payload_capacity) noexcept +{ + NetDatagramAdmission result; + if (datagram.size() <= CRC_SIZE) { + result.Error = NetAdmissionError::DATAGRAM_TOO_SHORT; + return(result); + } + + result.Payload = datagram.subspan(CRC_SIZE); + if (result.Payload.size() > payload_capacity) { + result.Payload = {}; + result.Error = NetAdmissionError::DATAGRAM_TOO_LARGE; + return(result); + } + + std::memcpy(&result.WireCRC, datagram.data(), sizeof(result.WireCRC)); + if (result.WireCRC != Calculate_Network_Datagram_CRC(result.Payload)) { + result.Payload = {}; + result.Error = NetAdmissionError::BAD_CRC; + } + return(result); +} + + +/// Validates a reliable-channel packet envelope. +NetConnectionAdmission Admit_Connection_Packet(std::span packet, std::size_t header_size, std::size_t packet_capacity) noexcept +{ + NetConnectionAdmission result; + if (header_size < COMMON_HEADER_SIZE || packet.size() < header_size) { + result.Error = NetAdmissionError::HEADER_TOO_SHORT; + return(result); + } + if (packet.size() > packet_capacity) { + result.Error = NetAdmissionError::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(NetPacketCode::COUNT)) { + result.Error = NetAdmissionError::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(NetPacketCode::ACK) && !result.Payload.empty(); + bool const data_has_no_payload = + (result.Code == static_cast(NetPacketCode::DATA_ACK) + || result.Code == static_cast(NetPacketCode::DATA_NOACK)) + && result.Payload.empty(); + if (ack_has_payload || data_has_no_payload) { + result.Payload = {}; + result.Error = NetAdmissionError::INVALID_PACKET_LENGTH; + } + return(result); +} + + +/// Checks that a caller can hold an admitted payload. +NetAdmissionError Validate_Network_Destination(std::span payload, std::size_t destination_capacity) noexcept +{ + return(payload.size() <= destination_capacity ? NetAdmissionError::NONE : NetAdmissionError::DESTINATION_TOO_SMALL); +} + + +/// Returns a stable admission-error name. +char const * Net_Admission_Error_Name(NetAdmissionError error) noexcept +{ + switch (error) { + case NetAdmissionError::NONE: return("none"); + case NetAdmissionError::DATAGRAM_TOO_SHORT: return("datagram too short"); + case NetAdmissionError::DATAGRAM_TOO_LARGE: return("datagram too large"); + case NetAdmissionError::BAD_CRC: return("bad datagram CRC"); + case NetAdmissionError::HEADER_TOO_SHORT: return("message header too short"); + case NetAdmissionError::PACKET_TOO_LARGE: return("message too large"); + case NetAdmissionError::INVALID_PACKET_CODE: return("invalid message code"); + case NetAdmissionError::INVALID_PACKET_LENGTH: return("invalid message length"); + case NetAdmissionError::DESTINATION_TOO_SMALL: return("destination too small"); + case NetAdmissionError::COUNT: break; + } + return("unknown admission error"); +} diff --git a/code/netadmit.h b/code/netadmit.h new file mode 100644 index 0000000..a26a2e8 --- /dev/null +++ b/code/netadmit.h @@ -0,0 +1,76 @@ +/******************************************************************************* + * 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 + + +constexpr std::size_t NET_DATAGRAM_PAYLOAD_CAPACITY = 768; +constexpr std::size_t NET_PRIVATE_HEADER_SIZE = sizeof(std::uint16_t) + sizeof(std::uint8_t) + sizeof(std::uint32_t); +constexpr std::size_t NET_GLOBAL_HEADER_SIZE = NET_PRIVATE_HEADER_SIZE + sizeof(std::uint16_t); + + +enum class NetPacketCode : std::uint8_t +{ + DATA_ACK, + DATA_NOACK, + ACK, + COUNT, +}; + + +enum class NetAdmissionError +{ + 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 NetDatagramAdmission +{ + NetAdmissionError Error = NetAdmissionError::NONE; + std::uint32_t WireCRC = 0; + std::span Payload; + + bool Succeeded(void) const noexcept {return(Error == NetAdmissionError::NONE);} +}; + + +struct NetConnectionAdmission +{ + NetAdmissionError Error = NetAdmissionError::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(Error == NetAdmissionError::NONE);} +}; + + +std::uint32_t Calculate_Network_Datagram_CRC(std::span payload) noexcept; + +NetDatagramAdmission Admit_Network_Datagram(std::span datagram, std::size_t payload_capacity = NET_DATAGRAM_PAYLOAD_CAPACITY) noexcept; + +NetConnectionAdmission Admit_Connection_Packet(std::span packet, std::size_t header_size, std::size_t packet_capacity) noexcept; + +NetAdmissionError Validate_Network_Destination(std::span payload, std::size_t destination_capacity) noexcept; + +char const * Net_Admission_Error_Name(NetAdmissionError error) noexcept; diff --git a/code/netdlg2.cpp b/code/netdlg2.cpp index 65b91c1..ac1d053 100644 --- a/code/netdlg2.cpp +++ b/code/netdlg2.cpp @@ -301,7 +301,7 @@ void Net2ServiceGameList(void) Net2DisplayUsers(); } else if (TickCount - Session.Chat[i]->Chat.LastTime > 5 * TIMER_SECOND && Session.Chat[i]->Chat.LastChance == 0) { - GlobalPacketType packet; + GlobalPacketType packet = {}; memset (&packet, 0, sizeof(GlobalPacketType)); strcpy(packet.Name, Session.Handle); packet.Command = NET_CHAT_REQUEST; @@ -747,7 +747,7 @@ bool Net2Remote_Connect(void) // If I'm not joined to a game, send a SIGN_OFF to all players // in my Chat vector (but not to myself, index 0) //............................................................... - GlobalPacketType gpacket; + GlobalPacketType gpacket = {}; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_SIGN_OFF; strcpy(gpacket.Name, Session.Handle); @@ -985,7 +985,7 @@ bool Net2Remote_Connect(void) // Send all players the NET_GO packet. Wait until all ACK's have been // received. //..................................................................... - GlobalPacketType gpacket; + GlobalPacketType gpacket = {}; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_GO; gpacket.ResponseTime.OneWay = Session.MaxAhead; @@ -1015,7 +1015,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) { @@ -1159,7 +1159,7 @@ BOOL CALLBACK MPlayer_Game_List_Dialog_Proc(HWND window, UINT message, WPARAM wp PMessagePrintf(ColorMe, "[%s] %s", Session.Handle, text); - GlobalPacketType gpacket; + GlobalPacketType gpacket = {}; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_MESSAGE; @@ -1552,7 +1552,7 @@ BOOL CALLBACK MPlayer_Host_Dialog_Proc(HWND window, UINT message, WPARAM wparam, PMessagePrintf(ColorMe, "[%s] %s", Session.Handle, text); - GlobalPacketType gpacket; + GlobalPacketType gpacket = {}; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_MESSAGE; @@ -1987,7 +1987,7 @@ static int Request_To_Join(int join_index) static void Unjoin_Game(int game_index) { int i; - GlobalPacketType packet; + GlobalPacketType packet = {}; //------------------------------------------------------------------------ // Fill in a SIGN_OFF packet @@ -2078,7 +2078,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 +2185,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 +2303,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; } @@ -2562,7 +2563,7 @@ static void Get_Join_Responses(void) // properly removed from their dialogs. //..................................................................... if ( JoinState == JOIN_CONFIRMED) { - GlobalPacketType packet; + GlobalPacketType packet = {}; memset (&packet, 0, sizeof(GlobalPacketType)); packet.Command = NET_SIGN_OFF; @@ -2910,7 +2911,7 @@ static void Get_Join_Responses(void) //------------------------------------------------------------------------ if (Session.GPacket.Command==NET_CHAT_REQUEST) { if (JoinState != JOIN_WAIT_CONFIRM && JoinState != JOIN_CONFIRMED) { - GlobalPacketType packet; + GlobalPacketType packet = {}; memset (&packet, 0, sizeof(GlobalPacketType)); @@ -2955,7 +2956,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 +3192,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); @@ -3374,7 +3375,7 @@ BOOL CALLBACK MPlayer_Guest_Dialog_Proc(HWND window, UINT message, WPARAM wparam if (len > 2) { PMessagePrintf(ColorMe, "[%s] %s", Session.Handle, text); - GlobalPacketType gpacket; + GlobalPacketType gpacket = {}; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_MESSAGE; diff --git a/code/netglobal.cpp b/code/netglobal.cpp new file mode 100644 index 0000000..2b83f54 --- /dev/null +++ b/code/netglobal.cpp @@ -0,0 +1,177 @@ +/******************************************************************************* + * 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 { + +static_assert(std::is_trivially_copyable_v); + + +/// 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(NetGlobalValidationContext 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_Global_Packet(GlobalPacketType & packet, NetCommandType command) noexcept +{ + std::memset(&packet, 0, sizeof(packet)); + packet.Command = command; +} + + +/// Identifies public in-game discovery commands. +bool Net_Global_Command_Is_Public(NetCommandType command) +{ + return(command == NET_QUERY_GAME || command == NET_QUERY_PLAYER); +} + + +/// Identifies commands restricted to session members. +bool Net_Global_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. +NetGlobalDecodeError Validate_In_Game_Global(GlobalPacketType const & packet, std::size_t packet_length, NetGlobalValidationContext const & context) +{ + if (packet_length != NET_GLOBAL_PACKET_SIZE) { + return(NetGlobalDecodeError::INVALID_LENGTH); + } + + bool const is_public = Net_Global_Command_Is_Public(packet.Command); + bool const requires_member = Net_Global_Command_Requires_Member(packet.Command); + if (!is_public && !requires_member) { + return(NetGlobalDecodeError::INVALID_COMMAND); + } + if (requires_member && !context.SenderIsMember) { + return(NetGlobalDecodeError::SENDER_NOT_MEMBER); + } + + switch (packet.Command) { + case NET_QUERY_PLAYER: + if (!Has_Terminator(packet.Name, sizeof(packet.Name))) { + return(NetGlobalDecodeError::UNTERMINATED_NAME); + } + break; + + case NET_MESSAGE: + if (!Has_Terminator(packet.Name, sizeof(packet.Name))) { + return(NetGlobalDecodeError::UNTERMINATED_NAME); + } + if (!Has_Terminator(packet.Message.Buf, sizeof(packet.Message.Buf))) { + return(NetGlobalDecodeError::UNTERMINATED_MESSAGE); + } + if (context.SenderPlayerColor < 0 || context.SenderPlayerColor >= MAX_MPLAYER_COLORS) { + return(NetGlobalDecodeError::INVALID_COLOR); + } + break; + + case NET_PROGRESS_REPORT: + if (packet.Progress.Percent < 0 || packet.Progress.Percent > 100) { + return(NetGlobalDecodeError::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(NetGlobalDecodeError::INVALID_KICK_PLAYER); + } + if (context.SenderPlayerID == static_cast(packet.Kick.KickeeID)) { + return(NetGlobalDecodeError::SELF_KICK); + } + break; + } + + default: + break; + } + + return(NetGlobalDecodeError::NONE); +} + + +/// Counts a rejection and selects sparse diagnostics. +NetGlobalRejectionRecord NetGlobalRejectionCounters::Record(NetGlobalDecodeError error) noexcept +{ + std::size_t const index = static_cast(error); + if (error == NetGlobalDecodeError::NONE || index >= Counts.size()) { + return(NetGlobalRejectionRecord{}); + } + + std::uint32_t & count = Counts[index]; + if (count != std::numeric_limits::max()) { + count++; + } + + return(NetGlobalRejectionRecord{count, count == 1 || (count & (count - 1)) == 0}); +} + + +/// Returns one rejection category's count. +std::uint32_t NetGlobalRejectionCounters::Count(NetGlobalDecodeError 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 * Net_Global_Error_Name(NetGlobalDecodeError error) noexcept +{ + switch (error) { + case NetGlobalDecodeError::NONE: return("none"); + case NetGlobalDecodeError::INVALID_LENGTH: return("invalid length"); + case NetGlobalDecodeError::INVALID_COMMAND: return("invalid command"); + case NetGlobalDecodeError::SENDER_NOT_MEMBER: return("sender is not a session member"); + case NetGlobalDecodeError::UNTERMINATED_NAME: return("unterminated player name"); + case NetGlobalDecodeError::UNTERMINATED_MESSAGE: return("unterminated message"); + case NetGlobalDecodeError::INVALID_COLOR: return("invalid session-member color"); + case NetGlobalDecodeError::INVALID_PROGRESS: return("invalid progress value"); + case NetGlobalDecodeError::INVALID_KICK_PLAYER: return("invalid kick player"); + case NetGlobalDecodeError::SELF_KICK: return("self kick proposal"); + case NetGlobalDecodeError::DUPLICATE_KICK_PROPOSAL: return("duplicate kick proposal"); + case NetGlobalDecodeError::KICK_PROPOSAL_QUEUE_FULL: return("kick proposal queue full"); + case NetGlobalDecodeError::COUNT: break; + } + + return("unknown global packet error"); +} diff --git a/code/netglobal.h b/code/netglobal.h new file mode 100644 index 0000000..ccac627 --- /dev/null +++ b/code/netglobal.h @@ -0,0 +1,75 @@ +/******************************************************************************* + * 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 + + +constexpr std::size_t NET_GLOBAL_PACKET_SIZE = sizeof(GlobalPacketType); + + +enum class NetGlobalDecodeError +{ + 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, + COUNT, +}; + + +struct NetGlobalValidationContext +{ + bool SenderIsMember = false; + int SenderPlayerID = -1; + int SenderPlayerColor = -1; + std::array ActivePlayers = {}; +}; + + +struct NetGlobalRejectionRecord +{ + std::uint32_t Count = 0; + bool ShouldLog = false; +}; + + +class NetGlobalRejectionCounters +{ + public: + NetGlobalRejectionRecord Record(NetGlobalDecodeError error) noexcept; + std::uint32_t Count(NetGlobalDecodeError error) const noexcept; + + private: + std::array(NetGlobalDecodeError::COUNT)> Counts = {}; +}; + + +void Initialize_Global_Packet(GlobalPacketType & packet, NetCommandType command) noexcept; + +NetGlobalDecodeError Validate_In_Game_Global(GlobalPacketType const & packet, std::size_t packet_length, NetGlobalValidationContext const & context); + +bool Net_Global_Command_Is_Public(NetCommandType command); + +bool Net_Global_Command_Requires_Member(NetCommandType command); + +char const * Net_Global_Error_Name(NetGlobalDecodeError error) noexcept; diff --git a/code/netshare.cpp b/code/netshare.cpp index 5909278..ca5adf2 100644 --- a/code/netshare.cpp +++ b/code/netshare.cpp @@ -662,7 +662,7 @@ void PumpGameopts(bool force, bool now) /// The encoded option string to send. void SendPublicGameopts(char const * options) { - GlobalPacketType packet; + GlobalPacketType packet = {}; memset(&packet, 0, sizeof(packet)); packet.Command = NET_PUB_GAMEOPT; strcpy(packet.Name, Session.Handle); @@ -1297,7 +1297,7 @@ void Update_Network_Dialog_Preview(HWND win) switch (Session.Type) { case GAME_IPX: if (WS_Top_Window_ID() == IDD_MPLAYER_GUEST && !Find_Local_Scenario(Session.ScenarioFileName, Session.ScenarioFileLength, Session.ScenarioDigest, Session.ScenarioIsOfficial)) { - GlobalPacketType packet; + GlobalPacketType packet = {}; memset(&packet, 0, sizeof(packet)); packet.Command = NET_REQ_PREVIEW; while (true) { @@ -1346,7 +1346,7 @@ void Receive_Random_Map_Preview(void) Ipx.Set_Timing(50, -1, 5000); DebugString("Starting map preview download\n"); - GlobalPacketType packet; + GlobalPacketType packet = {}; memset(&packet, 0, sizeof(packet)); packet.Command = NET_PREVIEW_ACK; DebugString("Sending preview mode acks\n"); @@ -1454,7 +1454,7 @@ void Send_Preview_To_Guests(void) if (MultiplayerMapPreview != NULL && stricmp(Session.ScenarioFileName, RANDOM_MAP_FILE_NAME) == 0 && Session.Players.Count() > 1) { DebugString("Starting map preview upload\n"); - GlobalPacketType packet; + GlobalPacketType packet = {}; memset(&packet, 0, sizeof(packet)); packet.Command = NET_PREVIEW_MODE; strcpy(packet.Name, Session.Handle); @@ -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/queue.cpp b/code/queue.cpp index 4fd3e31..f40f14a 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -53,9 +53,6 @@ * Build_Send_Packet -- Builds a big packet from a bunch of little ones. * * Add_Uncompressed_Events -- adds uncompressed events to a packet * * Add_Compressed_Events -- adds compressed events to a packet * - * Breakup_Receive_Packet -- Splits a big packet into little ones. * - * Extract_Uncompressed_Events -- extracts events from a packet * - * Extract_Compressed_Events -- extracts events from a packet * * * * DoList Management: * * Execute_DoList -- Executes commands from the DoList * @@ -125,6 +122,8 @@ #include "msgbox.h" #include "msgloop.h" #include "netdlg.h" +#include "netglobal.h" +#include "netpacket.h" #include "netshare.h" #include "opents_build.h" #include "overlay.h" @@ -170,6 +169,7 @@ #include "special.hh" #include +#include #include @@ -268,6 +268,24 @@ BasicTimerClass SentFrameSyncTimer; FrameSyncStruct TheirFrameSync[MAX_PLAYERS - 1]; unsigned short SentCommandCount; // # cmds I've sent out +static std::array(NetPacketDecodeError::COUNT)> + NetworkPacketDrops = {}; + + +/// Records and rate-limits one stable event-packet rejection reason. +void Record_Network_Packet_Drop(NetPacketDecodeError error) +{ + std::size_t const index = static_cast(error); + if (error == NetPacketDecodeError::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", Net_Packet_Error_Name(error), count); + } +} + /********************************* Prototypes *******************************/ @@ -278,7 +296,7 @@ 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); + int multi_packet_max, 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); @@ -297,7 +315,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 +327,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: @@ -746,7 +761,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){ @@ -869,7 +885,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 +1054,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 +1150,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 +1259,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 +1274,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 +1329,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(); @@ -1987,26 +2008,34 @@ 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; + NetPacketEncoding const encoding = Session.CommProtocol == COMM_PROTOCOL_SINGLE_NO_COMP + ? NetPacketEncoding::UNCOMPRESSED : NetPacketEncoding::COMPRESSED; + std::span const packet( + reinterpret_cast(multi_packet_buf), + packetlen > 0 ? static_cast(packetlen) : 0); + NetPacketDecodeResult decoded = Decode_Event_Packet(packet, encoding, id); + if (!decoded.Succeeded() || !decoded.HasEnvelope) { + Record_Network_Packet_Drop(decoded.Succeeded() + ? NetPacketDecodeError::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; //------------------------------------------------------------------------ // 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(NetPacketDecodeError::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 = event.Frame - event.Data.FrameInfo.Delay; if (their[index].frame < frame) { //..................................................................... @@ -2032,29 +2061,30 @@ 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) { + if ( abs((int)(their[index].sent - event.Data.FrameInfo.CommandCount)) > 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 +2093,30 @@ 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 (NetDecodedEvent 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 +2449,109 @@ void Draw_Sync_Bars(HWND window) } } -void Cast_Kick_Vote(int kicker, int kickee); +bool Cast_Kick_Vote(int kicker, int kickee); + + +/// +/// Finds a current session player by the stable ID used in the kick-vote arrays. +/// +/// The player ID to find. +/// The matching current player, or NULL when the ID is not active. +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); +} + + +/// +/// Reports whether one player has already cast a counted vote against another. +/// +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); +} + + +/// +/// Reports whether the bounded pending queue already contains this exact 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 as both a kick target and a voter, including pending proposals. +/// +/// The stable player ID leaving the current session. +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,32 +2600,67 @@ 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; + Initialize_Global_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 canonical voter and target are copied into a bounded, deduplicated queue 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) +/// The current member matched from the packet's source address. +/// The current member that voter wants removed. +/// NONE when queued, otherwise the reason the proposal was refused. +NetGlobalDecodeError 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(NetGlobalDecodeError::INVALID_KICK_PLAYER); + } + if (kicker == kickee) { + return(NetGlobalDecodeError::SELF_KICK); + } + if (Kick_Vote_Already_Cast(kicker, kickee) + || Kick_Proposal_Already_Pending(kicker, kickee)) { + return(NetGlobalDecodeError::DUPLICATE_KICK_PROPOSAL); + } + if (Session.KickProposals.Count() >= MAX_PLAYERS * MAX_PLAYERS) { + return(NetGlobalDecodeError::KICK_PROPOSAL_QUEUE_FULL); + } + + GlobalPacketType * newpacket = new GlobalPacketType; + Initialize_Global_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(NetGlobalDecodeError::KICK_PROPOSAL_QUEUE_FULL); + } + + return(NetGlobalDecodeError::NONE); } @@ -2503,34 +2672,40 @@ void Kick_Packet_Received(GlobalPacketType & packet, IPXAddressClass & address) /// /// Player ID of the one casting the vote. /// Player ID of the one being voted against. -void Cast_Kick_Vote(int kicker, int kickee) +/// True when a new bounded vote was recorded. +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 +2899,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 +3526,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 cd9101c..dc5981e 100644 --- a/code/queue.h +++ b/code/queue.h @@ -50,9 +50,11 @@ 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); +enum class NetGlobalDecodeError; + +NetGlobalDecodeError 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 a8d6566..118af61 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/wsproto.cpp b/code/wsproto.cpp index aa0a185..3073365 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,21 +427,7 @@ 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]); - - 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); - } + packet->CRC = Calculate_Packet_CRC(packet->Buffer, packet->BufferLen); } @@ -443,27 +448,13 @@ void WinsockInterfaceClass::Build_Packet_CRC(WinsockBufferType * packet) bool WinsockInterfaceClass::Passes_CRC_Check(WinsockBufferType * packet) { fw_assert (packet->InUse); - fw_assert (packet->BufferLen < WS_INTERNET_BUFFER_LEN); + fw_assert (packet->BufferLen <= WS_INTERNET_BUFFER_LEN); - if (packet->BufferLen >= WS_INTERNET_BUFFER_LEN) { + if (packet->BufferLen <= 0 || packet->BufferLen > WS_INTERNET_BUFFER_LEN) { return(false); } - unsigned int crc = 0; - - 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); - } + unsigned int crc = Calculate_Packet_CRC(packet->Buffer, packet->BufferLen); if (crc == packet->CRC) { return(true); @@ -475,6 +466,41 @@ bool WinsockInterfaceClass::Passes_CRC_Check(WinsockBufferType * packet) } +/// 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(Calculate_Network_Datagram_CRC(std::span(static_cast(buffer), static_cast(buffer_len)))); +} + + +/// Returns the number of transport packets rejected for one stable reason. +unsigned int WinsockInterfaceClass::Dropped_Packets(PacketDropReasonType reason) const +{ + if (reason < 0 || reason >= WS_DROP_COUNT) { + return(0); + } + + return(PacketDrops[reason]); +} + + +/// Records a transport rejection and rate-limits its diagnostic. +void WinsockInterfaceClass::Record_Packet_Drop(PacketDropReasonType reason) +{ + if (reason < 0 || reason >= WS_DROP_COUNT) { + return; + } + + unsigned int count = ++PacketDrops[reason]; + if (count == 1 || (count & (count - 1)) == 0) { + DebugString("Network packet drop [%s]: %u\n", Packet_Drop_Name(reason), count); + } +} + + /*********************************************************************************************** * WIC::Get_New_Out_Buffer -- Get a holding buffer for an outgoing packet * * * @@ -601,7 +627,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 +649,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 +676,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 +711,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 +774,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 ce2e02e..acbf170 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: @@ -177,6 +187,8 @@ class WinsockInterfaceClass { */ 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 +238,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 b0341fc..ef7bce9 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); + NetDatagramAdmission const admission = Admit_Network_Datagram(datagram, WS_INTERNET_BUFFER_LEN); + if (!admission.Succeeded()) { + switch (admission.Error) { + case NetAdmissionError::DATAGRAM_TOO_LARGE: + Record_Packet_Drop(WS_DROP_RECEIVE_TOO_LARGE); + break; + case NetAdmissionError::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/tests/netpacket/CMakeLists.txt b/tests/netpacket/CMakeLists.txt index e406e49..76645d8 100644 --- a/tests/netpacket/CMakeLists.txt +++ b/tests/netpacket/CMakeLists.txt @@ -3,6 +3,8 @@ 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" ) @@ -11,8 +13,11 @@ 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 diff --git a/tests/netpacket/netcontract.cpp b/tests/netpacket/netcontract.cpp index af7e452..abe5501 100644 --- a/tests/netpacket/netcontract.cpp +++ b/tests/netpacket/netcontract.cpp @@ -9,8 +9,10 @@ // 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 @@ -26,6 +28,7 @@ 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; @@ -142,16 +145,13 @@ void Test_Reader(void) } -void Test_Layout(void) +void Test_Event_Contract(void) { - Check(EventClass::LATENCYFUDGE == 35, "the last inherited event keeps numeric ID 35"); - Check(EventClass::NETWORK_REPORT == 36, "NETWORK_REPORT is appended as numeric ID 36"); - Check(EventClass::LAST_EVENT == 37, "LAST_EVENT advances without renumbering old events"); - Check(EventClass::EventLength[EventClass::NETWORK_REPORT] == 4, "NETWORK_REPORT has a four-byte wire payload"); + 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"); - Check(sizeof(EventClass) == 46 && EnvelopeSize == 17, "full and envelope event layouts match the legacy wire"); } @@ -473,18 +473,306 @@ void Test_Uncompressed(void) "an uncompressed packet rejects a trailing partial record"); } + +Bytes Datagram(Bytes const & payload) +{ + Bytes datagram; + std::uint32_t const crc = Calculate_Network_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(NetAdmissionError actual, NetAdmissionError expected, char const * what) +{ + Check(actual == expected, what); + if (actual != expected) { + std::printf(" got %s\n", Net_Admission_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(Admit_Network_Datagram(short_datagram).Error, + NetAdmissionError::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}); + NetDatagramAdmission const admission = Admit_Network_Datagram(Datagram(payload)); + Check(admission.Succeeded() && admission.Payload.size() == payload_size, + "every legal datagram word/capacity boundary is accepted intact"); + } + + Bytes oversized_payload(NET_DATAGRAM_PAYLOAD_CAPACITY + 1, std::byte{0x33}); + Check_Admission_Error(Admit_Network_Datagram(Datagram(oversized_payload)).Error, + NetAdmissionError::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(Admit_Network_Datagram(damaged).Error, + NetAdmissionError::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); + NetDatagramAdmission const admitted_unaligned = Admit_Network_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 < NET_PRIVATE_HEADER_SIZE; size++) { + Bytes packet(size, std::byte{0}); + Check_Admission_Error(Admit_Connection_Packet( + packet, NET_PRIVATE_HEADER_SIZE, 64).Error, + NetAdmissionError::HEADER_TOO_SHORT, + "every incomplete seven-byte private header is rejected"); + } + for (std::size_t size = 0; size < NET_GLOBAL_HEADER_SIZE; size++) { + Bytes packet(size, std::byte{0}); + Check_Admission_Error(Admit_Connection_Packet( + packet, NET_GLOBAL_HEADER_SIZE, 64).Error, + NetAdmissionError::HEADER_TOO_SHORT, + "every incomplete nine-byte global header is rejected"); + } + + for (std::size_t header_size : {NET_PRIVATE_HEADER_SIZE, NET_GLOBAL_HEADER_SIZE}) { + Bytes ack = Connection_Packet(header_size, + static_cast(NetPacketCode::ACK), 0); + NetConnectionAdmission admitted = 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(Admit_Connection_Packet(ack, header_size, ack.size()).Error, + NetAdmissionError::INVALID_PACKET_LENGTH, + "an ACK with application bytes is rejected"); + + Bytes empty_data = Connection_Packet(header_size, + static_cast(NetPacketCode::DATA_ACK), 0); + Check_Admission_Error(Admit_Connection_Packet( + empty_data, header_size, empty_data.size()).Error, + NetAdmissionError::INVALID_PACKET_LENGTH, + "a data header without application payload is rejected"); + + Bytes data = Connection_Packet(header_size, + static_cast(NetPacketCode::DATA_NOACK), 1); + admitted = 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(Validate_Network_Destination(admitted.Payload, 0), + NetAdmissionError::DESTINATION_TOO_SMALL, + "a destination overflow is rejected before copying"); + Check_Admission_Error(Validate_Network_Destination(admitted.Payload, 1), + NetAdmissionError::NONE, + "an exact-capacity destination accepts the payload"); + + Check_Admission_Error(Admit_Connection_Packet( + data, header_size, data.size() - 1).Error, + NetAdmissionError::PACKET_TOO_LARGE, + "a message above its connection capacity is rejected"); + } + + Bytes invalid_code = Connection_Packet(NET_PRIVATE_HEADER_SIZE, + static_cast(NetPacketCode::COUNT), 1); + Check_Admission_Error(Admit_Connection_Packet( + invalid_code, NET_PRIVATE_HEADER_SIZE, invalid_code.size()).Error, + NetAdmissionError::INVALID_PACKET_CODE, + "a packet code outside DATA/ACK is rejected"); + + Bytes aligned = Connection_Packet(NET_PRIVATE_HEADER_SIZE, + static_cast(NetPacketCode::DATA_ACK), 1); + Bytes unaligned(1, std::byte{0}); + Append_Bytes(unaligned, aligned); + NetConnectionAdmission const admitted_unaligned = Admit_Connection_Packet( + std::span(unaligned).subspan(1), NET_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); +} + + +NetGlobalValidationContext Member_Context(void) +{ + NetGlobalValidationContext 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, + NetGlobalValidationContext const & context, + NetGlobalDecodeError expected, + char const * what) +{ + NetGlobalDecodeError const actual = Validate_In_Game_Global(packet, length, context); + Check(actual == expected, what); + if (actual != expected) { + std::printf(" got %s\n", Net_Global_Error_Name(actual)); + } +} + + +void Test_Global_Packets(void) +{ + constexpr std::size_t packet_size = sizeof(GlobalPacketType); + NetGlobalValidationContext member = Member_Context(); + NetGlobalValidationContext outsider; + GlobalPacketType packet = Global_Packet(NET_QUERY_GAME); + GlobalPacketType poisoned; + std::memset(&poisoned, 0xA5, sizeof(poisoned)); + Initialize_Global_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"); + Check_Global_Error(packet, packet_size - 1, outsider, NetGlobalDecodeError::INVALID_LENGTH, + "a short global packet is rejected before dispatch"); + Check_Global_Error(packet, packet_size + 1, outsider, NetGlobalDecodeError::INVALID_LENGTH, + "an oversized global packet is rejected before dispatch"); + Check_Global_Error(packet, packet_size, outsider, NetGlobalDecodeError::NONE, + "game discovery remains public during a match"); + + packet = Global_Packet(NET_QUERY_PLAYER); + Check_Global_Error(packet, packet_size, outsider, NetGlobalDecodeError::NONE, + "player discovery remains public during a match"); + std::memset(packet.Name, 'x', sizeof(packet.Name)); + Check_Global_Error(packet, packet_size, outsider, NetGlobalDecodeError::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, NetGlobalDecodeError::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, NetGlobalDecodeError::NONE, + "sign-off and ready commands accept a matched session member"); + } + + packet = Global_Packet(static_cast(999)); + Check_Global_Error(packet, packet_size, member, NetGlobalDecodeError::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, NetGlobalDecodeError::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, NetGlobalDecodeError::UNTERMINATED_MESSAGE, + "chat rejects an unterminated message body"); + packet = Global_Packet(NET_MESSAGE); + packet.Message.Color = 999; + Check_Global_Error(packet, packet_size, member, NetGlobalDecodeError::NONE, + "chat ignores the wire color in favor of the matched member's color"); + NetGlobalValidationContext bad_color = member; + bad_color.SenderPlayerColor = MAX_MPLAYER_COLORS; + Check_Global_Error(packet, packet_size, bad_color, NetGlobalDecodeError::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, NetGlobalDecodeError::INVALID_PROGRESS, + "progress rejects a negative percentage"); + packet.Progress.Percent = 101; + Check_Global_Error(packet, packet_size, member, NetGlobalDecodeError::INVALID_PROGRESS, + "progress rejects a percentage above 100"); + packet.Progress.Percent = 100; + Check_Global_Error(packet, packet_size, member, NetGlobalDecodeError::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, NetGlobalDecodeError::NONE, + "kick validation ignores the claimed voter and uses the matched member"); + packet.Kick.KickeeID = 2; + Check_Global_Error(packet, packet_size, member, NetGlobalDecodeError::SELF_KICK, + "a member cannot vote to kick itself"); + packet.Kick.KickeeID = 7; + Check_Global_Error(packet, packet_size, member, NetGlobalDecodeError::INVALID_KICK_PLAYER, + "a kick target must be a current session member"); + + NetGlobalRejectionCounters counters; + NetGlobalRejectionRecord first = counters.Record(NetGlobalDecodeError::INVALID_LENGTH); + NetGlobalRejectionRecord second = counters.Record(NetGlobalDecodeError::INVALID_LENGTH); + NetGlobalRejectionRecord third = counters.Record(NetGlobalDecodeError::INVALID_LENGTH); + NetGlobalRejectionRecord fourth = counters.Record(NetGlobalDecodeError::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(NetGlobalDecodeError::INVALID_LENGTH) == 4, + "global rejection counters retain a stable per-error total"); + Check(counters.Record(NetGlobalDecodeError::NONE).Count == 0, + "successful packets do not enter rejection counters"); +} + } // namespace int main(void) { Test_Reader(); - Test_Layout(); + Test_Event_Contract(); Test_Envelope_Rules(); 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); From 4847de56b467596c0c9440bc8dfeab4682c11d94 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 16:59:49 +0300 Subject: [PATCH 03/13] Adapt multiplayer frame timing --- code/combuf.h | 3 + code/connect.cpp | 154 ++++++---- code/connect.h | 24 +- code/connmgr.h | 17 +- code/event.cpp | 169 +++++++++-- code/ipxmgr.cpp | 28 +- code/ipxmgr.h | 10 +- code/netsemantic.cpp | 79 ++++++ code/netsemantic.h | 37 +++ code/nettime.cpp | 31 ++ code/nettime.h | 44 +++ code/nettiming.cpp | 417 +++++++++++++++++++++++++++ code/nettiming.h | 155 ++++++++++ code/queue.cpp | 361 ++++++----------------- code/session.cpp | 181 ++++++++++++ code/session.h | 22 ++ tests/CMakeLists.txt | 1 + tests/nettiming/CMakeLists.txt | 36 +++ tests/nettiming/nettiming.cpp | 504 +++++++++++++++++++++++++++++++++ 19 files changed, 1899 insertions(+), 374 deletions(-) create mode 100644 code/netsemantic.cpp create mode 100644 code/netsemantic.h create mode 100644 code/nettime.cpp create mode 100644 code/nettime.h create mode 100644 code/nettiming.cpp create mode 100644 code/nettiming.h create mode 100644 tests/nettiming/CMakeLists.txt create mode 100644 tests/nettiming/nettiming.cpp diff --git a/code/combuf.h b/code/combuf.h index 54b9b73..4f02f0c 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 8248f13..94c095d 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 @@ -191,6 +217,7 @@ void ConnectionClass::Init (void) LastSeqID = 0xffffffff; LastReadID = 0xffffffff; + RoundTripEstimator.Reset(); Queue->Init(); @@ -287,7 +314,7 @@ int ConnectionClass::Send_Packet (void * buf, int buflen, int ack_req) *=========================================================================*/ int ConnectionClass::Receive_Packet (void * buf, int buflen) { - CommHeaderType packet_header; // packet header + 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 @@ -300,8 +327,7 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) if (buf != NULL && buflen > 0) { packet_bytes = {static_cast(buf), static_cast(buflen)}; } - NetConnectionAdmission const admission = Admit_Connection_Packet( - packet_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + NetConnectionAdmission const admission = Admit_Connection_Packet(packet_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); if (!admission.Succeeded()) { Record_Admission_Drop(admission.Error, admission.Code); return(1); @@ -337,17 +363,14 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) if (send_entry != NULL) { 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)}; + entry_bytes = {reinterpret_cast(send_entry->Buffer), static_cast(send_entry->BufLen)}; } - NetConnectionAdmission const entry = Admit_Connection_Packet( - entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + NetConnectionAdmission const entry = Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); /*............................................................... If ACK is for this entry, mark it ...............................................................*/ - if (entry.Succeeded() && packet->PacketID == entry.PacketID && - entry.Code == PACKET_DATA_ACK) { + if (entry.Succeeded() && packet->PacketID == entry.PacketID && entry.Code == PACKET_DATA_ACK) { send_entry->IsACK = 1; break; } @@ -412,17 +435,14 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) if (rec_entry) { 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)}; + entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; } - NetConnectionAdmission const entry = Admit_Connection_Packet( - entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + NetConnectionAdmission const entry = Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); /*........................................................... Packet is found; it's a resend ...........................................................*/ - if (entry.Succeeded() && entry.Code == PACKET_DATA_ACK && - entry.PacketID == packet->PacketID) { + if (entry.Succeeded() && entry.Code == PACKET_DATA_ACK && entry.PacketID == packet->PacketID) { save_packet = 0; break; } @@ -475,18 +495,14 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) if (rec_entry) { 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)}; + entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; } - NetConnectionAdmission const entry = Admit_Connection_Packet( - entry_bytes, sizeof(CommHeaderType), - static_cast(MaxPacketLen)); + NetConnectionAdmission const entry = Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); /*...................................................... Entry is found ......................................................*/ - if (entry.Succeeded() && entry.Code == PACKET_DATA_ACK && - entry.PacketID == (LastSeqID + 1)) { + if (entry.Succeeded() && entry.Code == PACKET_DATA_ACK && entry.PacketID == (LastSeqID + 1)) { LastSeqID = entry.PacketID; found = 1; @@ -557,11 +573,9 @@ int ConnectionClass::Get_Packet (void * buf, int capacity, int *buflen) if (rec_entry && 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)}; + entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; } - NetConnectionAdmission const admission = Admit_Connection_Packet( - entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + NetConnectionAdmission const admission = Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); if (!admission.Succeeded()) { rec_entry->IsRead = 1; Record_Admission_Drop(admission.Error, admission.Code); @@ -583,8 +597,7 @@ int ConnectionClass::Get_Packet (void * buf, int capacity, int *buflen) LastReadID = admission.PacketID; rec_entry->IsRead = 1; - NetAdmissionError const destination = Validate_Network_Destination( - admission.Payload, static_cast(capacity)); + NetAdmissionError const destination = Validate_Network_Destination(admission.Payload, static_cast(capacity)); if (destination != NetAdmissionError::NONE) { Record_Admission_Drop(destination, admission.Code); continue; @@ -599,8 +612,7 @@ int ConnectionClass::Get_Packet (void * buf, int capacity, int *buflen) else if (admission.Code == PACKET_DATA_NOACK) { rec_entry->IsRead = 1; - NetAdmissionError const destination = Validate_Network_Destination( - admission.Payload, static_cast(capacity)); + NetAdmissionError const destination = Validate_Network_Destination(admission.Payload, static_cast(capacity)); if (destination != NetAdmissionError::NONE) { Record_Admission_Drop(destination, admission.Code); continue; @@ -619,6 +631,7 @@ int ConnectionClass::Get_Packet (void * buf, int capacity, int *buflen) namespace { +/// Names a stable connection rejection reason. char const * Packet_Drop_Name(ConnectionClass::PacketDropReasonType reason) { switch (reason) { @@ -635,7 +648,7 @@ char const * Packet_Drop_Name(ConnectionClass::PacketDropReasonType reason) } // namespace -/// Returns the number of packets rejected for one stable admission reason. +/// Returns a packet rejection count. unsigned int ConnectionClass::Dropped_Packets(PacketDropReasonType reason) const { if (reason < 0 || reason >= CONNECTION_DROP_COUNT) { @@ -646,7 +659,7 @@ unsigned int ConnectionClass::Dropped_Packets(PacketDropReasonType reason) const } -/// Records and rate-limits diagnostics for one rejected packet. +/// Records a packet rejection. void ConnectionClass::Record_Packet_Drop(PacketDropReasonType reason) { if (reason < 0 || reason >= CONNECTION_DROP_COUNT) { @@ -660,7 +673,7 @@ void ConnectionClass::Record_Packet_Drop(PacketDropReasonType reason) } -/// Maps one shared admission failure to the connection's stable drop counters. +/// Maps a shared admission rejection to the connection counters. void ConnectionClass::Record_Admission_Drop(NetAdmissionError error, unsigned char code) { switch (error) { @@ -676,8 +689,7 @@ void ConnectionClass::Record_Admission_Drop(NetAdmissionError error, unsigned ch Record_Packet_Drop(CONNECTION_DROP_INVALID_CODE); break; case NetAdmissionError::INVALID_PACKET_LENGTH: - Record_Packet_Drop(code == PACKET_ACK - ? CONNECTION_DROP_INVALID_LENGTH : CONNECTION_DROP_EMPTY_DATA); + Record_Packet_Drop(code == PACKET_ACK ? CONNECTION_DROP_INVALID_LENGTH : CONNECTION_DROP_EMPTY_DATA); break; case NetAdmissionError::DESTINATION_TOO_SMALL: Record_Packet_Drop(CONNECTION_DROP_OUTPUT_TOO_SMALL); @@ -748,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; @@ -769,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); + } + } } /*.................................................................. @@ -795,13 +813,29 @@ 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) { + // New packets send immediately; retransmissions follow the connection's current timeout. + 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))); + + 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 = send_entry->SendCount == 0 + ? (adaptive_timing ? RoundTripEstimator.Retransmit_Timeout() : Ticks_To_Milliseconds(RetryDelta)) : send_entry->RetransmitTimeoutMilliseconds; + unsigned int const prior_retransmissions = send_entry->SendCount == 0 ? 0 : send_entry->SendCount - 1; + if (send_entry->SendCount == 0 || + NetTiming::Retransmit_Is_Due(send_entry->LastTimeMilliseconds, current_milliseconds, retry_timeout, prior_retransmissions, connection_timeout)) { /*.................................................................. Send the message @@ -812,17 +846,21 @@ int ConnectionClass::Service_Send_Queue (void) /*.................................................................. Fill in Time fields ..................................................................*/ + curtime = Time(); 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 { @@ -842,11 +880,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; - } } } @@ -892,7 +925,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; /*------------------------------------------------------------------------ @@ -905,13 +938,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 8270f29..e7e4119 100644 --- a/code/connect.h +++ b/code/connect.h @@ -98,6 +98,7 @@ */ #include "combuf.h" #include "netadmit.h" +#include "nettiming.h" /* ********************************** Defines ********************************** @@ -143,9 +144,8 @@ class ConnectionClass /*..................................................................... 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); /*..................................................................... @@ -185,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); } @@ -227,12 +234,9 @@ 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; - /// Returns whether this channel represents one peer with adaptive link timing. + 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); - /// Maps a shared admission failure to this connection's stable counter. void Record_Admission_Drop(NetAdmissionError error, unsigned char code); /* @@ -296,6 +300,12 @@ class ConnectionClass .....................................................................*/ unsigned int Timeout; + /*..................................................................... + The adaptive retry estimator and its monotonic millisecond clock. + .....................................................................*/ + 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 82ef04e..99dfaf1 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 capacity, 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/event.cpp b/code/event.cpp index 4f96caa..63188b1 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,6 +77,62 @@ #include "special.hh" +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, + 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 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); + } +} + + /*********************************************************************************************** * EventClass::EventClass -- Constructs event to transfer special flags. * * * @@ -549,6 +606,19 @@ 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]; HouseClass * hptr = NULL; const char *str = NULL; @@ -559,7 +629,6 @@ void EventClass::Execute(void) // bool formation = false; int i; int index; - unsigned int ul; // RTTIType rt; //if (Debug_Print_Events) { @@ -597,11 +666,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; @@ -672,6 +746,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) { @@ -683,7 +770,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(); } } @@ -996,6 +1083,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]; @@ -1041,10 +1132,15 @@ void EventClass::Execute(void) break; case REMOVEPLAYER: - DebugString("Executing REMOVEPLAYER event. Frame is %d\n", ::Frame); - Disable_Multiplayer_Saving(); index = Data.General.Value; + if (!NetSemantic::Index_Is_Valid(index, Houses.Count()) || Houses[index] == NULL) { + Log_Event_Rejection(EventRejectReason::InvalidRemovedHouse, Type, ID, index); + break; + } + DebugString("Executing REMOVEPLAYER event. Frame is %d\n", ::Frame); + Disable_Multiplayer_Saving(); + Session.Remove_Network_Timing_Player(index); house = Houses[index]; if (Session.Type == GAME_INTERNET && WestwoodOnline_Tournament) { house->Flag_To_Die(); @@ -1056,6 +1152,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); @@ -1076,7 +1176,34 @@ 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 (!NetSemantic::Timing_Authority_Is_Valid(ID, master_id)) { + Log_Event_Rejection(EventRejectReason::UnauthorizedTiming, Type, ID, master_id); + break; + } + + unsigned int const fog_padding = Scen->Special.IsFogOfWar ? 10u : 0u; + if (Data.Timing.MaxAhead < fog_padding || 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, fog_padding); + if (!decoded_settings) { + Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, Data.Timing.MaxAhead); + break; + } + NetTiming::TimingSettings const settings = *decoded_settings; + + unsigned int const old_frame_send_rate = Session.FrameSendRate; + unsigned int const old_max_ahead = Session.MaxAhead; + NetworkTimingScheduleResult const result = Session.Schedule_Network_Timing(settings, Data.Timing.DesiredFrameRate, (unsigned int)Frame); + if (result == NetworkTimingScheduleResult::Rejected) { + Log_Event_Rejection(EventRejectReason::UnschedulableTiming, Type, ID, (int)settings.MaxAhead); + break; + } #if (TIMING_FIX) // @@ -1086,27 +1213,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 == NetworkTimingScheduleResult::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 @@ -1122,6 +1239,14 @@ void EventClass::Execute(void) } break; + case NETWORK_REPORT: + if (Frame < 0 || + !NetSemantic::Network_Report_Is_Valid(Data.NetworkReport.AverageProcessMilliseconds, Data.NetworkReport.WorstRoundTripMilliseconds) || + !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/ipxmgr.cpp b/code/ipxmgr.cpp index 8fe5d52..778501a 100644 --- a/code/ipxmgr.cpp +++ b/code/ipxmgr.cpp @@ -1011,8 +1011,7 @@ int IPXManagerClass::Service(void) memcpy(&address, temp_address, sizeof(address)); memset(&packet_header, 0, sizeof(packet_header)); - memcpy(&packet_header, temp_receive_buffer, - std::min(packetlen, (int)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()) { @@ -1055,8 +1054,7 @@ int IPXManagerClass::Service(void) ** 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. */ - int frame_info_size = sizeof(CommHeaderType) + offsetof(EventClass, Data) + - size_of(EventClass, Data.FrameInfo); + int frame_info_size = sizeof(CommHeaderType) + offsetof(EventClass, Data) + size_of(EventClass, Data.FrameInfo); if (Frame > 8 && packetlen >= frame_info_size) { if (packet->Code == ConnectionClass::PACKET_DATA_NOACK){ /* @@ -1355,6 +1353,28 @@ unsigned int IPXManagerClass::Response_Time(void) } /* end of Response_Time */ +/// Returns the worst measured private-link round trip, once all links have a sample. +std::optional IPXManagerClass::Worst_Local_Round_Trip_MS(void) const +{ + if (NumConnections == 0) { + return(std::nullopt); + } + + std::optional worst; + for (int i = 0; i < NumConnections; i++) { + std::optional const round_trip = Connection[i]->Smoothed_Round_Trip_MS(); + if (!round_trip) { + return(std::nullopt); + } + if (!worst || *round_trip > *worst) { + 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 f7b4e32..1534d61 100644 --- a/code/ipxmgr.h +++ b/code/ipxmgr.h @@ -196,13 +196,10 @@ 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 capacity, 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 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; /*..................................................................... @@ -230,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; diff --git a/code/netsemantic.cpp b/code/netsemantic.cpp new file mode 100644 index 0000000..c67df7a --- /dev/null +++ b/code/netsemantic.cpp @@ -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. + ******************************************************************************/ + +#include "netsemantic.h" + +#include + + +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 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 and decodes settings carried by a timing event. + std::optional Decode_Timing_Settings(std::uint16_t desired_frame_rate, std::uint16_t wire_max_ahead, + std::uint8_t frame_send_rate, unsigned int fog_padding) noexcept + { + if (desired_frame_rate == 0 || desired_frame_rate > 60 || fog_padding > std::numeric_limits::max() + || wire_max_ahead < fog_padding) { + return(std::nullopt); + } + + NetTiming::TimingSettings const settings{frame_send_rate, wire_max_ahead - fog_padding}; + return(NetTiming::Timing_Settings_Are_Valid(settings) ? std::optional(settings) : std::nullopt); + } + + + /// Checks reported process and round-trip times. + bool Network_Report_Is_Valid(std::uint16_t process_milliseconds, std::uint16_t round_trip_milliseconds) noexcept + { + return(process_milliseconds <= NetTiming::MAXIMUM_PROCESS_MILLISECONDS + && (round_trip_milliseconds <= NetTiming::MAXIMUM_REPORTED_RTT || round_trip_milliseconds == UINT16_MAX)); + } +} diff --git a/code/netsemantic.h b/code/netsemantic.h new file mode 100644 index 0000000..c32d708 --- /dev/null +++ b/code/netsemantic.h @@ -0,0 +1,37 @@ +/******************************************************************************* + * 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 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; + + std::optional Decode_Timing_Settings(std::uint16_t desired_frame_rate, std::uint16_t wire_max_ahead, + std::uint8_t frame_send_rate, unsigned int fog_padding) noexcept; + + bool Network_Report_Is_Valid(std::uint16_t process_milliseconds, std::uint16_t round_trip_milliseconds) noexcept; +} diff --git a/code/nettime.cpp b/code/nettime.cpp new file mode 100644 index 0000000..e25a179 --- /dev/null +++ b/code/nettime.cpp @@ -0,0 +1,31 @@ +/******************************************************************************* + * 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 + + +namespace NetTiming +{ + /// Reads the system's wrapping millisecond clock. + Milliseconds SystemMillisecondClock::Now(void) const + { + return(static_cast(GetTickCount())); + } + + + /// 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 0000000..4f28709 --- /dev/null +++ b/code/nettime.h @@ -0,0 +1,44 @@ +/******************************************************************************* + * 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; + }; + + class SystemMillisecondClock final : public MillisecondClock + { + public: + Milliseconds Now(void) const override; + }; + + 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 0000000..15825f6 --- /dev/null +++ b/code/nettiming.cpp @@ -0,0 +1,417 @@ +/******************************************************************************* + * 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, LatencyFudge fudge, bool require_headroom) + { + if (census.ActivePlayers == 0) { + return(Settings_For_Rung(INITIAL_TIMING_RUNG)); + } + if (!census.Complete) { + return(TimingSettings{MAXIMUM_TIMING_RUNG, MAXIMUM_MAX_AHEAD}); + } + return(Select_Timing_Settings(census.WorstRoundTrip, target_fps, fudge, 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}); + } + + + /// 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); + } + + + /// Applies the selected RTT safety margin. + Milliseconds Apply_Latency_Fudge(Milliseconds round_trip, LatencyFudge fudge) + { + std::uint64_t numerator = round_trip; + std::uint64_t denominator = 1; + + switch (fudge) { + case LatencyFudge::None: + break; + case LatencyFudge::Half: + numerator *= 3; + denominator = 2; + break; + case LatencyFudge::Double: + numerator *= 2; + break; + case LatencyFudge::Triple: + numerator *= 3; + break; + } + + return(static_cast(std::min(Divide_Round_Up(numerator, denominator), std::numeric_limits::max()))); + } + + + /// 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, LatencyFudge fudge, bool require_headroom) + { + target_fps = std::clamp(target_fps, 1u, 60u); + + std::uint64_t adjusted = Apply_Latency_Fudge(worst_round_trip, fudge); + 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); + } + + + /// Returns the policy rung selected for an adjusted RTT. + unsigned int Select_Timing_Rung(Milliseconds worst_round_trip, unsigned int target_fps, LatencyFudge fudge, bool require_headroom) + { + return(Select_Timing_Settings(worst_round_trip, target_fps, fudge, require_headroom).FrameSendRate); + } + + + /// 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) + { + if (player >= Reports.size()) { + return(false); + } + + PlayerReport & report = Reports[player]; + if (report.Active != active) { + report = {}; + report.Active = active; + } + return(true); + } + + + /// Records one active player's fresh RTT report. + bool TimingReportCensus::Record_Report(unsigned int player, Milliseconds round_trip, std::uint32_t frame) + { + if (player >= Reports.size() || !Reports[player].Active || round_trip > MAXIMUM_REPORTED_RTT) { + return(false); + } + + PlayerReport & report = Reports[player]; + report.Present = true; + report.RoundTrip = round_trip; + report.Frame = frame; + return(true); + } + + + /// Marks an active player's RTT as unavailable. + bool TimingReportCensus::Clear_Report(unsigned int player) + { + if (player >= Reports.size() || !Reports[player].Active) { + return(false); + } + + Reports[player].Present = false; + Reports[player].RoundTrip = 0; + Reports[player].Frame = 0; + 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++; + if (!report.Present || frame - report.Frame >= REPORT_EXPIRY) { + result.Complete = false; + continue; + } + + result.FreshReports++; + result.WorstRoundTrip = std::max(result.WorstRoundTrip, report.RoundTrip); + } + return(result); + } + + + /// Restores the balanced policy's initial state. + void BalancedTimingPolicy::Reset(void) + { + CurrentRung = INITIAL_TIMING_RUNG; + CurrentSettings = Settings_For_Rung(INITIAL_TIMING_RUNG); + GoodEvaluations = 0; + ReversibleChanges = 0; + LastEvaluationFrame = 0; + LastChangeFrame = 0; + HasEvaluated = false; + HasChanged = false; + HasCompleteCensus = 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; + if (ReversibleChanges < REVERSIBLE_CHANGE_LIMIT) { + ReversibleChanges++; + } + } + + + /// Applies cadence, hysteresis, and the change budget. + TimingEvaluation BalancedTimingPolicy::Evaluate(TimingCensus const & census, unsigned int target_fps, LatencyFudge fudge, std::uint32_t frame) + { + TimingEvaluation result{Current_Settings(), CurrentRung, false, false}; + if (HasEvaluated && frame - LastEvaluationFrame < EVALUATION_INTERVAL) { + return(result); + } + + HasEvaluated = true; + LastEvaluationFrame = frame; + result.Evaluated = true; + if (census.Complete && census.ActivePlayers > 0) { + HasCompleteCensus = true; + } + if (!HasCompleteCensus && !census.Complete) { + return(result); + } + + // Worsening is immediate; improvement must clear the headroom, cadence, and change-budget gates. + TimingSettings const desired_settings = Desired_Settings(census, target_fps, fudge, false); + if (Timing_Is_Worse(desired_settings, CurrentSettings)) { + Change_To(desired_settings, frame); + result.Changed = true; + } else if (Timing_Is_Better(desired_settings, CurrentSettings) && ReversibleChanges < REVERSIBLE_CHANGE_LIMIT + && (!HasChanged || frame - LastChangeFrame >= CHANGE_COOLDOWN)) { + TimingSettings const headroom = Desired_Settings(census, target_fps, fudge, 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_Settings_Are_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, event_frame, false}); + } + + // Aligning to both periods keeps already scheduled commands on the old horizon. + 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); + } + + return(StagedTimingUpdate{requested, static_cast(activation), true}); + } + + + /// 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 0000000..08c2493 --- /dev/null +++ b/code/nettiming.h @@ -0,0 +1,155 @@ +/******************************************************************************* + * 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 = 3; + constexpr unsigned int MAXIMUM_MAX_AHEAD = 250; + + 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; + constexpr unsigned int REVERSIBLE_CHANGE_LIMIT = 8; + + enum class LatencyFudge : unsigned char { + None, + Half, + Double, + Triple, + }; + + 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 = 3; + unsigned int MaxAhead = 9; + + bool operator==(TimingSettings const &) const = default; + }; + + TimingSettings Settings_For_Rung(unsigned int rung); + bool Timing_Settings_Are_Valid(TimingSettings settings); + Milliseconds Apply_Latency_Fudge(Milliseconds round_trip, LatencyFudge fudge); + 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, LatencyFudge fudge, bool require_headroom = false); + unsigned int Select_Timing_Rung(Milliseconds worst_round_trip, unsigned int target_fps, LatencyFudge fudge, bool require_headroom = false); + + struct TimingCensus { + unsigned int ActivePlayers = 0; + unsigned int FreshReports = 0; + Milliseconds WorstRoundTrip = 0; + bool Complete = true; + }; + + class TimingReportCensus + { + public: + void Reset(void); + bool Set_Player_Active(unsigned int player, bool active); + bool Record_Report(unsigned int player, Milliseconds round_trip, std::uint32_t frame); + bool Clear_Report(unsigned int player); + TimingCensus Inspect(std::uint32_t frame) const; + + private: + struct PlayerReport { + bool Active = false; + bool Present = false; + Milliseconds RoundTrip = 0; + std::uint32_t Frame = 0; + }; + + std::array Reports = {}; + }; + + struct TimingEvaluation { + TimingSettings Settings; + unsigned int Rung = INITIAL_TIMING_RUNG; + bool Evaluated = false; + bool Changed = false; + }; + + class BalancedTimingPolicy + { + public: + void Reset(void); + TimingEvaluation Evaluate(TimingCensus const & census, unsigned int target_fps, LatencyFudge fudge, std::uint32_t frame); + + unsigned int Current_Rung(void) const {return(CurrentRung);} + TimingSettings Current_Settings(void) const {return(CurrentSettings);} + unsigned int Reversible_Changes(void) const {return(ReversibleChanges);} + unsigned int Good_Evaluations(void) const {return(GoodEvaluations);} + + private: + void Change_To(TimingSettings settings, std::uint32_t frame); + + unsigned int CurrentRung = INITIAL_TIMING_RUNG; + TimingSettings CurrentSettings = {3, 9}; + unsigned int GoodEvaluations = 0; + unsigned int ReversibleChanges = 0; + std::uint32_t LastEvaluationFrame = 0; + std::uint32_t LastChangeFrame = 0; + bool HasEvaluated = false; + bool HasChanged = false; + bool HasCompleteCensus = false; + }; + + struct StagedTimingUpdate { + TimingSettings Settings; + std::uint32_t ActivationFrame = 0; + bool Deferred = false; + }; + + std::optional Stage_Timing_Update(TimingSettings current, TimingSettings requested, std::uint32_t event_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 f40f14a..5b0772c 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -124,6 +124,7 @@ #include "netdlg.h" #include "netglobal.h" #include "netpacket.h" +#include "nettiming.h" #include "netshare.h" #include "opents_build.h" #include "overlay.h" @@ -268,8 +269,7 @@ BasicTimerClass SentFrameSyncTimer; FrameSyncStruct TheirFrameSync[MAX_PLAYERS - 1]; unsigned short SentCommandCount; // # cmds I've sent out -static std::array(NetPacketDecodeError::COUNT)> - NetworkPacketDrops = {}; +static std::array(NetPacketDecodeError::COUNT)> NetworkPacketDrops = {}; /// Records and rate-limits one stable event-packet rejection reason. @@ -298,8 +298,8 @@ static RetcodeType Wait_For_Players(int first_time, ConnManClass *net, int resend_delta, int dialog_time, int timeout, char *multi_packet_buf, int multi_packet_max, 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); +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); @@ -481,6 +481,10 @@ bool Queue_Exit(void) *=========================================================================*/ void Queue_AI(void) { + if (Frame >= 0) { + Session.Apply_Staged_Network_Timing(static_cast(Frame)); + } + if (Session.Play) { Queue_Playback(); } @@ -830,10 +834,8 @@ static void Queue_AI_Multiplayer(void) // //if (Session.CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { - // - // All systems will transmit their required process time. - // - Generate_Process_Time_Event(net); + // Every peer reports its processing time and worst local RTT. + Generate_Network_Report_Event(net); //} else { // // @@ -843,11 +845,10 @@ static void Queue_AI_Multiplayer(void) // } } - // - // 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 periodically evaluates the shared reports. + if (Session.Am_I_Master() && (Session.PrecalcMaxAhead != 0 || Session.PrecalcDesiredFrameRate != 0 || + (Frame & (NetTiming::EVALUATION_INTERVAL - 1)) == 0)) { + Generate_Real_Timing_Event(); } //------------------------------------------------------------------------ @@ -1534,247 +1535,104 @@ static void Generate_Timing_Event(ConnManClass *net, int my_sent) } // end of Generate_Timing_Event +/// Maps the validated game-speed setting to its historical frame-rate target. +static int Game_Speed_Frame_Rate(void) +{ + 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); + } +} + + /*************************************************************************** * Generate_Real_Timing_Event -- Generates a TIMING event * * * * INPUT: * - * net ptr to connection manager * - * my_sent # commands I've sent out so far * + * none. * * * * OUTPUT: * * none. * * * * WARNINGS: * - * none. * + * Only the deterministic session master may call this routine. * * * * HISTORY: * * 07/02/1996 BRR : Created. * *=========================================================================*/ -static void Generate_Real_Timing_Event(ConnManClass *net, int my_sent) +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; + EventClass event; + memset(&event, 0, sizeof(event)); 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); + NetTiming::TimingSettings const settings{Session.PrecalcDesiredFrameRate > 30u ? 10u : 5u, static_cast(Session.PrecalcMaxAhead)}; + if (Session.PrecalcDesiredFrameRate > 0 && Session.PrecalcDesiredFrameRate <= 60 && NetTiming::Timing_Settings_Are_Valid(settings)) { + event.Type = EventClass::TIMING; + event.Data.Timing.DesiredFrameRate = Session.PrecalcDesiredFrameRate; + event.Data.Timing.MaxAhead = settings.MaxAhead + (Scen->Special.IsFogOfWar ? 10u : 0u); + event.Data.Timing.FrameSendRate = settings.FrameSendRate; + OutList.push_back(event); + } else { + DebugString("Ignoring invalid precalculated network timing values\n"); + } Session.PrecalcMaxAhead = 0; Session.PrecalcDesiredFrameRate = 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) { return; } - // - // 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) { + int highest_process_milliseconds = 0; + for (int index = 0; index < Session.Players.Count(); index++) { + NodeNameType const * player = Session.Players[index]; + if (player == NULL || player->Player.ProcessTime < 0) { 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; + highest_process_milliseconds = std::max(highest_process_milliseconds, player->Player.ProcessTime); } - 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; - } + unsigned int process_frame_rate = highest_process_milliseconds == 0 ? 60u : static_cast(std::max(1, 1000 / highest_process_milliseconds)); + unsigned int const desired_frame_rate = std::min(process_frame_rate, static_cast(Game_Speed_Frame_Rate())); + NetTiming::TimingEvaluation const evaluation = Session.Evaluate_Network_Timing(desired_frame_rate, static_cast(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 + (Scen->Special.IsFogOfWar ? 10u : 0u); + 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); - } } @@ -2011,13 +1869,11 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, RetcodeType retcode = RC_NORMAL; NetPacketEncoding const encoding = Session.CommProtocol == COMM_PROTOCOL_SINGLE_NO_COMP ? NetPacketEncoding::UNCOMPRESSED : NetPacketEncoding::COMPRESSED; - std::span const packet( - reinterpret_cast(multi_packet_buf), - packetlen > 0 ? static_cast(packetlen) : 0); + 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. NetPacketDecodeResult decoded = Decode_Event_Packet(packet, encoding, id); if (!decoded.Succeeded() || !decoded.HasEnvelope) { - Record_Network_Packet_Drop(decoded.Succeeded() - ? NetPacketDecodeError::INVALID_PREFIX : decoded.Failure.Code); + Record_Network_Packet_Drop(decoded.Succeeded() ? NetPacketDecodeError::INVALID_PREFIX : decoded.Failure.Code); return(RC_NORMAL); } @@ -2100,8 +1956,7 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, 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()); + memcpy(queued.Data.Variable.Pointer, source.AddPlayerData.data(), source.AddPlayerData.size()); } } DoList.push_back(queued); @@ -2452,11 +2307,7 @@ void Draw_Sync_Bars(HWND window) bool Cast_Kick_Vote(int kicker, int kickee); -/// -/// Finds a current session player by the stable ID used in the kick-vote arrays. -/// -/// The player ID to find. -/// The matching current player, or NULL when the ID is not active. +/// Finds an active session player by stable ID. static NodeNameType * Current_Player_From_ID(int player) { if (player < 0 || player >= MAX_PLAYERS) { @@ -2473,9 +2324,7 @@ static NodeNameType * Current_Player_From_ID(int player) } -/// -/// Reports whether one player has already cast a counted vote against another. -/// +/// 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) { @@ -2495,16 +2344,13 @@ static bool Kick_Vote_Already_Cast(int kicker, int kickee) } -/// -/// Reports whether the bounded pending queue already contains this exact vote. -/// +/// 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)) { + if (proposal != NULL && proposal->Kick.KickerID == static_cast(kicker) && + proposal->Kick.KickeeID == static_cast(kickee)) { return(true); } } @@ -2512,10 +2358,7 @@ static bool Kick_Proposal_Already_Pending(int kicker, int kickee) } -/// -/// Removes a departing player as both a kick target and a voter, including pending proposals. -/// -/// The stable player ID leaving the current session. +/// Removes a departing player from pending and counted kick votes. void Forget_Kick_Player(int player) { if (player < 0 || player >= MAX_PLAYERS) { @@ -2524,9 +2367,8 @@ void Forget_Kick_Player(int player) 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)) { + if (proposal == NULL || proposal->Kick.KickerID == static_cast(player) || + proposal->Kick.KickeeID == static_cast(player)) { delete proposal; Session.KickProposals.Delete_Index(index); } @@ -2602,8 +2444,7 @@ void Propose_Kick_Player(HWND window, int id) 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)) { + if (Current_Player_From_ID(kicker) == NULL || Current_Player_From_ID(kickee) == NULL || Kick_Vote_Already_Cast(kicker, kickee)) { return; } @@ -2623,14 +2464,7 @@ void Propose_Kick_Player(HWND window, int id) } -/// -/// Handles a kick proposal arriving from another player. -/// The canonical voter and target are copied into a bounded, deduplicated queue so that the -/// wait-for-players loop can act on the proposal when it next gets the chance. -/// -/// The current member matched from the packet's source address. -/// The current member that voter wants removed. -/// NONE when queued, otherwise the reason the proposal was refused. +/// Queues a bounded, canonical kick proposal from a session member. NetGlobalDecodeError Kick_Packet_Received(int kicker, int kickee) { NodeNameType * kicker_player = Current_Player_From_ID(kicker); @@ -2641,8 +2475,7 @@ NetGlobalDecodeError Kick_Packet_Received(int kicker, int kickee) if (kicker == kickee) { return(NetGlobalDecodeError::SELF_KICK); } - if (Kick_Vote_Already_Cast(kicker, kickee) - || Kick_Proposal_Already_Pending(kicker, kickee)) { + if (Kick_Vote_Already_Cast(kicker, kickee) || Kick_Proposal_Already_Pending(kicker, kickee)) { return(NetGlobalDecodeError::DUPLICATE_KICK_PROPOSAL); } if (Session.KickProposals.Count() >= MAX_PLAYERS * MAX_PLAYERS) { @@ -2664,15 +2497,7 @@ NetGlobalDecodeError Kick_Packet_Received(int kicker, int kickee) } -/// -/// 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. -/// True when a new bounded vote was recorded. +/// Records one bounded, deduplicated kick vote. bool Cast_Kick_Vote(int kicker, int kickee) { char buffer[256]; @@ -2694,10 +2519,8 @@ bool Cast_Kick_Vote(int kicker, int kickee) Session.KickVoteCount[kickee]++; if (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET) { - 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); + 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); diff --git a/code/session.cpp b/code/session.cpp index da27427..ef6af97 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -189,6 +189,7 @@ SessionClass::SessionClass(void) MaxAhead = FrameSendRate * 3; MaxMaxAhead = MaxAhead; + Reset_Network_Timing(); memset(ConnectionStats, 0, sizeof(ConnectionStats)); @@ -333,6 +334,7 @@ int SessionClass::Create_Connections(void) if (Session.Type != GAME_IPX && Session.Type != GAME_INTERNET) { return(0); } + Reset_Network_Timing(); //------------------------------------------------------------------------ // Loop through all entries in 'Players' @@ -430,6 +432,185 @@ 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 +{ + if (id < 0 || id >= (int)NetTiming::MAX_TIMING_PLAYERS || RemovedNetworkTimingPlayers[id]) { + return(false); + } + for (int i = 0; i < Players.Count(); i++) { + if (Players[i] != NULL && Players[i]->Player.ID == id) { + return(true); + } + } + return(false); +} + + +/// Starts a fresh adaptive-timing census. +void SessionClass::Reset_Network_Timing(void) +{ + NetworkTimingReports.Reset(); + NetworkTimingPolicy.Reset(); + PendingNetworkTiming.reset(); + PendingNetworkDesiredFrameRate = 0; + for (bool & removed : RemovedNetworkTimingPlayers) { + removed = false; + } + + 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); + } + } +} + + +/// 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) +{ + if (id < 0 || id >= (int)NetTiming::MAX_TIMING_PLAYERS || RemovedNetworkTimingPlayers[id] || + process_milliseconds > NetTiming::MAXIMUM_PROCESS_MILLISECONDS || + (round_trip_milliseconds != EventClass::NETWORK_RTT_UNAVAILABLE && round_trip_milliseconds > NetTiming::MAXIMUM_REPORTED_RTT)) { + return(false); + } + + NodeNameType * player = NULL; + for (int i = 0; i < Players.Count(); i++) { + if (Players[i] != NULL && Players[i]->Player.ID == id) { + player = Players[i]; + break; + } + } + if (player == NULL) { + return(false); + } + + player->Player.ProcessTime = process_milliseconds; + NetworkTimingReports.Set_Player_Active(id, true); + if (round_trip_milliseconds == EventClass::NETWORK_RTT_UNAVAILABLE) { + // The player remains in the census while its missing RTT prevents a complete sample set. + return(NetworkTimingReports.Clear_Report(id)); + } + return(NetworkTimingReports.Record_Report(id, round_trip_milliseconds, frame)); +} + + +/// Removes a departed player from the timing census. +void SessionClass::Remove_Network_Timing_Player(int id) +{ + if (id >= 0 && id < (int)NetTiming::MAX_TIMING_PLAYERS) { + RemovedNetworkTimingPlayers[id] = true; + NetworkTimingReports.Set_Player_Active(id, false); + } +} + + +/// Returns a freshness-aware census of seated players. +NetTiming::TimingCensus SessionClass::Network_Timing_Census(unsigned int frame) +{ + bool active[NetTiming::MAX_TIMING_PLAYERS] = {}; + 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) { + active[id] = true; + } + } + for (unsigned int id = 0; id < NetTiming::MAX_TIMING_PLAYERS; id++) { + NetworkTimingReports.Set_Player_Active(id, active[id] && !RemovedNetworkTimingPlayers[id]); + } + return(NetworkTimingReports.Inspect(frame)); +} + + +/// Evaluates the adaptive-timing policy against the current census. +NetTiming::TimingEvaluation SessionClass::Evaluate_Network_Timing(unsigned int target_fps, unsigned int frame) +{ + int const fudge = std::clamp(LatencyFudge, 0, 3); + return(NetworkTimingPolicy.Evaluate(Network_Timing_Census(frame), target_fps, static_cast(fudge), frame)); +} + + +/// Applies a timing increase or safely stages a decrease. +NetworkTimingScheduleResult 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(NetworkTimingScheduleResult::Rejected); + } + + NetTiming::TimingSettings const current{FrameSendRate, MaxAhead}; + std::optional staged; + if (NetTiming::Timing_Settings_Are_Valid(current)) { + staged = NetTiming::Stage_Timing_Update(current, settings, event_frame); + } else { + staged = NetTiming::StagedTimingUpdate{settings, event_frame, false}; + } + if (!staged) { + return(NetworkTimingScheduleResult::Rejected); + } + + // Decreases wait until commands scheduled under the old horizon have drained. + if (staged->Deferred) { + PendingNetworkTiming = staged; + PendingNetworkDesiredFrameRate = desired_frame_rate; + return(NetworkTimingScheduleResult::Staged); + } + + PendingNetworkTiming.reset(); + PendingNetworkDesiredFrameRate = 0; + DesiredFrameRate = desired_frame_rate; + FrameSendRate = settings.FrameSendRate; + MaxAhead = settings.MaxAhead; + MaxMaxAhead = std::max(MaxMaxAhead, (int)MaxAhead); + return(NetworkTimingScheduleResult::Applied); +} + + +/// Activates a staged timing decrease once its safe frame is reached. +bool SessionClass::Apply_Staged_Network_Timing(unsigned int frame) +{ + if (!PendingNetworkTiming || !NetTiming::Timing_Update_Is_Due(frame, PendingNetworkTiming->ActivationFrame)) { + return(false); + } + + NetTiming::TimingSettings const settings = PendingNetworkTiming->Settings; + DesiredFrameRate = PendingNetworkDesiredFrameRate; + FrameSendRate = settings.FrameSendRate; + MaxAhead = settings.MaxAhead; + MaxMaxAhead = std::max(MaxMaxAhead, (int)MaxAhead); + PendingNetworkTiming.reset(); + PendingNetworkDesiredFrameRate = 0; + return(true); +} + + /*************************************************************************** * SessionClass::Read_MultiPlayer_Settings -- reads settings INI * * * diff --git a/code/session.h b/code/session.h index a6b2928..ca51f8f 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,13 @@ struct MPStatsType { IPXAddressClass Address; /// Address these stats were gathered from. }; + +enum class NetworkTimingScheduleResult { + Rejected, + Applied, + Staged, +}; + //--------------------------------------------------------------------------- // Class Definition //--------------------------------------------------------------------------- @@ -460,6 +468,15 @@ 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; + void Reset_Network_Timing(void); + 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); + NetTiming::TimingCensus Network_Timing_Census(unsigned int frame); + NetTiming::TimingEvaluation Evaluate_Network_Timing(unsigned int target_fps, unsigned int frame); + NetworkTimingScheduleResult Schedule_Network_Timing(NetTiming::TimingSettings settings, unsigned int desired_frame_rate, unsigned int event_frame); + bool Apply_Staged_Network_Timing(unsigned int frame); unsigned int Compute_Unique_ID(void); void Update_Progress(int percent); void Init_Fixed_Alliances(void); @@ -530,6 +547,11 @@ class SessionClass //..................................................................... unsigned int MaxAhead; unsigned int FrameSendRate; + NetTiming::TimingReportCensus NetworkTimingReports; + NetTiming::BalancedTimingPolicy NetworkTimingPolicy; + std::optional PendingNetworkTiming; + unsigned int PendingNetworkDesiredFrameRate; + bool RemovedNetworkTimingPlayers[NetTiming::MAX_TIMING_PLAYERS]; int DesiredFrameRate; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 12d3dd5..039febd 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -2,3 +2,4 @@ add_subdirectory(gamedirs) add_subdirectory(logstress) add_subdirectory(cpudetect) add_subdirectory(netpacket) +add_subdirectory(nettiming) diff --git a/tests/nettiming/CMakeLists.txt b/tests/nettiming/CMakeLists.txt new file mode 100644 index 0000000..337f589 --- /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) + +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 0000000..d537961 --- /dev/null +++ b/tests/nettiming/nettiming.cpp @@ -0,0 +1,504 @@ +/******************************************************************************* + * 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 + + +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)); + Expect("activate second peer", census.Set_Player_Active(2, true)); + Expect("reject out of range peer", !census.Set_Player_Active(MAX_TIMING_PLAYERS, true)); + Expect("record first peer", census.Record_Report(1, 80, 100)); + Expect("record second peer", census.Record_Report(2, 180, 100)); + Expect("accept RTT above retransmit clamp", census.Record_Report(2, MAXIMUM_RTO + 1, 100)); + Expect("reject RTT beyond wire range", !census.Record_Report(2, MAXIMUM_REPORTED_RTT + 1, 100)); + + TimingCensus result = census.Inspect(200); + Expect_Equal("active peer count", result.ActivePlayers, 2u); + Expect_Equal("fresh report count", result.FreshReports, 2u); + Expect_Equal("unequal links publish worst", result.WorstRoundTrip, MAXIMUM_RTO + 1); + Expect("fresh census complete", result.Complete); + BalancedTimingPolicy aggregate; + TimingEvaluation const guest_degradation = aggregate.Evaluate( + result, 60, LatencyFudge::None, 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("reports expire on boundary", !result.Complete); + Expect_Equal("expired reports not fresh", result.FreshReports, 0u); + + Expect("departed peer removed", census.Set_Player_Active(2, false)); + Expect("remaining peer refreshed", census.Record_Report(1, 90, 700)); + result = census.Inspect(700); + Expect("departure restores complete census", result.Complete); + Expect_Equal("departed peer excluded", result.ActivePlayers, 1u); + Expect_Equal("remaining peer wins census", result.WorstRoundTrip, 90u); + Expect("clear unavailable report", census.Clear_Report(1)); + Expect("cleared active report makes census incomplete", !census.Inspect(700).Complete); + } + + + void Test_Rungs_And_Fudge(void) + { + using namespace NetTiming; + + Expect_Equal("initial FSR", Settings_For_Rung(INITIAL_TIMING_RUNG).FrameSendRate, 3u); + Expect_Equal("initial MaxAhead", Settings_For_Rung(INITIAL_TIMING_RUNG).MaxAhead, 9u); + 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("unaligned settings invalid", !Timing_Settings_Are_Valid({3, 10})); + + Expect_Equal("no latency fudge", Apply_Latency_Fudge(100, LatencyFudge::None), 100u); + Expect_Equal("half latency fudge", Apply_Latency_Fudge(100, LatencyFudge::Half), 150u); + Expect_Equal("double latency fudge", Apply_Latency_Fudge(100, LatencyFudge::Double), 200u); + Expect_Equal("triple latency fudge", Apply_Latency_Fudge(100, LatencyFudge::Triple), 300u); + Expect_Equal("half fudge rounds up", Apply_Latency_Fudge(1, LatencyFudge::Half), 2u); + + Expect_Equal("zero RTT selects best rung", Select_Timing_Rung(0, 60, LatencyFudge::None), 1u); + Expect_Equal("100 ms fits best rung", Select_Timing_Rung(100, 60, LatencyFudge::None), 1u); + Expect_Equal("101 ms advances a rung", Select_Timing_Rung(101, 60, LatencyFudge::None), 2u); + Expect_Equal("300 ms selects balanced rung", Select_Timing_Rung(300, 60, LatencyFudge::None), 5u); + Expect_Equal("fudge raises selected rung", Select_Timing_Rung(100, 60, LatencyFudge::Half), 3u); + TimingSettings const high_rtt = Select_Timing_Settings(2000, 60, LatencyFudge::None); + 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, LatencyFudge::Triple); + 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_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)); + + std::optional settings = + Decode_Timing_Settings(60, 19, 3, 10); + Expect("fog-padded timing decodes", settings && *settings == NetTiming::TimingSettings{3, 9}); + Expect("zero desired FPS is rejected", !Decode_Timing_Settings(0, 19, 3, 10)); + Expect("desired FPS above 60 is rejected", !Decode_Timing_Settings(61, 19, 3, 10)); + Expect("fog subtraction underflow is rejected", !Decode_Timing_Settings(60, 9, 3, 10)); + Expect("zero send period is rejected", !Decode_Timing_Settings(60, 19, 0, 10)); + Expect("unaligned horizon is rejected", !Decode_Timing_Settings(60, 20, 3, 10)); + Expect("aligned 250-frame horizon is valid", Decode_Timing_Settings(60, 260, 10, 10).has_value()); + Expect("horizon above 250 is rejected", !Decode_Timing_Settings(60, 261, 10, 10)); + + Expect("bounded report is valid", Network_Report_Is_Valid(1000, 65534)); + Expect("unavailable RTT sentinel is valid", Network_Report_Is_Valid(0, UINT16_MAX)); + Expect("process time above engine cap is rejected", !Network_Report_Is_Valid(1001, 10)); + } + + + void Record_One(NetTiming::TimingReportCensus & census, NetTiming::Milliseconds rtt, + std::uint32_t frame) + { + census.Record_Report(1, rtt, frame); + } + + + void Test_Hysteresis_And_Cooldown(void) + { + using namespace NetTiming; + + TimingReportCensus reports; + reports.Set_Player_Active(1, true); + BalancedTimingPolicy policy; + + Record_One(reports, 0, 0); + TimingEvaluation result = policy.Evaluate(reports.Inspect(0), 60, LatencyFudge::None, 0); + Expect("first good evaluation does not change", !result.Changed); + Record_One(reports, 0, 256); + result = policy.Evaluate(reports.Inspect(256), 60, LatencyFudge::None, 256); + Expect("second good evaluation does not change", !result.Changed); + Record_One(reports, 0, 512); + result = policy.Evaluate(reports.Inspect(512), 60, LatencyFudge::None, 512); + Expect("third good evaluation improves one rung", result.Changed); + Expect_Equal("one-rung improvement", policy.Current_Rung(), 2u); + + Record_One(reports, 0, 600); + result = policy.Evaluate(reports.Inspect(600), 60, LatencyFudge::None, 600); + Expect("evaluation interval enforced", !result.Evaluated); + Expect_Equal("cooldown leaves rung", policy.Current_Rung(), 2u); + + BalancedTimingPolicy headroom; + TimingReportCensus edge; + edge.Set_Player_Active(1, true); + for (std::uint32_t frame : {0u, 256u, 512u}) { + Record_One(edge, 120, frame); + headroom.Evaluate(edge.Inspect(frame), 60, LatencyFudge::None, frame); + } + Expect_Equal("20 percent headroom blocks marginal improvement", headroom.Current_Rung(), 3u); + + Record_One(reports, 2000, 768); + result = policy.Evaluate(reports.Inspect(768), 60, LatencyFudge::None, 768); + 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 : {1024u, 1280u, 1536u}) { + Record_One(reports, 1300, frame); + result = policy.Evaluate(reports.Inspect(frame), 60, LatencyFudge::None, 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_Transition_Budget(void) + { + using namespace NetTiming; + + TimingReportCensus stale; + stale.Set_Player_Active(1, true); + BalancedTimingPolicy stale_policy; + TimingEvaluation result = stale_policy.Evaluate(stale.Inspect(0), 60, LatencyFudge::None, 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, 100, 256); + stale_policy.Evaluate(stale.Inspect(256), 60, LatencyFudge::None, 256); + result = stale_policy.Evaluate(stale.Inspect(256 + REPORT_EXPIRY), 60, + LatencyFudge::None, 256 + REPORT_EXPIRY); + Expect("established stale report worsens policy", result.Changed); + Expect_Equal("established stale report chooses worst rung", stale_policy.Current_Rung(), 10u); + + stale.Set_Player_Active(1, false); + for (std::uint32_t frame : {1024u, 1280u, 1536u}) { + stale_policy.Evaluate(stale.Inspect(frame), 60, LatencyFudge::None, frame); + } + Expect_Equal("departed peer allows recovery", stale_policy.Current_Rung(), 9u); + + TimingReportCensus reports; + reports.Set_Player_Active(1, true); + BalancedTimingPolicy policy; + std::uint32_t frame = 0; + + auto evaluate = [&](Milliseconds rtt) { + Record_One(reports, rtt, frame); + policy.Evaluate(reports.Inspect(frame), 60, LatencyFudge::None, frame); + frame += EVALUATION_INTERVAL; + }; + + evaluate(2000); // 1: 3 -> 10 + for (int cycle = 0; cycle < 3; cycle++) { + evaluate(0); + evaluate(0); + evaluate(0); // even transition: 10 -> 9 + evaluate(2000); // odd transition: 9 -> 10 + } + evaluate(0); + evaluate(0); + evaluate(0); // 8: 10 -> 9 + + Expect_Equal("transition budget reached", policy.Reversible_Changes(), REVERSIBLE_CHANGE_LIMIT); + Expect_Equal("eighth transition leaves rung nine", policy.Current_Rung(), 9u); + for (int i = 0; i < 6; i++) { + evaluate(0); + } + Expect_Equal("budget locks further improvement", policy.Current_Rung(), 9u); + evaluate(2000); + Expect_Equal("worsening remains available after budget", policy.Current_Rung(), 10u); + } + + + 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("staged update not early", !Timing_Update_Is_Due(110, staged->ActivationFrame)); + Expect("staged update due", Timing_Update_Is_Due(111, staged->ActivationFrame)); + + staged = Stage_Timing_Update({3, 9}, {2, 6}, 100); + Expect_Equal("both periods use LCM", staged->ActivationFrame, 114u); + + 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); + staged = immediate; + Expect("an immediate worse update replaces a pending decrease", + staged && !staged->Deferred && staged->Settings == TimingSettings{5, 15}); + + 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)); + Expect("overflowing staging rejected", !Stage_Timing_Update({10, 30}, {9, 27}, + std::numeric_limits::max() - 10)); + } +} + + +int main(void) +{ + Test_Rtt_Estimator(); + Test_Clock_And_Wrap(); + Test_Retransmit_Backoff(); + Test_Loss_Jitter_And_Reordering(); + Test_Census(); + Test_Rungs_And_Fudge(); + Test_Event_Semantics(); + Test_Hysteresis_And_Cooldown(); + Test_Stale_And_Transition_Budget(); + Test_Staged_Decrease(); + + if (Failures != 0) { + std::cerr << Failures << " network timing checks failed\n"; + return(1); + } + + std::cout << "All network timing checks passed\n"; + return(0); +} From af358a929d5e6aae2b889295a41b22561f7e31bb Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 17:04:02 +0300 Subject: [PATCH 04/13] Document network synchronization --- manual/changes/adaptive-network-timing.md | 20 ++++++ manual/changes/network-packet-validation.md | 23 ++++++ .../systems/network-synchronization.md | 72 +++++++++++++++++++ 3 files changed, 115 insertions(+) create mode 100644 manual/changes/adaptive-network-timing.md create mode 100644 manual/changes/network-packet-validation.md create mode 100644 manual/content/systems/network-synchronization.md diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md new file mode 100644 index 0000000..87952e5 --- /dev/null +++ b/manual/changes/adaptive-network-timing.md @@ -0,0 +1,20 @@ +--- +title: Adapt multiplayer timing to every connection +category: performance +release: 0.2.0 +targets: +- type: system + id: network-synchronization + effect: added +credit: +- ZivDero +--- + +Network games measure every peer-to-peer path instead of letting the host's own +links stand for the whole match. Healthy links use their own retransmission +timers, and the synchronized command delay can return toward a more responsive +setting after a temporary slowdown clears. + +Timing reports extend the network and multiplayer-recording event stream. Run +every player with the same OpenTS snapshot and play a recording with the +snapshot that created it; 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 0000000..619fd22 --- /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 network traffic is rejected before it can enter the simulation. +Undersized and oversized envelopes, truncated events, invalid indices, and +packets claiming another player's identity no longer reach the state they +could crash or corrupt. A rejected command packet can still make an +uncooperative peer stall a lockstep match, but it cannot make the receiver use +bytes outside that packet. + +In-game chat, progress, sign-off, ready, and kick packets now have to come from +an address in the match's player list. Chat identity and kick votes are taken +from that membership record, so changing the corresponding fields in a packet +cannot impersonate another player. + +The packet layout used before this change remains accepted. All players should +still 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 0000000..02da28c --- /dev/null +++ b/manual/content/systems/network-synchronization.md @@ -0,0 +1,72 @@ +--- +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 packet passes through bounded transport, connection, and event decoders +before it can change the simulation. The receiver checks the packet envelope, +the declared event sizes, the complete event stream, and the player identity of +the connection that delivered it. A malformed, truncated, oversized, or +misattributed packet is discarded as one packet; no events from it enter the +simulation queue. + +Public game and player discovery still accepts queries from outside the +session. Once a match is running, chat, loading progress, sign-off, ready, and +kick-control packets are accepted only from an address recorded in the player +list. The recorded player identity, rather than the name or voter claimed by +the packet, owns that action. + +The packet checksum detects damaged bytes. It is not authentication or +encryption, and a network game still assumes that its players and the network +path carrying their traffic are trusted. + +## Link measurement and retransmission + +Each connection measures its own round-trip time. An acknowledgement measures +the link only when its packet was transmitted once, because an acknowledgement +after a retry cannot identify which transmission it answers. Lost packets use +progressively longer retry intervals, while a healthy connection keeps the +interval derived from its own measurements instead of inheriting the slowest +other link in the match. + +## Match timing + +Every player periodically reports two bounded measurements: the processing +time of its simulation frames and the worst round-trip time among its own +connections. The deterministic session master combines the reports, chooses +one timing rung, and sends the resulting frame rate, send period, and +look-ahead as a synchronized event. Reports from other players can influence +that decision, but a timing event sent by any of them is ignored. + +The match begins with commands sent every three frames and a nine-frame +look-ahead. A worse measured path can move directly to a more conservative +rung. Returning toward a more responsive rung requires sustained headroom and +moves one rung at a time, so short spikes do not make the timing oscillate. A +decrease waits until commands scheduled with the previous look-ahead have +cleared that horizon. + +A connected player whose established report expires is treated +conservatively. Removing that player removes its report as well, allowing the +remaining links to determine later timing decisions. + +The latency-margin setting keeps its existing four steps. They apply one, +one-and-a-half, two, or three times the measured round trip before a rung is +chosen. The game-speed setting also keeps its existing frame-rate mapping, +including speed zero as 60 frames per second. + +## 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. There is no player-facing timing setting to +migrate. From 852d74918e0ddc6ae48c2179206d49d5632d5f5c Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 19:47:16 +0300 Subject: [PATCH 05/13] Remove fog-of-war timing padding --- code/event.cpp | 5 ++--- code/netsemantic.cpp | 11 +++-------- code/netsemantic.h | 3 +-- code/queue.cpp | 4 ++-- manual/changes/adaptive-network-timing.md | 3 ++- tests/nettiming/nettiming.cpp | 19 +++++++++---------- 6 files changed, 19 insertions(+), 26 deletions(-) diff --git a/code/event.cpp b/code/event.cpp index 63188b1..2a598db 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -1183,14 +1183,13 @@ void EventClass::Execute(void) break; } - unsigned int const fog_padding = Scen->Special.IsFogOfWar ? 10u : 0u; - if (Data.Timing.MaxAhead < fog_padding || Frame < 0) { + 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, fog_padding); + Data.Timing.DesiredFrameRate, Data.Timing.MaxAhead, Data.Timing.FrameSendRate); if (!decoded_settings) { Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, Data.Timing.MaxAhead); break; diff --git a/code/netsemantic.cpp b/code/netsemantic.cpp index c67df7a..af319e2 100644 --- a/code/netsemantic.cpp +++ b/code/netsemantic.cpp @@ -9,9 +9,6 @@ #include "netsemantic.h" -#include - - namespace NetSemantic { /// Checks a signed index against a collection size. @@ -57,15 +54,13 @@ namespace NetSemantic /// Validates and decodes settings carried by a timing event. - std::optional Decode_Timing_Settings(std::uint16_t desired_frame_rate, std::uint16_t wire_max_ahead, - std::uint8_t frame_send_rate, unsigned int fog_padding) noexcept + 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 || fog_padding > std::numeric_limits::max() - || wire_max_ahead < fog_padding) { + if (desired_frame_rate == 0 || desired_frame_rate > 60) { return(std::nullopt); } - NetTiming::TimingSettings const settings{frame_send_rate, wire_max_ahead - fog_padding}; + 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 index c32d708..1fb4e0b 100644 --- a/code/netsemantic.h +++ b/code/netsemantic.h @@ -30,8 +30,7 @@ namespace NetSemantic bool Timing_Authority_Is_Valid(int sender, int master) noexcept; - std::optional Decode_Timing_Settings(std::uint16_t desired_frame_rate, std::uint16_t wire_max_ahead, - std::uint8_t frame_send_rate, unsigned int fog_padding) noexcept; + std::optional Decode_Timing_Settings(std::uint16_t desired_frame_rate, std::uint16_t max_ahead, std::uint8_t frame_send_rate) noexcept; bool Network_Report_Is_Valid(std::uint16_t process_milliseconds, std::uint16_t round_trip_milliseconds) noexcept; } diff --git a/code/queue.cpp b/code/queue.cpp index 5b0772c..ad7cf88 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -1576,7 +1576,7 @@ static void Generate_Real_Timing_Event(void) if (Session.PrecalcDesiredFrameRate > 0 && Session.PrecalcDesiredFrameRate <= 60 && NetTiming::Timing_Settings_Are_Valid(settings)) { event.Type = EventClass::TIMING; event.Data.Timing.DesiredFrameRate = Session.PrecalcDesiredFrameRate; - event.Data.Timing.MaxAhead = settings.MaxAhead + (Scen->Special.IsFogOfWar ? 10u : 0u); + event.Data.Timing.MaxAhead = settings.MaxAhead; event.Data.Timing.FrameSendRate = settings.FrameSendRate; OutList.push_back(event); } else { @@ -1606,7 +1606,7 @@ static void Generate_Real_Timing_Event(void) event.Type = EventClass::TIMING; event.Data.Timing.DesiredFrameRate = desired_frame_rate; - event.Data.Timing.MaxAhead = evaluation.Settings.MaxAhead + (Scen->Special.IsFogOfWar ? 10u : 0u); + event.Data.Timing.MaxAhead = evaluation.Settings.MaxAhead; event.Data.Timing.FrameSendRate = evaluation.Settings.FrameSendRate; OutList.push_back(event); } diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md index 87952e5..4b5e751 100644 --- a/manual/changes/adaptive-network-timing.md +++ b/manual/changes/adaptive-network-timing.md @@ -17,4 +17,5 @@ setting after a temporary slowdown clears. Timing reports extend the network and multiplayer-recording event stream. Run every player with the same OpenTS snapshot and play a recording with the -snapshot that created it; there is no configuration to migrate. +snapshot that created it. Timing events carry the selected look-ahead directly; +fog of war no longer adds an offset. There is no configuration to migrate. diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index d537961..06de337 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -322,16 +322,15 @@ namespace Expect("guest timing authority is rejected", !Timing_Authority_Is_Valid(3, 2)); Expect("unresolved timing authority is rejected", !Timing_Authority_Is_Valid(2, -1)); - std::optional settings = - Decode_Timing_Settings(60, 19, 3, 10); - Expect("fog-padded timing decodes", settings && *settings == NetTiming::TimingSettings{3, 9}); - Expect("zero desired FPS is rejected", !Decode_Timing_Settings(0, 19, 3, 10)); - Expect("desired FPS above 60 is rejected", !Decode_Timing_Settings(61, 19, 3, 10)); - Expect("fog subtraction underflow is rejected", !Decode_Timing_Settings(60, 9, 3, 10)); - Expect("zero send period is rejected", !Decode_Timing_Settings(60, 19, 0, 10)); - Expect("unaligned horizon is rejected", !Decode_Timing_Settings(60, 20, 3, 10)); - Expect("aligned 250-frame horizon is valid", Decode_Timing_Settings(60, 260, 10, 10).has_value()); - Expect("horizon above 250 is rejected", !Decode_Timing_Settings(60, 261, 10, 10)); + 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)); Expect("bounded report is valid", Network_Report_Is_Valid(1000, 65534)); Expect("unavailable RTT sentinel is valid", Network_Report_Is_Valid(0, UINT16_MAX)); From 313784b88f064673e87fe9fe8d765c24c5d68eda Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 21:02:53 +0300 Subject: [PATCH 06/13] Scope network helpers in namespaces --- code/connect.cpp | 44 +- code/connect.h | 10 +- code/conquer.cpp | 22 +- code/event.cpp | 6 +- code/ipxgconn.cpp | 14 +- code/netadmit.cpp | 213 +++---- code/netadmit.h | 95 +-- code/netglobal.cpp | 252 ++++---- code/netglobal.h | 108 ++-- code/netpacket.cpp | 1009 ++++++++++++++++--------------- code/netpacket.h | 141 ++--- code/netreader.cpp | 59 +- code/netreader.h | 55 +- code/nettime.cpp | 10 + code/nettime.h | 6 - code/nettiming.h | 7 + code/queue.cpp | 38 +- code/queue.h | 7 +- code/session.cpp | 10 +- code/session.h | 8 +- code/wsproto.cpp | 2 +- code/wspudp.cpp | 8 +- tests/netpacket/netcontract.cpp | 274 ++++----- 23 files changed, 1212 insertions(+), 1186 deletions(-) diff --git a/code/connect.cpp b/code/connect.cpp index 94c095d..eb9f67c 100644 --- a/code/connect.cpp +++ b/code/connect.cpp @@ -327,9 +327,9 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) if (buf != NULL && buflen > 0) { packet_bytes = {static_cast(buf), static_cast(buflen)}; } - NetConnectionAdmission const admission = Admit_Connection_Packet(packet_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + NetAdmission::ConnectionResult const admission = NetAdmission::Admit_Connection_Packet(packet_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); if (!admission.Succeeded()) { - Record_Admission_Drop(admission.Error, admission.Code); + Record_Admission_Drop(admission.ErrorCode, admission.Code); return(1); } @@ -365,7 +365,7 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) if (send_entry->Buffer != NULL && send_entry->BufLen > 0) { entry_bytes = {reinterpret_cast(send_entry->Buffer), static_cast(send_entry->BufLen)}; } - NetConnectionAdmission const entry = Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + NetAdmission::ConnectionResult const entry = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); /*............................................................... If ACK is for this entry, mark it @@ -437,7 +437,7 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) if (rec_entry->Buffer != NULL && rec_entry->BufLen > 0) { entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; } - NetConnectionAdmission const entry = Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + NetAdmission::ConnectionResult const entry = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); /*........................................................... Packet is found; it's a resend @@ -497,7 +497,7 @@ int ConnectionClass::Receive_Packet (void * buf, int buflen) if (rec_entry->Buffer != NULL && rec_entry->BufLen > 0) { entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; } - NetConnectionAdmission const entry = Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + NetAdmission::ConnectionResult const entry = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); /*...................................................... Entry is found @@ -575,10 +575,10 @@ int ConnectionClass::Get_Packet (void * buf, int capacity, int *buflen) if (rec_entry->Buffer != NULL && rec_entry->BufLen > 0) { entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; } - NetConnectionAdmission const admission = Admit_Connection_Packet(entry_bytes, sizeof(CommHeaderType), static_cast(MaxPacketLen)); + 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.Error, admission.Code); + Record_Admission_Drop(admission.ErrorCode, admission.Code); continue; } if (admission.Code == PACKET_ACK) { @@ -597,8 +597,8 @@ int ConnectionClass::Get_Packet (void * buf, int capacity, int *buflen) LastReadID = admission.PacketID; rec_entry->IsRead = 1; - NetAdmissionError const destination = Validate_Network_Destination(admission.Payload, static_cast(capacity)); - if (destination != NetAdmissionError::NONE) { + NetAdmission::Error const destination = NetAdmission::Validate_Destination(admission.Payload, static_cast(capacity)); + if (destination != NetAdmission::Error::NONE) { Record_Admission_Drop(destination, admission.Code); continue; } @@ -612,8 +612,8 @@ int ConnectionClass::Get_Packet (void * buf, int capacity, int *buflen) else if (admission.Code == PACKET_DATA_NOACK) { rec_entry->IsRead = 1; - NetAdmissionError const destination = Validate_Network_Destination(admission.Payload, static_cast(capacity)); - if (destination != NetAdmissionError::NONE) { + NetAdmission::Error const destination = NetAdmission::Validate_Destination(admission.Payload, static_cast(capacity)); + if (destination != NetAdmission::Error::NONE) { Record_Admission_Drop(destination, admission.Code); continue; } @@ -674,31 +674,31 @@ void ConnectionClass::Record_Packet_Drop(PacketDropReasonType reason) /// Maps a shared admission rejection to the connection counters. -void ConnectionClass::Record_Admission_Drop(NetAdmissionError error, unsigned char code) +void ConnectionClass::Record_Admission_Drop(NetAdmission::Error error, unsigned char code) { switch (error) { - case NetAdmissionError::HEADER_TOO_SHORT: - case NetAdmissionError::DATAGRAM_TOO_SHORT: + case NetAdmission::Error::HEADER_TOO_SHORT: + case NetAdmission::Error::DATAGRAM_TOO_SHORT: Record_Packet_Drop(CONNECTION_DROP_SHORT_HEADER); break; - case NetAdmissionError::PACKET_TOO_LARGE: - case NetAdmissionError::DATAGRAM_TOO_LARGE: + case NetAdmission::Error::PACKET_TOO_LARGE: + case NetAdmission::Error::DATAGRAM_TOO_LARGE: Record_Packet_Drop(CONNECTION_DROP_OVERSIZED_DATA); break; - case NetAdmissionError::INVALID_PACKET_CODE: + case NetAdmission::Error::INVALID_PACKET_CODE: Record_Packet_Drop(CONNECTION_DROP_INVALID_CODE); break; - case NetAdmissionError::INVALID_PACKET_LENGTH: + case NetAdmission::Error::INVALID_PACKET_LENGTH: Record_Packet_Drop(code == PACKET_ACK ? CONNECTION_DROP_INVALID_LENGTH : CONNECTION_DROP_EMPTY_DATA); break; - case NetAdmissionError::DESTINATION_TOO_SMALL: + case NetAdmission::Error::DESTINATION_TOO_SMALL: Record_Packet_Drop(CONNECTION_DROP_OUTPUT_TOO_SMALL); break; - case NetAdmissionError::BAD_CRC: + case NetAdmission::Error::BAD_CRC: Record_Packet_Drop(CONNECTION_DROP_INVALID_LENGTH); break; - case NetAdmissionError::NONE: - case NetAdmissionError::COUNT: + case NetAdmission::Error::NONE: + case NetAdmission::Error::COUNT: break; } } diff --git a/code/connect.h b/code/connect.h index e7e4119..20ddbde 100644 --- a/code/connect.h +++ b/code/connect.h @@ -135,10 +135,10 @@ class ConnectionClass These are the possible values for the Code field of the CommHeaderType: .....................................................................*/ enum ConnectionEnum { - PACKET_DATA_ACK = static_cast(NetPacketCode::DATA_ACK), // this is a data packet requiring an ACK - PACKET_DATA_NOACK = static_cast(NetPacketCode::DATA_NOACK), // this is a data packet not requiring an ACK - PACKET_ACK = static_cast(NetPacketCode::ACK), // this is an ACK for a packet - PACKET_COUNT = static_cast(NetPacketCode::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 }; /*..................................................................... @@ -237,7 +237,7 @@ class ConnectionClass 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(NetAdmissionError error, unsigned char code); + 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 diff --git a/code/conquer.cpp b/code/conquer.cpp index 653d5d8..676088e 100644 --- a/code/conquer.cpp +++ b/code/conquer.cpp @@ -524,15 +524,15 @@ bool MapGen_Call_Back(void) } -static NetGlobalRejectionCounters GlobalPacketRejections; +static NetGlobal::RejectionCounters GlobalPacketRejections; /// Records a rejected global packet. -static void Record_Global_Packet_Rejection(NetGlobalDecodeError error) +static void Record_Global_Packet_Rejection(NetGlobal::DecodeError error) { - NetGlobalRejectionRecord const record = GlobalPacketRejections.Record(error); + NetGlobal::RejectionRecord const record = GlobalPacketRejections.Record(error); if (record.ShouldLog) { - DebugString("In-game global packet drop [%s]: %u\n", Net_Global_Error_Name(error), record.Count); + DebugString("In-game global packet drop [%s]: %u\n", NetGlobal::Error_Name(error), record.Count); } } @@ -553,9 +553,9 @@ static NodeNameType * Session_Member_From_Address(IPXAddressClass & address, int /// Builds the membership facts used to validate a global packet. -static NetGlobalValidationContext Global_Validation_Context(NodeNameType const * sender) +static NetGlobal::ValidationContext Global_Validation_Context(NodeNameType const * sender) { - NetGlobalValidationContext context; + 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())) { @@ -594,10 +594,10 @@ void IPX_Call_Back(void) if (Session.GProductID == IPXGlobalConnClass::COMMAND_AND_CONQUER2) { int sender_index = -1; NodeNameType * sender = Session_Member_From_Address(Session.GAddress, sender_index); - NetGlobalValidationContext const context = Global_Validation_Context(sender); - NetGlobalDecodeError error = Validate_In_Game_Global(Session.GPacket, Session.GPacketlen, context); + NetGlobal::ValidationContext const context = Global_Validation_Context(sender); + NetGlobal::DecodeError error = NetGlobal::Validate_In_Game_Packet(Session.GPacket, Session.GPacketlen, context); - if (error != NetGlobalDecodeError::NONE) { + if (error != NetGlobal::DecodeError::NONE) { Record_Global_Packet_Rejection(error); } else { switch (Session.GPacket.Command) { @@ -608,7 +608,7 @@ void IPX_Call_Back(void) case NET_PROPOSE_KICK: error = Kick_Packet_Received(sender->Player.ID, static_cast(Session.GPacket.Kick.KickeeID)); - if (error != NetGlobalDecodeError::NONE) { + if (error != NetGlobal::DecodeError::NONE) { Record_Global_Packet_Rejection(error); } break; @@ -649,7 +649,7 @@ void IPX_Call_Back(void) break; default: - Record_Global_Packet_Rejection(NetGlobalDecodeError::INVALID_COMMAND); + Record_Global_Packet_Rejection(NetGlobal::DecodeError::INVALID_COMMAND); break; } } diff --git a/code/event.cpp b/code/event.cpp index 2a598db..e322af6 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -1198,8 +1198,8 @@ void EventClass::Execute(void) unsigned int const old_frame_send_rate = Session.FrameSendRate; unsigned int const old_max_ahead = Session.MaxAhead; - NetworkTimingScheduleResult const result = Session.Schedule_Network_Timing(settings, Data.Timing.DesiredFrameRate, (unsigned int)Frame); - if (result == NetworkTimingScheduleResult::Rejected) { + 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; } @@ -1212,7 +1212,7 @@ 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 (result == NetworkTimingScheduleResult::Applied && + if (result == NetTiming::ScheduleResult::Applied && (settings.MaxAhead > old_max_ahead || settings.FrameSendRate > old_frame_send_rate)) { NewMaxAheadFrame1 = Frame; NewMaxAheadFrame2 = settings.FrameSendRate * ((settings.FrameSendRate + settings.MaxAhead + Frame - 1) / settings.FrameSendRate); diff --git a/code/ipxgconn.cpp b/code/ipxgconn.cpp index b823660..2315f2c 100644 --- a/code/ipxgconn.cpp +++ b/code/ipxgconn.cpp @@ -235,9 +235,9 @@ int IPXGlobalConnClass::Receive_Packet (void * buf, int buflen, if (buf != NULL && buflen > 0) { packet_bytes = {static_cast(buf), static_cast(buflen)}; } - NetConnectionAdmission const packet = Admit_Connection_Packet(packet_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); + NetAdmission::ConnectionResult const packet = NetAdmission::Admit_Connection_Packet(packet_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); if (!packet.Succeeded()) { - Record_Admission_Drop(packet.Error, packet.Code); + Record_Admission_Drop(packet.ErrorCode, packet.Code); return(1); } @@ -348,7 +348,7 @@ int IPXGlobalConnClass::Receive_Packet (void * buf, int buflen, if (send_entry->Buffer != NULL && send_entry->BufLen > 0) { entry_bytes = {reinterpret_cast(send_entry->Buffer), static_cast(send_entry->BufLen)}; } - NetConnectionAdmission const entry = Admit_Connection_Packet(entry_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); + NetAdmission::ConnectionResult const entry = NetAdmission::Admit_Connection_Packet(entry_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); /*............................................................... If ACK is for this entry, mark it @@ -423,10 +423,10 @@ int IPXGlobalConnClass::Get_Packet (void * buf, int capacity, int *buflen, if (rec_entry->Buffer != NULL && rec_entry->BufLen > 0) { entry_bytes = {reinterpret_cast(rec_entry->Buffer), static_cast(rec_entry->BufLen)}; } - NetConnectionAdmission const admission = Admit_Connection_Packet(entry_bytes, sizeof(GlobalHeaderType), static_cast(MaxPacketLen)); + 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.Error, admission.Code); + Record_Admission_Drop(admission.ErrorCode, admission.Code); return(0); } if (admission.Code == PACKET_ACK) { @@ -443,8 +443,8 @@ int IPXGlobalConnClass::Get_Packet (void * buf, int capacity, int *buflen, /*..................................................................... Copy data packet .....................................................................*/ - NetAdmissionError const destination = Validate_Network_Destination(admission.Payload, static_cast(capacity)); - if (destination != NetAdmissionError::NONE) { + 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); } diff --git a/code/netadmit.cpp b/code/netadmit.cpp index 80d3562..82db1ad 100644 --- a/code/netadmit.cpp +++ b/code/netadmit.cpp @@ -12,125 +12,128 @@ #include -namespace { - -constexpr std::size_t CRC_SIZE = sizeof(std::uint32_t); -constexpr std::size_t COMMON_HEADER_SIZE = NET_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_Network_Datagram_CRC(std::span payload) noexcept +namespace NetAdmission { - 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); + 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); } - 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. -NetDatagramAdmission Admit_Network_Datagram(std::span datagram, std::size_t payload_capacity) noexcept -{ - NetDatagramAdmission result; - if (datagram.size() <= CRC_SIZE) { - result.Error = NetAdmissionError::DATAGRAM_TOO_SHORT; + /// 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); } - result.Payload = datagram.subspan(CRC_SIZE); - if (result.Payload.size() > payload_capacity) { - result.Payload = {}; - result.Error = NetAdmissionError::DATAGRAM_TOO_LARGE; - return(result); - } - std::memcpy(&result.WireCRC, datagram.data(), sizeof(result.WireCRC)); - if (result.WireCRC != Calculate_Network_Datagram_CRC(result.Payload)) { - result.Payload = {}; - result.Error = NetAdmissionError::BAD_CRC; - } - return(result); -} - - -/// Validates a reliable-channel packet envelope. -NetConnectionAdmission Admit_Connection_Packet(std::span packet, std::size_t header_size, std::size_t packet_capacity) noexcept -{ - NetConnectionAdmission result; - if (header_size < COMMON_HEADER_SIZE || packet.size() < header_size) { - result.Error = NetAdmissionError::HEADER_TOO_SHORT; - return(result); - } - if (packet.size() > packet_capacity) { - result.Error = NetAdmissionError::PACKET_TOO_LARGE; + /// 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); } - 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(NetPacketCode::COUNT)) { - result.Error = NetAdmissionError::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(NetPacketCode::ACK) && !result.Payload.empty(); - bool const data_has_no_payload = - (result.Code == static_cast(NetPacketCode::DATA_ACK) - || result.Code == static_cast(NetPacketCode::DATA_NOACK)) - && result.Payload.empty(); - if (ack_has_payload || data_has_no_payload) { - result.Payload = {}; - result.Error = NetAdmissionError::INVALID_PACKET_LENGTH; + /// 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); } - return(result); -} -/// Checks that a caller can hold an admitted payload. -NetAdmissionError Validate_Network_Destination(std::span payload, std::size_t destination_capacity) noexcept -{ - return(payload.size() <= destination_capacity ? NetAdmissionError::NONE : NetAdmissionError::DESTINATION_TOO_SMALL); -} - - -/// Returns a stable admission-error name. -char const * Net_Admission_Error_Name(NetAdmissionError error) noexcept -{ - switch (error) { - case NetAdmissionError::NONE: return("none"); - case NetAdmissionError::DATAGRAM_TOO_SHORT: return("datagram too short"); - case NetAdmissionError::DATAGRAM_TOO_LARGE: return("datagram too large"); - case NetAdmissionError::BAD_CRC: return("bad datagram CRC"); - case NetAdmissionError::HEADER_TOO_SHORT: return("message header too short"); - case NetAdmissionError::PACKET_TOO_LARGE: return("message too large"); - case NetAdmissionError::INVALID_PACKET_CODE: return("invalid message code"); - case NetAdmissionError::INVALID_PACKET_LENGTH: return("invalid message length"); - case NetAdmissionError::DESTINATION_TOO_SMALL: return("destination too small"); - case NetAdmissionError::COUNT: break; + /// 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"); } - return("unknown admission error"); } diff --git a/code/netadmit.h b/code/netadmit.h index a26a2e8..8a871c6 100644 --- a/code/netadmit.h +++ b/code/netadmit.h @@ -14,63 +14,66 @@ #include -constexpr std::size_t NET_DATAGRAM_PAYLOAD_CAPACITY = 768; -constexpr std::size_t NET_PRIVATE_HEADER_SIZE = sizeof(std::uint16_t) + sizeof(std::uint8_t) + sizeof(std::uint32_t); -constexpr std::size_t NET_GLOBAL_HEADER_SIZE = NET_PRIVATE_HEADER_SIZE + sizeof(std::uint16_t); +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 NetPacketCode : std::uint8_t -{ - DATA_ACK, - DATA_NOACK, - ACK, - COUNT, -}; + enum class PacketCode : std::uint8_t + { + DATA_ACK, + DATA_NOACK, + ACK, + COUNT, + }; -enum class NetAdmissionError -{ - 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 NetDatagramAdmission -{ - NetAdmissionError Error = NetAdmissionError::NONE; - std::uint32_t WireCRC = 0; - std::span Payload; + 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, + }; - bool Succeeded(void) const noexcept {return(Error == NetAdmissionError::NONE);} -}; + struct DatagramResult + { + Error ErrorCode = Error::NONE; + std::uint32_t WireCRC = 0; + std::span Payload; -struct NetConnectionAdmission -{ - NetAdmissionError Error = NetAdmissionError::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);} + }; + + + 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(Error == NetAdmissionError::NONE);} -}; + bool Succeeded(void) const noexcept {return(ErrorCode == Error::NONE);} + }; -std::uint32_t Calculate_Network_Datagram_CRC(std::span payload) noexcept; + std::uint32_t Calculate_Datagram_CRC(std::span payload) noexcept; -NetDatagramAdmission Admit_Network_Datagram(std::span datagram, std::size_t payload_capacity = NET_DATAGRAM_PAYLOAD_CAPACITY) noexcept; + DatagramResult Admit_Datagram(std::span datagram, std::size_t payload_capacity = DATAGRAM_PAYLOAD_CAPACITY) noexcept; -NetConnectionAdmission Admit_Connection_Packet(std::span packet, std::size_t header_size, std::size_t packet_capacity) noexcept; + ConnectionResult Admit_Connection_Packet(std::span packet, std::size_t header_size, std::size_t packet_capacity) noexcept; -NetAdmissionError Validate_Network_Destination(std::span payload, std::size_t destination_capacity) noexcept; + Error Validate_Destination(std::span payload, std::size_t destination_capacity) noexcept; -char const * Net_Admission_Error_Name(NetAdmissionError error) noexcept; + char const * Error_Name(Error error) noexcept; +} diff --git a/code/netglobal.cpp b/code/netglobal.cpp index 2b83f54..e90483e 100644 --- a/code/netglobal.cpp +++ b/code/netglobal.cpp @@ -16,162 +16,166 @@ #include -namespace { - -static_assert(std::is_trivially_copyable_v); - - -/// Checks that a fixed wire string contains a terminator. -bool Has_Terminator(char const * text, std::size_t capacity) +namespace NetGlobal { - return(std::memchr(text, '\0', capacity) != NULL); -} + namespace { + static_assert(std::is_trivially_copyable_v); + constexpr std::size_t PACKET_SIZE = sizeof(GlobalPacketType); -/// Checks a player index against the current session roster. -bool Is_Active_Player(NetGlobalValidationContext const & context, int player) -{ - return(player >= 0 && player < static_cast(context.ActivePlayers.size()) && context.ActivePlayers[player]); -} -} // namespace + /// 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); + } -/// Clears an outgoing packet before selecting its command. -void Initialize_Global_Packet(GlobalPacketType & packet, NetCommandType command) noexcept -{ - std::memset(&packet, 0, sizeof(packet)); - packet.Command = command; -} + /// 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]); + } - -/// Identifies public in-game discovery commands. -bool Net_Global_Command_Is_Public(NetCommandType command) -{ - return(command == NET_QUERY_GAME || command == NET_QUERY_PLAYER); -} + } // namespace -/// Identifies commands restricted to session members. -bool Net_Global_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); + /// 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; } -} -/// Validates an in-game global packet before dispatch. -NetGlobalDecodeError Validate_In_Game_Global(GlobalPacketType const & packet, std::size_t packet_length, NetGlobalValidationContext const & context) -{ - if (packet_length != NET_GLOBAL_PACKET_SIZE) { - return(NetGlobalDecodeError::INVALID_LENGTH); + /// Identifies public in-game discovery commands. + static bool Command_Is_Public(NetCommandType command) + { + return(command == NET_QUERY_GAME || command == NET_QUERY_PLAYER); } - bool const is_public = Net_Global_Command_Is_Public(packet.Command); - bool const requires_member = Net_Global_Command_Requires_Member(packet.Command); - if (!is_public && !requires_member) { - return(NetGlobalDecodeError::INVALID_COMMAND); - } - if (requires_member && !context.SenderIsMember) { - return(NetGlobalDecodeError::SENDER_NOT_MEMBER); + + /// 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); + } } - switch (packet.Command) { - case NET_QUERY_PLAYER: - if (!Has_Terminator(packet.Name, sizeof(packet.Name))) { - return(NetGlobalDecodeError::UNTERMINATED_NAME); - } - break; - case NET_MESSAGE: - if (!Has_Terminator(packet.Name, sizeof(packet.Name))) { - return(NetGlobalDecodeError::UNTERMINATED_NAME); - } - if (!Has_Terminator(packet.Message.Buf, sizeof(packet.Message.Buf))) { - return(NetGlobalDecodeError::UNTERMINATED_MESSAGE); - } - if (context.SenderPlayerColor < 0 || context.SenderPlayerColor >= MAX_MPLAYER_COLORS) { - return(NetGlobalDecodeError::INVALID_COLOR); - } - break; + /// 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); + } - case NET_PROGRESS_REPORT: - if (packet.Progress.Percent < 0 || packet.Progress.Percent > 100) { - return(NetGlobalDecodeError::INVALID_PROGRESS); - } - break; + 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); + } - case NET_PROPOSE_KICK: { - if (!Is_Active_Player(context, context.SenderPlayerID) || packet.Kick.KickeeID >= context.ActivePlayers.size() - || !context.ActivePlayers[packet.Kick.KickeeID]) { - return(NetGlobalDecodeError::INVALID_KICK_PLAYER); - } - if (context.SenderPlayerID == static_cast(packet.Kick.KickeeID)) { - return(NetGlobalDecodeError::SELF_KICK); + 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; } - break; + + default: + break; } - default: - break; + return(DecodeError::NONE); } - return(NetGlobalDecodeError::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{}); + } -/// Counts a rejection and selects sparse diagnostics. -NetGlobalRejectionRecord NetGlobalRejectionCounters::Record(NetGlobalDecodeError error) noexcept -{ - std::size_t const index = static_cast(error); - if (error == NetGlobalDecodeError::NONE || index >= Counts.size()) { - return(NetGlobalRejectionRecord{}); - } + std::uint32_t & count = Counts[index]; + if (count != std::numeric_limits::max()) { + count++; + } - std::uint32_t & count = Counts[index]; - if (count != std::numeric_limits::max()) { - count++; + return(RejectionRecord{count, count == 1 || (count & (count - 1)) == 0}); } - return(NetGlobalRejectionRecord{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 one rejection category's count. -std::uint32_t NetGlobalRejectionCounters::Count(NetGlobalDecodeError 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::COUNT: break; + } -/// Returns a stable global-packet rejection name. -char const * Net_Global_Error_Name(NetGlobalDecodeError error) noexcept -{ - switch (error) { - case NetGlobalDecodeError::NONE: return("none"); - case NetGlobalDecodeError::INVALID_LENGTH: return("invalid length"); - case NetGlobalDecodeError::INVALID_COMMAND: return("invalid command"); - case NetGlobalDecodeError::SENDER_NOT_MEMBER: return("sender is not a session member"); - case NetGlobalDecodeError::UNTERMINATED_NAME: return("unterminated player name"); - case NetGlobalDecodeError::UNTERMINATED_MESSAGE: return("unterminated message"); - case NetGlobalDecodeError::INVALID_COLOR: return("invalid session-member color"); - case NetGlobalDecodeError::INVALID_PROGRESS: return("invalid progress value"); - case NetGlobalDecodeError::INVALID_KICK_PLAYER: return("invalid kick player"); - case NetGlobalDecodeError::SELF_KICK: return("self kick proposal"); - case NetGlobalDecodeError::DUPLICATE_KICK_PROPOSAL: return("duplicate kick proposal"); - case NetGlobalDecodeError::KICK_PROPOSAL_QUEUE_FULL: return("kick proposal queue full"); - case NetGlobalDecodeError::COUNT: break; + return("unknown global packet error"); } - - return("unknown global packet error"); } diff --git a/code/netglobal.h b/code/netglobal.h index ccac627..f9a7b1b 100644 --- a/code/netglobal.h +++ b/code/netglobal.h @@ -16,60 +16,56 @@ #include -constexpr std::size_t NET_GLOBAL_PACKET_SIZE = sizeof(GlobalPacketType); - - -enum class NetGlobalDecodeError -{ - 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, - COUNT, -}; - - -struct NetGlobalValidationContext -{ - bool SenderIsMember = false; - int SenderPlayerID = -1; - int SenderPlayerColor = -1; - std::array ActivePlayers = {}; -}; - - -struct NetGlobalRejectionRecord -{ - std::uint32_t Count = 0; - bool ShouldLog = false; -}; - - -class NetGlobalRejectionCounters +namespace NetGlobal { - public: - NetGlobalRejectionRecord Record(NetGlobalDecodeError error) noexcept; - std::uint32_t Count(NetGlobalDecodeError error) const noexcept; - - private: - std::array(NetGlobalDecodeError::COUNT)> Counts = {}; -}; - - -void Initialize_Global_Packet(GlobalPacketType & packet, NetCommandType command) noexcept; - -NetGlobalDecodeError Validate_In_Game_Global(GlobalPacketType const & packet, std::size_t packet_length, NetGlobalValidationContext const & context); - -bool Net_Global_Command_Is_Public(NetCommandType command); - -bool Net_Global_Command_Requires_Member(NetCommandType command); - -char const * Net_Global_Error_Name(NetGlobalDecodeError error) noexcept; + 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, + COUNT, + }; + + + 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; + + 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 index 7341834..c1f3286 100644 --- a/code/netpacket.cpp +++ b/code/netpacket.cpp @@ -18,599 +18,602 @@ #include -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_SENDER_OFFSET = offsetof(EventClass, ID); -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 +namespace NetPacket { - 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{}; -}; + 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_SENDER_OFFSET = offsetof(EventClass, ID); + 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); + } -/// Builds a packet-decode failure at a stable byte offset. -NetPacketDecodeResult Failed(NetPacketDecodeError code, std::size_t offset, std::uint8_t event_type = NET_PACKET_NO_EVENT_TYPE) -{ - NetPacketDecodeResult 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); + } -/// 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); + } -/// 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); + } -/// Reads a complete frame envelope from bounded packet bytes. -bool Read_Envelope(NetReaderClass & reader, PacketEnvelope & envelope, NetPacketDecodeFailure & 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 = NetPacketDecodeError::TRUNCATED_ENVELOPE; - failure.Offset = offset; - failure.EventType = type.value_or(NET_PACKET_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); + } - envelope.Type = *type; - envelope.Frame = *frame; - envelope.Sender = *sender; - std::memcpy(envelope.FrameInfo.data(), frame_info->data(), envelope.FrameInfo.size()); - return(true); -} + /// 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); + } -/// 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); + } -/// Fetches a validated event payload length. -bool Event_Data_Length(std::uint8_t type, std::size_t offset, std::size_t & length, NetPacketDecodeFailure & 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); + } - failure.Code = NetPacketDecodeError::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; + } -/// 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()); + } - 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); + } -/// Reads and owns a variable-length ADDPLAYER payload. -bool Read_Add_Player(NetReaderClass & reader, PendingEvent & event, std::size_t event_offset, NetPacketDecodeFailure & failure) -{ - auto size = reader.Read_Value(); - if (!size) { - failure.Code = NetPacketDecodeError::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); + } - auto data = reader.Take(*size); - if (!data) { - failure.Code = NetPacketDecodeError::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); + } - 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); + } -/// Expands a bounded compressed MEGAMISSION run into pending events. -bool Read_Compressed_Mega_Mission(NetReaderClass & reader, int frame, int sender, std::size_t event_offset, - std::vector & events, NetPacketDecodeFailure & failure) -{ - auto count = reader.Read_Value(); - if (!count) { - failure.Code = NetPacketDecodeError::TRUNCATED_MEGAMISSION; - failure.Offset = event_offset; - failure.EventType = EventClass::MEGAMISSION; - return(false); - } - if (*count == 0) { - failure.Code = NetPacketDecodeError::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); + } - std::size_t const data_length = EventClass::EventLength[EventClass::MEGAMISSION]; - auto data = reader.Take(data_length); - if (!data) { - failure.Code = NetPacketDecodeError::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)); + } - 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 = NetPacketDecodeError::TRUNCATED_MEGAMISSION; - failure.Offset = event_offset; - failure.EventType = EventClass::MEGAMISSION; - return(false); + return(true); } - 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()); -/// Materializes a completely validated batch of pending events. -NetPacketDecodeResult Materialize(std::vector pending) -{ - NetPacketDecodeResult 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. -NetPacketDecodeResult Materialize_Frame_Sync(PacketEnvelope const & envelope) -{ - NetPacketDecodeResult result = Materialize({Pending_From_Envelope(envelope)}); - result.Events.clear(); - return(result); -} - + result.Events.emplace_back(event, std::move(source.AddPlayerData)); + } + if (!result.Events.empty()) { + result.Envelope = result.Events.front().Event; + result.HasEnvelope = true; + } -/// Decodes a compressed event packet transactionally. -NetPacketDecodeResult Decode_Compressed(std::span packet, int expected_sender) -{ - NetReaderClass reader(packet); - std::uint8_t const first_type = std::to_integer(packet.front()); + return(result); + } - if (!Is_Known_Event(first_type)) { - return(Failed(NetPacketDecodeError::INVALID_EVENT_TYPE, 0, first_type)); - } - if (!Is_Envelope(first_type)) { - return(Failed(NetPacketDecodeError::INVALID_PREFIX, 0, first_type)); - } - PacketEnvelope envelope; - NetPacketDecodeFailure failure; - if (!Read_Envelope(reader, envelope, failure)) { - NetPacketDecodeResult result; - result.Failure = failure; - return(result); - } - if (envelope.Sender != expected_sender) { - return(Failed(NetPacketDecodeError::SENDER_MISMATCH, EVENT_SENDER_OFFSET, envelope.Type)); - } - if (envelope.Type == EventClass::FRAMESYNC) { - if (!reader.Empty()) { - return(Failed(NetPacketDecodeError::FRAMESYNC_NOT_ALONE, reader.Offset(), envelope.Type)); + /// 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); } - return(Materialize_Frame_Sync(envelope)); - } - // 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(NetPacketDecodeError::TRUNCATED_EVENT, event_offset)); - } + /// 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()); - std::uint8_t const type = *type_value; - if (!Is_Known_Event(type)) { - return(Failed(NetPacketDecodeError::INVALID_EVENT_TYPE, event_offset, type)); - } - if (Is_Envelope(type)) { - return(Failed(NetPacketDecodeError::NESTED_ENVELOPE, event_offset, type)); - } + 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 (type == EventClass::MEGAMISSION) { - if (!Read_Compressed_Mega_Mission(reader, envelope.Frame, envelope.Sender, event_offset, events, failure)) { - NetPacketDecodeResult result; + PacketEnvelope envelope; + DecodeFailure failure; + if (!Read_Envelope(reader, envelope, 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(NetPacketDecodeError::INVALID_EVENT_LENGTH, event_offset, type)); + if (envelope.Sender != expected_sender) { + return(Failed(DecodeError::SENDER_MISMATCH, EVENT_SENDER_OFFSET, envelope.Type)); } - if (!Read_Add_Player(reader, event, event_offset, failure)) { - NetPacketDecodeResult result; - result.Failure = failure; - return(result); + if (envelope.Type == EventClass::FRAMESYNC) { + if (!reader.Empty()) { + return(Failed(DecodeError::FRAMESYNC_NOT_ALONE, reader.Offset(), envelope.Type)); + } + return(Materialize_Frame_Sync(envelope)); } - events.push_back(std::move(event)); - continue; - } - - std::size_t data_length = 0; - if (!Event_Data_Length(type, event_offset, data_length, failure)) { - NetPacketDecodeResult result; - result.Failure = failure; - return(result); - } - auto data = reader.Take(data_length); - if (!data) { - return(Failed(NetPacketDecodeError::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, NetPacketDecodeFailure & failure) -{ - NetReaderClass 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 = NetPacketDecodeError::TRUNCATED_EVENT; - failure.Offset = event_offset; - failure.EventType = type.value_or(NET_PACKET_NO_EVENT_TYPE); - return(false); - } - if (!Is_Known_Event(*type)) { - failure.Code = NetPacketDecodeError::INVALID_EVENT_TYPE; - failure.Offset = event_offset; - failure.EventType = *type; - return(false); - } - if (*sender != expected_sender) { - failure.Code = NetPacketDecodeError::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); - } + // 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)); + } - 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(Materialize(std::move(events))); + } - return(true); -} + /// 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); + } -/// 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); -} + 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); + } -/// Decodes an uncompressed event packet transactionally. -NetPacketDecodeResult 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(NetPacketDecodeError::INVALID_EVENT_TYPE, 0, first_type)); - } - if (!Is_Envelope(first_type)) { - return(Failed(NetPacketDecodeError::INVALID_PREFIX, 0, first_type)); - } + 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); + } - if (first_type == EventClass::FRAMESYNC) { - NetReaderClass reader(packet); - PacketEnvelope envelope; - NetPacketDecodeFailure failure; - if (!Read_Envelope(reader, envelope, failure)) { - NetPacketDecodeResult result; - result.Failure = failure; - return(result); - } - if (envelope.Sender != expected_sender) { - return(Failed(NetPacketDecodeError::SENDER_MISMATCH, EVENT_SENDER_OFFSET, envelope.Type)); + return(true); } - if (!reader.Empty()) { - return(Failed(NetPacketDecodeError::FRAMESYNC_NOT_ALONE, reader.Offset(), envelope.Type)); - } - return(Materialize_Frame_Sync(envelope)); - } - if (packet.size() < sizeof(EventClass)) { - return(Failed(NetPacketDecodeError::TRUNCATED_ENVELOPE, 0, first_type)); - } - NetReaderClass reader(packet); - std::vector events; - NetPacketDecodeFailure failure; - while (!reader.Empty()) { - std::size_t const event_offset = reader.Offset(); - if (reader.Remaining() < sizeof(EventClass)) { - return(Failed(NetPacketDecodeError::TRAILING_BYTES, event_offset)); + /// 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); } - auto bytes = reader.Take(sizeof(EventClass)); - PendingEvent event; - if (!Read_Full_Event(*bytes, event, event_offset, expected_sender, failure)) { - NetPacketDecodeResult result; - result.Failure = failure; - return(result); - } - if (events.empty()) { - if (event.Type != EventClass::FRAMEINFO) { - return(Failed(NetPacketDecodeError::INVALID_PREFIX, event_offset, event.Type)); + /// 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)); } - } else if (Is_Envelope(event.Type)) { - return(Failed(NetPacketDecodeError::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(NetPacketDecodeError::TRUNCATED_ADDPLAYER, event_offset, event.Type)); + if (!Is_Envelope(first_type)) { + return(Failed(DecodeError::INVALID_PREFIX, 0, first_type)); } - event.AddPlayerData.assign(data->begin(), data->end()); - } - events.push_back(std::move(event)); - } - - return(Materialize(std::move(events))); -} + 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)); + } + return(Materialize_Frame_Sync(envelope)); + } + if (packet.size() < sizeof(EventClass)) { + return(Failed(DecodeError::TRUNCATED_ENVELOPE, 0, first_type)); + } -} // namespace + 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)); + } + } 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))); + } -/// Constructs an empty decoded event. -NetDecodedEvent::NetDecodedEvent(void) noexcept -{ - std::memset(&Event, 0, sizeof(Event)); -} + } // namespace -/// Owns one decoded event and its optional variable payload. -NetDecodedEvent::NetDecodedEvent(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. -NetDecodedEvent::NetDecodedEvent(NetDecodedEvent const & other) - : Event(other.Event), AddPlayerData(other.AddPlayerData) -{ - Bind_AddPlayer_Data(); -} + /// Constructs an empty decoded event. + DecodedEvent::DecodedEvent(void) noexcept + { + std::memset(&Event, 0, sizeof(Event)); + } -/// Moves a decoded event and repairs both payload pointers. -NetDecodedEvent::NetDecodedEvent(NetDecodedEvent && other) noexcept - : Event(other.Event), AddPlayerData(std::move(other.AddPlayerData)) -{ - Bind_AddPlayer_Data(); - other.Bind_AddPlayer_Data(); -} + /// 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. -NetDecodedEvent & NetDecodedEvent::operator=(NetDecodedEvent const & other) -{ - if (this != &other) { - Event = other.Event; - AddPlayerData = other.AddPlayerData; + /// Copies a decoded event and repairs its owned payload pointer. + DecodedEvent::DecodedEvent(DecodedEvent const & other) + : Event(other.Event), AddPlayerData(other.AddPlayerData) + { Bind_AddPlayer_Data(); } - return(*this); -} -/// Moves a decoded event and repairs both payload pointers. -NetDecodedEvent & NetDecodedEvent::operator=(NetDecodedEvent && other) noexcept -{ - if (this != &other) { - Event = other.Event; - AddPlayerData = std::move(other.AddPlayerData); + /// 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(); } - return(*this); -} -/// Binds an ADDPLAYER event to its owned variable payload. -void NetDecodedEvent::Bind_AddPlayer_Data(void) noexcept -{ - if (Event.Type != EventClass::ADDPLAYER) { - return; + /// 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); } - Event.Data.Variable.Size = static_cast(AddPlayerData.size()); - Event.Data.Variable.Pointer = AddPlayerData.empty() ? nullptr : AddPlayerData.data(); -} + /// 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); + } -/// Checks whether packet decoding completed without error. -bool NetPacketDecodeResult::Succeeded(void) const noexcept -{ - return(Failure.Code == NetPacketDecodeError::NONE); -} + /// Binds an ADDPLAYER event to its owned variable payload. + void DecodedEvent::Bind_AddPlayer_Data(void) noexcept + { + if (Event.Type != EventClass::ADDPLAYER) { + return; + } -/// Decodes a complete event packet using its negotiated encoding. -NetPacketDecodeResult Decode_Event_Packet(std::span packet, NetPacketEncoding encoding, int expected_sender) -{ - if (packet.empty()) { - return(Failed(NetPacketDecodeError::EMPTY_PACKET, 0)); + Event.Data.Variable.Size = static_cast(AddPlayerData.size()); + Event.Data.Variable.Pointer = AddPlayerData.empty() ? nullptr : AddPlayerData.data(); } - switch (encoding) { - case NetPacketEncoding::UNCOMPRESSED: - return(Decode_Uncompressed(packet, expected_sender)); - case NetPacketEncoding::COMPRESSED: - return(Decode_Compressed(packet, expected_sender)); + /// Checks whether packet decoding completed without error. + bool DecodeResult::Succeeded(void) const noexcept + { + return(Failure.Code == DecodeError::NONE); } - return(Failed(NetPacketDecodeError::INVALID_PREFIX, 0)); -} + /// 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)); + } -/// Returns a stable packet-decode error name. -char const * Net_Packet_Error_Name(NetPacketDecodeError error) noexcept -{ - switch (error) { - case NetPacketDecodeError::NONE: return("none"); - case NetPacketDecodeError::EMPTY_PACKET: return("empty packet"); - case NetPacketDecodeError::INVALID_EVENT_TYPE: return("invalid event type"); - case NetPacketDecodeError::INVALID_PREFIX: return("invalid packet prefix"); - case NetPacketDecodeError::TRUNCATED_ENVELOPE: return("truncated packet envelope"); - case NetPacketDecodeError::FRAMESYNC_NOT_ALONE: return("framesync is not alone"); - case NetPacketDecodeError::NESTED_ENVELOPE: return("nested packet envelope"); - case NetPacketDecodeError::SENDER_MISMATCH: return("sender identity mismatch"); - case NetPacketDecodeError::INVALID_EVENT_LENGTH: return("invalid event length"); - case NetPacketDecodeError::TRUNCATED_EVENT: return("truncated event"); - case NetPacketDecodeError::ZERO_MEGAMISSION_COUNT: return("zero megamission count"); - case NetPacketDecodeError::TRUNCATED_MEGAMISSION: return("truncated megamission"); - case NetPacketDecodeError::TRUNCATED_ADDPLAYER: return("truncated add-player data"); - case NetPacketDecodeError::TRAILING_BYTES: return("trailing packet bytes"); - case NetPacketDecodeError::INVALID_CONNECTION: return("invalid connection"); - case NetPacketDecodeError::COUNT: break; - } + switch (encoding) { + case Encoding::UNCOMPRESSED: + return(Decode_Uncompressed(packet, expected_sender)); - return("unknown packet error"); + 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_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 index aebff08..de9bd15 100644 --- a/code/netpacket.h +++ b/code/netpacket.h @@ -17,73 +17,76 @@ #include -enum class NetPacketEncoding +namespace NetPacket { - UNCOMPRESSED, - COMPRESSED, -}; - - -enum class NetPacketDecodeError -{ - NONE, - EMPTY_PACKET, - INVALID_EVENT_TYPE, - INVALID_PREFIX, - TRUNCATED_ENVELOPE, - FRAMESYNC_NOT_ALONE, - NESTED_ENVELOPE, - SENDER_MISMATCH, - INVALID_EVENT_LENGTH, - TRUNCATED_EVENT, - ZERO_MEGAMISSION_COUNT, - TRUNCATED_MEGAMISSION, - TRUNCATED_ADDPLAYER, - TRAILING_BYTES, - INVALID_CONNECTION, - COUNT, -}; - - -constexpr std::uint8_t NET_PACKET_NO_EVENT_TYPE = UINT8_MAX; - - -struct NetPacketDecodeFailure -{ - NetPacketDecodeError Code = NetPacketDecodeError::NONE; - std::size_t Offset = 0; - std::uint8_t EventType = NET_PACKET_NO_EVENT_TYPE; -}; - - -struct NetDecodedEvent -{ - NetDecodedEvent(void) noexcept; - NetDecodedEvent(EventClass const & event, std::vector add_player_data) noexcept; - NetDecodedEvent(NetDecodedEvent const & other); - NetDecodedEvent(NetDecodedEvent && other) noexcept; - NetDecodedEvent & operator=(NetDecodedEvent const & other); - NetDecodedEvent & operator=(NetDecodedEvent && other) noexcept; - - EventClass Event; - std::vector AddPlayerData; - - private: - void Bind_AddPlayer_Data(void) noexcept; -}; - - -struct NetPacketDecodeResult -{ - bool Succeeded(void) const noexcept; - - NetPacketDecodeFailure Failure; - EventClass Envelope; - bool HasEnvelope = false; - std::vector Events; -}; - - -NetPacketDecodeResult Decode_Event_Packet(std::span packet, NetPacketEncoding encoding, int expected_sender); - -char const * Net_Packet_Error_Name(NetPacketDecodeError error) noexcept; + 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_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); + + char const * Error_Name(DecodeError error) noexcept; +} diff --git a/code/netreader.cpp b/code/netreader.cpp index 7af55f1..4c65fc1 100644 --- a/code/netreader.cpp +++ b/code/netreader.cpp @@ -10,42 +10,45 @@ #include "netreader.h" -/// Starts a bounded read over packet bytes. -NetReaderClass::NetReaderClass(std::span data) noexcept - : Data(data), Position(0) +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 NetReaderClass::Offset(void) const noexcept -{ - return(Position); -} + /// Returns the current read offset. + std::size_t Reader::Offset(void) const noexcept + { + return(Position); + } -/// Returns the unread byte count. -std::size_t NetReaderClass::Remaining(void) const noexcept -{ - return(Data.size() - 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 NetReaderClass::Empty(void) const noexcept -{ - return(Remaining() == 0); -} + /// 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> NetReaderClass::Take(std::size_t size) noexcept -{ - if (size > Remaining()) { - return(std::nullopt); - } + /// 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); + std::span bytes = Data.subspan(Position, size); + Position += size; + return(bytes); + } } diff --git a/code/netreader.h b/code/netreader.h index 7236df9..4a3c63b 100644 --- a/code/netreader.h +++ b/code/netreader.h @@ -16,32 +16,35 @@ #include -class NetReaderClass +namespace NetPacket { - public: - explicit NetReaderClass(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); + 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); } - T value{}; - std::memcpy(&value, bytes->data(), sizeof(value)); - return(value); - } - - private: - std::span Data; - std::size_t Position; -}; + private: + std::span Data; + std::size_t Position; + }; +} diff --git a/code/nettime.cpp b/code/nettime.cpp index e25a179..7e4b8f3 100644 --- a/code/nettime.cpp +++ b/code/nettime.cpp @@ -15,6 +15,16 @@ 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 { diff --git a/code/nettime.h b/code/nettime.h index 4f28709..d07200c 100644 --- a/code/nettime.h +++ b/code/nettime.h @@ -24,12 +24,6 @@ namespace NetTiming virtual Milliseconds Now(void) const = 0; }; - class SystemMillisecondClock final : public MillisecondClock - { - public: - Milliseconds Now(void) const override; - }; - MillisecondClock const & Default_Clock(void); constexpr Milliseconds Elapsed_Milliseconds(Milliseconds start, Milliseconds finish) diff --git a/code/nettiming.h b/code/nettiming.h index 08c2493..1ac4085 100644 --- a/code/nettiming.h +++ b/code/nettiming.h @@ -150,6 +150,13 @@ namespace NetTiming bool Deferred = false; }; + enum class ScheduleResult + { + Rejected, + Applied, + Staged, + }; + std::optional Stage_Timing_Update(TimingSettings current, TimingSettings requested, std::uint32_t event_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 ad7cf88..28080f9 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -269,20 +269,20 @@ BasicTimerClass SentFrameSyncTimer; FrameSyncStruct TheirFrameSync[MAX_PLAYERS - 1]; unsigned short SentCommandCount; // # cmds I've sent out -static std::array(NetPacketDecodeError::COUNT)> NetworkPacketDrops = {}; +static std::array(NetPacket::DecodeError::COUNT)> NetworkPacketDrops = {}; /// Records and rate-limits one stable event-packet rejection reason. -void Record_Network_Packet_Drop(NetPacketDecodeError error) +static void Record_Network_Packet_Drop(NetPacket::DecodeError error) { std::size_t const index = static_cast(error); - if (error == NetPacketDecodeError::NONE || index >= NetworkPacketDrops.size()) { + 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", Net_Packet_Error_Name(error), count); + DebugString("Network event packet drop [%s]: %u\n", NetPacket::Error_Name(error), count); } } @@ -1867,13 +1867,13 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, char *multi_packet_buf, int id, int packetlen, FrameSyncStruct *their, BasicTimerClass *timer) { RetcodeType retcode = RC_NORMAL; - NetPacketEncoding const encoding = Session.CommProtocol == COMM_PROTOCOL_SINGLE_NO_COMP - ? NetPacketEncoding::UNCOMPRESSED : NetPacketEncoding::COMPRESSED; + 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. - NetPacketDecodeResult decoded = Decode_Event_Packet(packet, encoding, id); + NetPacket::DecodeResult decoded = NetPacket::Decode_Event_Packet(packet, encoding, id); if (!decoded.Succeeded() || !decoded.HasEnvelope) { - Record_Network_Packet_Drop(decoded.Succeeded() ? NetPacketDecodeError::INVALID_PREFIX : decoded.Failure.Code); + Record_Network_Packet_Drop(decoded.Succeeded() ? NetPacket::DecodeError::INVALID_PREFIX : decoded.Failure.Code); return(RC_NORMAL); } @@ -1884,7 +1884,7 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, //------------------------------------------------------------------------ int const index = net->Connection_Index(id); if (index < 0 || index >= net->Num_Connections()) { - Record_Network_Packet_Drop(NetPacketDecodeError::INVALID_CONNECTION); + Record_Network_Packet_Drop(NetPacket::DecodeError::INVALID_CONNECTION); return(RC_NORMAL); } @@ -1950,7 +1950,7 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, // FRAMEINFO packets received //------------------------------------------------------------------------ if (event.Type != EventClass::FRAMESYNC) { - for (NetDecodedEvent const & source : decoded.Events) { + for (NetPacket::DecodedEvent const & source : decoded.Events) { EventClass queued = source.Event; if (queued.Type == EventClass::ADDPLAYER) { queued.Data.Variable.Pointer = NULL; @@ -2449,7 +2449,7 @@ void Propose_Kick_Player(HWND window, int id) } GlobalPacketType gpacket; - Initialize_Global_Packet(gpacket, NET_PROPOSE_KICK); + 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); @@ -2465,35 +2465,35 @@ void Propose_Kick_Player(HWND window, int id) /// Queues a bounded, canonical kick proposal from a session member. -NetGlobalDecodeError Kick_Packet_Received(int kicker, int kickee) +NetGlobal::DecodeError Kick_Packet_Received(int kicker, int kickee) { NodeNameType * kicker_player = Current_Player_From_ID(kicker); NodeNameType * kickee_player = Current_Player_From_ID(kickee); if (kicker_player == NULL || kickee_player == NULL) { - return(NetGlobalDecodeError::INVALID_KICK_PLAYER); + return(NetGlobal::DecodeError::INVALID_KICK_PLAYER); } if (kicker == kickee) { - return(NetGlobalDecodeError::SELF_KICK); + return(NetGlobal::DecodeError::SELF_KICK); } if (Kick_Vote_Already_Cast(kicker, kickee) || Kick_Proposal_Already_Pending(kicker, kickee)) { - return(NetGlobalDecodeError::DUPLICATE_KICK_PROPOSAL); + return(NetGlobal::DecodeError::DUPLICATE_KICK_PROPOSAL); } if (Session.KickProposals.Count() >= MAX_PLAYERS * MAX_PLAYERS) { - return(NetGlobalDecodeError::KICK_PROPOSAL_QUEUE_FULL); + return(NetGlobal::DecodeError::KICK_PROPOSAL_QUEUE_FULL); } GlobalPacketType * newpacket = new GlobalPacketType; - Initialize_Global_Packet(*newpacket, NET_PROPOSE_KICK); + 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(NetGlobalDecodeError::KICK_PROPOSAL_QUEUE_FULL); + return(NetGlobal::DecodeError::KICK_PROPOSAL_QUEUE_FULL); } - return(NetGlobalDecodeError::NONE); + return(NetGlobal::DecodeError::NONE); } diff --git a/code/queue.h b/code/queue.h index dc5981e..9fa284d 100644 --- a/code/queue.h +++ b/code/queue.h @@ -50,9 +50,12 @@ void Add_CRC(unsigned int *crc, unsigned int val); void Wait_For_End_Of_Queue(void); -enum class NetGlobalDecodeError; +namespace NetGlobal +{ + enum class DecodeError; +} -NetGlobalDecodeError Kick_Packet_Received(int kicker, int kickee); +NetGlobal::DecodeError Kick_Packet_Received(int kicker, int kickee); void Forget_Kick_Player(int player); diff --git a/code/session.cpp b/code/session.cpp index ef6af97..2723d18 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -559,10 +559,10 @@ NetTiming::TimingEvaluation SessionClass::Evaluate_Network_Timing(unsigned int t /// Applies a timing increase or safely stages a decrease. -NetworkTimingScheduleResult SessionClass::Schedule_Network_Timing(NetTiming::TimingSettings settings, unsigned int desired_frame_rate, unsigned int event_frame) +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(NetworkTimingScheduleResult::Rejected); + return(NetTiming::ScheduleResult::Rejected); } NetTiming::TimingSettings const current{FrameSendRate, MaxAhead}; @@ -573,14 +573,14 @@ NetworkTimingScheduleResult SessionClass::Schedule_Network_Timing(NetTiming::Tim staged = NetTiming::StagedTimingUpdate{settings, event_frame, false}; } if (!staged) { - return(NetworkTimingScheduleResult::Rejected); + return(NetTiming::ScheduleResult::Rejected); } // Decreases wait until commands scheduled under the old horizon have drained. if (staged->Deferred) { PendingNetworkTiming = staged; PendingNetworkDesiredFrameRate = desired_frame_rate; - return(NetworkTimingScheduleResult::Staged); + return(NetTiming::ScheduleResult::Staged); } PendingNetworkTiming.reset(); @@ -589,7 +589,7 @@ NetworkTimingScheduleResult SessionClass::Schedule_Network_Timing(NetTiming::Tim FrameSendRate = settings.FrameSendRate; MaxAhead = settings.MaxAhead; MaxMaxAhead = std::max(MaxMaxAhead, (int)MaxAhead); - return(NetworkTimingScheduleResult::Applied); + return(NetTiming::ScheduleResult::Applied); } diff --git a/code/session.h b/code/session.h index ca51f8f..84d7583 100644 --- a/code/session.h +++ b/code/session.h @@ -428,12 +428,6 @@ struct MPStatsType { }; -enum class NetworkTimingScheduleResult { - Rejected, - Applied, - Staged, -}; - //--------------------------------------------------------------------------- // Class Definition //--------------------------------------------------------------------------- @@ -475,7 +469,7 @@ class SessionClass void Remove_Network_Timing_Player(int id); NetTiming::TimingCensus Network_Timing_Census(unsigned int frame); NetTiming::TimingEvaluation Evaluate_Network_Timing(unsigned int target_fps, unsigned int frame); - NetworkTimingScheduleResult Schedule_Network_Timing(NetTiming::TimingSettings settings, unsigned int desired_frame_rate, unsigned int event_frame); + NetTiming::ScheduleResult Schedule_Network_Timing(NetTiming::TimingSettings settings, unsigned int desired_frame_rate, unsigned int event_frame); bool Apply_Staged_Network_Timing(unsigned int frame); unsigned int Compute_Unique_ID(void); void Update_Progress(int percent); diff --git a/code/wsproto.cpp b/code/wsproto.cpp index 3073365..085283f 100644 --- a/code/wsproto.cpp +++ b/code/wsproto.cpp @@ -472,7 +472,7 @@ unsigned int WinsockInterfaceClass::Calculate_Packet_CRC(void const * buffer, in if (buffer == NULL || buffer_len <= 0) { return(0); } - return(Calculate_Network_Datagram_CRC(std::span(static_cast(buffer), static_cast(buffer_len)))); + return(NetAdmission::Calculate_Datagram_CRC(std::span(static_cast(buffer), static_cast(buffer_len)))); } diff --git a/code/wspudp.cpp b/code/wspudp.cpp index ef7bce9..d90f0af 100644 --- a/code/wspudp.cpp +++ b/code/wspudp.cpp @@ -567,13 +567,13 @@ int UDPInterfaceClass::Message_Handler(HWND, UINT message, UINT, LONG lParam) } std::span const datagram(reinterpret_cast(ReceiveBuffer), rc > 0 ? static_cast(rc) : 0); - NetDatagramAdmission const admission = Admit_Network_Datagram(datagram, WS_INTERNET_BUFFER_LEN); + NetAdmission::DatagramResult const admission = NetAdmission::Admit_Datagram(datagram, WS_INTERNET_BUFFER_LEN); if (!admission.Succeeded()) { - switch (admission.Error) { - case NetAdmissionError::DATAGRAM_TOO_LARGE: + switch (admission.ErrorCode) { + case NetAdmission::Error::DATAGRAM_TOO_LARGE: Record_Packet_Drop(WS_DROP_RECEIVE_TOO_LARGE); break; - case NetAdmissionError::BAD_CRC: + case NetAdmission::Error::BAD_CRC: Record_Packet_Drop(WS_DROP_BAD_CRC); break; default: diff --git a/tests/netpacket/netcontract.cpp b/tests/netpacket/netcontract.cpp index abe5501..7ba5b97 100644 --- a/tests/netpacket/netcontract.cpp +++ b/tests/netpacket/netcontract.cpp @@ -50,14 +50,14 @@ void Check(bool condition, char const * what) void Check_Error( - NetPacketDecodeResult const & result, - NetPacketDecodeError expected, + 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", Net_Packet_Error_Name(result.Failure.Code), result.Failure.Offset); + std::printf(" got %s at %zu\n", NetPacket::Error_Name(result.Failure.Code), result.Failure.Offset); } } @@ -129,7 +129,7 @@ void Test_Reader(void) Append_Value(bytes, first); Append_Value(bytes, second); - NetReaderClass reader(bytes); + 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"); @@ -158,27 +158,27 @@ void Test_Event_Contract(void) void Test_Envelope_Rules(void) { Check_Error( - Decode_Event_Packet({}, NetPacketEncoding::COMPRESSED, Sender), - NetPacketDecodeError::EMPTY_PACKET, + 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( - Decode_Event_Packet(invalid_prefix, NetPacketEncoding::COMPRESSED, Sender), - NetPacketDecodeError::INVALID_PREFIX, + 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( - Decode_Event_Packet(unknown, NetPacketEncoding::COMPRESSED, Sender), - NetPacketDecodeError::INVALID_EVENT_TYPE, + 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); - NetPacketDecodeResult result = Decode_Event_Packet(truncated, NetPacketEncoding::COMPRESSED, Sender); - if (result.Failure.Code != NetPacketDecodeError::TRUNCATED_ENVELOPE || !result.Events.empty()) { + 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; } @@ -187,7 +187,7 @@ void Test_Envelope_Rules(void) } } - NetPacketDecodeResult header = Decode_Event_Packet(complete, NetPacketEncoding::COMPRESSED, Sender); + 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"); @@ -198,35 +198,35 @@ void Test_Envelope_Rules(void) } Check_Error( - Decode_Event_Packet(complete, NetPacketEncoding::COMPRESSED, Sender + 1), - NetPacketDecodeError::SENDER_MISMATCH, + NetPacket::Decode_Event_Packet(complete, NetPacket::Encoding::COMPRESSED, Sender + 1), + NetPacket::DecodeError::SENDER_MISMATCH, "the envelope sender must match the demultiplexer sender"); - for (NetPacketEncoding encoding : {NetPacketEncoding::COMPRESSED, NetPacketEncoding::UNCOMPRESSED}) { + for (NetPacket::Encoding encoding : {NetPacket::Encoding::COMPRESSED, NetPacket::Encoding::UNCOMPRESSED}) { Bytes framesync = Envelope(EventClass::FRAMESYNC); - NetPacketDecodeResult result = Decode_Event_Packet(framesync, encoding, Sender); + 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( - Decode_Event_Packet(framesync, encoding, Sender), - NetPacketDecodeError::FRAMESYNC_NOT_ALONE, + 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( - Decode_Event_Packet(nested, NetPacketEncoding::COMPRESSED, Sender), - NetPacketDecodeError::NESTED_ENVELOPE, + 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( - Decode_Event_Packet(short_uncompressed, NetPacketEncoding::UNCOMPRESSED, Sender), - NetPacketDecodeError::TRUNCATED_ENVELOPE, + NetPacket::Decode_Event_Packet(short_uncompressed, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::TRUNCATED_ENVELOPE, "an uncompressed FRAMEINFO must carry the complete full event"); } @@ -259,7 +259,7 @@ void Test_Full_Compressed_Table(void) } Bytes packet = Valid_Compressed_Event(type); - NetPacketDecodeResult result = Decode_Event_Packet(packet, NetPacketEncoding::COMPRESSED, Sender); + 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]); @@ -272,21 +272,21 @@ void Test_Full_Compressed_Table(void) } packet.pop_back(); - NetPacketDecodeError expected = NetPacketDecodeError::TRUNCATED_EVENT; + NetPacket::DecodeError expected = NetPacket::DecodeError::TRUNCATED_EVENT; if (type == EventClass::ADDPLAYER) { - expected = NetPacketDecodeError::TRUNCATED_ADDPLAYER; + expected = NetPacket::DecodeError::TRUNCATED_ADDPLAYER; } else if (type == EventClass::MEGAMISSION) { - expected = NetPacketDecodeError::TRUNCATED_MEGAMISSION; + expected = NetPacket::DecodeError::TRUNCATED_MEGAMISSION; } std::snprintf(label, sizeof(label), "compressed event %-18s rejects one byte short", EventClass::EventNames[type]); - Check_Error(Decode_Event_Packet(packet, NetPacketEncoding::COMPRESSED, Sender), expected, label); + 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)); - NetPacketDecodeResult decoded_response = Decode_Event_Packet(response, NetPacketEncoding::COMPRESSED, Sender); + 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"); @@ -298,7 +298,7 @@ void Test_Full_Compressed_Table(void) Append_Value(report_data, average); Append_Value(report_data, worst); Add_Compressed_Event(report, EventClass::NETWORK_REPORT, report_data); - NetPacketDecodeResult decoded_report = Decode_Event_Packet(report, NetPacketEncoding::COMPRESSED, Sender); + 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, @@ -324,7 +324,7 @@ void Test_Mega_Mission(void) Append_Value(packet, whom2); Append_Value(packet, whom3); - NetPacketDecodeResult result = Decode_Event_Packet(packet, NetPacketEncoding::COMPRESSED, Sender); + 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, @@ -345,23 +345,23 @@ void Test_Mega_Mission(void) zero.push_back(std::byte{0}); zero.insert(zero.end(), data_size, std::byte{0}); Check_Error( - Decode_Event_Packet(zero, NetPacketEncoding::COMPRESSED, Sender), - NetPacketDecodeError::ZERO_MEGAMISSION_COUNT, + 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( - Decode_Event_Packet(packet, NetPacketEncoding::COMPRESSED, Sender), - NetPacketDecodeError::TRUNCATED_MEGAMISSION, + 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(NetPacketDecodeResult const & result, char const * what) +void Check_Add_Player(NetPacket::DecodeResult const & result, char const * what) { bool valid = result.Succeeded() && result.Events.size() == 2; if (valid) { - NetDecodedEvent const & event = result.Events[1]; + NetPacket::DecodedEvent const & event = result.Events[1]; valid = event.Event.Type == EventClass::ADDPLAYER && event.Event.Data.Variable.Size == 3 && event.AddPlayerData.size() == 3 @@ -382,10 +382,10 @@ void Test_Add_Player(void) Append_Value(compressed, size); Append_Bytes(compressed, payload); - NetPacketDecodeResult result = Decode_Event_Packet(compressed, NetPacketEncoding::COMPRESSED, Sender); + NetPacket::DecodeResult result = NetPacket::Decode_Event_Packet(compressed, NetPacket::Encoding::COMPRESSED, Sender); Check_Add_Player(result, "compressed ADDPLAYER owns and binds its variable data"); - NetPacketDecodeResult copied = result; + 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, @@ -394,8 +394,8 @@ void Test_Add_Player(void) Bytes truncated = compressed; truncated.pop_back(); Check_Error( - Decode_Event_Packet(truncated, NetPacketEncoding::COMPRESSED, Sender), - NetPacketDecodeError::TRUNCATED_ADDPLAYER, + 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(); @@ -407,8 +407,8 @@ void Test_Add_Player(void) Append_Value(transactional, size); transactional.push_back(std::byte{0x11}); Check_Error( - Decode_Event_Packet(transactional, NetPacketEncoding::COMPRESSED, Sender), - NetPacketDecodeError::TRUNCATED_ADDPLAYER, + 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); @@ -417,13 +417,13 @@ void Test_Add_Player(void) Append_Bytes(uncompressed, add); Append_Bytes(uncompressed, payload); Check_Add_Player( - Decode_Event_Packet(uncompressed, NetPacketEncoding::UNCOMPRESSED, Sender), + NetPacket::Decode_Event_Packet(uncompressed, NetPacket::Encoding::UNCOMPRESSED, Sender), "uncompressed ADDPLAYER owns and binds its variable data"); uncompressed.pop_back(); Check_Error( - Decode_Event_Packet(uncompressed, NetPacketEncoding::UNCOMPRESSED, Sender), - NetPacketDecodeError::TRUNCATED_ADDPLAYER, + NetPacket::Decode_Event_Packet(uncompressed, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::TRUNCATED_ADDPLAYER, "uncompressed ADDPLAYER rejects a truncated owned payload"); } @@ -436,7 +436,7 @@ void Test_Uncompressed(void) Write_Value(speed, DataOffset, value); Append_Bytes(packet, speed); - NetPacketDecodeResult result = Decode_Event_Packet(packet, NetPacketEncoding::UNCOMPRESSED, Sender); + 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, @@ -447,29 +447,29 @@ void Test_Uncompressed(void) Bytes wrong_sender = Full_Event(EventClass::FRAMEINFO); Append_Bytes(wrong_sender, Full_Event(EventClass::GAMESPEED, Sender + 1)); Check_Error( - Decode_Event_Packet(wrong_sender, NetPacketEncoding::UNCOMPRESSED, Sender), - NetPacketDecodeError::SENDER_MISMATCH, + 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( - Decode_Event_Packet(nested, NetPacketEncoding::UNCOMPRESSED, Sender), - NetPacketDecodeError::NESTED_ENVELOPE, + 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( - Decode_Event_Packet(unknown, NetPacketEncoding::UNCOMPRESSED, Sender), - NetPacketDecodeError::INVALID_EVENT_TYPE, + 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( - Decode_Event_Packet(trailing, NetPacketEncoding::UNCOMPRESSED, Sender), - NetPacketDecodeError::TRAILING_BYTES, + NetPacket::Decode_Event_Packet(trailing, NetPacket::Encoding::UNCOMPRESSED, Sender), + NetPacket::DecodeError::TRAILING_BYTES, "an uncompressed packet rejects a trailing partial record"); } @@ -477,7 +477,7 @@ void Test_Uncompressed(void) Bytes Datagram(Bytes const & payload) { Bytes datagram; - std::uint32_t const crc = Calculate_Network_Datagram_CRC(payload); + std::uint32_t const crc = NetAdmission::Calculate_Datagram_CRC(payload); Append_Value(datagram, crc); Append_Bytes(datagram, payload); return(datagram); @@ -496,11 +496,11 @@ Bytes Connection_Packet(std::size_t header_size, std::uint8_t code, std::size_t } -void Check_Admission_Error(NetAdmissionError actual, NetAdmissionError expected, char const * what) +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", Net_Admission_Error_Name(actual)); + std::printf(" got %s\n", NetAdmission::Error_Name(actual)); } } @@ -509,32 +509,32 @@ 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(Admit_Network_Datagram(short_datagram).Error, - NetAdmissionError::DATAGRAM_TOO_SHORT, + 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}); - NetDatagramAdmission const admission = Admit_Network_Datagram(Datagram(payload)); + 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(NET_DATAGRAM_PAYLOAD_CAPACITY + 1, std::byte{0x33}); - Check_Admission_Error(Admit_Network_Datagram(Datagram(oversized_payload)).Error, - NetAdmissionError::DATAGRAM_TOO_LARGE, + 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(Admit_Network_Datagram(damaged).Error, - NetAdmissionError::BAD_CRC, "a damaged transport payload fails CRC admission"); + 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); - NetDatagramAdmission const admitted_unaligned = Admit_Network_Datagram( + 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"); @@ -543,72 +543,72 @@ void Test_Datagram_Admission(void) void Test_Connection_Admission(void) { - for (std::size_t size = 0; size < NET_PRIVATE_HEADER_SIZE; size++) { + for (std::size_t size = 0; size < NetAdmission::PRIVATE_HEADER_SIZE; size++) { Bytes packet(size, std::byte{0}); - Check_Admission_Error(Admit_Connection_Packet( - packet, NET_PRIVATE_HEADER_SIZE, 64).Error, - NetAdmissionError::HEADER_TOO_SHORT, + 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 < NET_GLOBAL_HEADER_SIZE; size++) { + for (std::size_t size = 0; size < NetAdmission::GLOBAL_HEADER_SIZE; size++) { Bytes packet(size, std::byte{0}); - Check_Admission_Error(Admit_Connection_Packet( - packet, NET_GLOBAL_HEADER_SIZE, 64).Error, - NetAdmissionError::HEADER_TOO_SHORT, + 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 : {NET_PRIVATE_HEADER_SIZE, NET_GLOBAL_HEADER_SIZE}) { + for (std::size_t header_size : {NetAdmission::PRIVATE_HEADER_SIZE, NetAdmission::GLOBAL_HEADER_SIZE}) { Bytes ack = Connection_Packet(header_size, - static_cast(NetPacketCode::ACK), 0); - NetConnectionAdmission admitted = Admit_Connection_Packet(ack, header_size, ack.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(Admit_Connection_Packet(ack, header_size, ack.size()).Error, - NetAdmissionError::INVALID_PACKET_LENGTH, + 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(NetPacketCode::DATA_ACK), 0); - Check_Admission_Error(Admit_Connection_Packet( - empty_data, header_size, empty_data.size()).Error, - NetAdmissionError::INVALID_PACKET_LENGTH, + 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(NetPacketCode::DATA_NOACK), 1); - admitted = Admit_Connection_Packet(data, header_size, data.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(Validate_Network_Destination(admitted.Payload, 0), - NetAdmissionError::DESTINATION_TOO_SMALL, + Check_Admission_Error(NetAdmission::Validate_Destination(admitted.Payload, 0), + NetAdmission::Error::DESTINATION_TOO_SMALL, "a destination overflow is rejected before copying"); - Check_Admission_Error(Validate_Network_Destination(admitted.Payload, 1), - NetAdmissionError::NONE, + Check_Admission_Error(NetAdmission::Validate_Destination(admitted.Payload, 1), + NetAdmission::Error::NONE, "an exact-capacity destination accepts the payload"); - Check_Admission_Error(Admit_Connection_Packet( - data, header_size, data.size() - 1).Error, - NetAdmissionError::PACKET_TOO_LARGE, + 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(NET_PRIVATE_HEADER_SIZE, - static_cast(NetPacketCode::COUNT), 1); - Check_Admission_Error(Admit_Connection_Packet( - invalid_code, NET_PRIVATE_HEADER_SIZE, invalid_code.size()).Error, - NetAdmissionError::INVALID_PACKET_CODE, + 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(NET_PRIVATE_HEADER_SIZE, - static_cast(NetPacketCode::DATA_ACK), 1); + 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); - NetConnectionAdmission const admitted_unaligned = Admit_Connection_Packet( - std::span(unaligned).subspan(1), NET_PRIVATE_HEADER_SIZE, aligned.size()); + 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"); } @@ -624,9 +624,9 @@ GlobalPacketType Global_Packet(NetCommandType command) } -NetGlobalValidationContext Member_Context(void) +NetGlobal::ValidationContext Member_Context(void) { - NetGlobalValidationContext context; + NetGlobal::ValidationContext context; context.SenderIsMember = true; context.SenderPlayerID = 2; context.SenderPlayerColor = 3; @@ -639,14 +639,14 @@ NetGlobalValidationContext Member_Context(void) void Check_Global_Error( GlobalPacketType const & packet, std::size_t length, - NetGlobalValidationContext const & context, - NetGlobalDecodeError expected, + NetGlobal::ValidationContext const & context, + NetGlobal::DecodeError expected, char const * what) { - NetGlobalDecodeError const actual = Validate_In_Game_Global(packet, length, context); + NetGlobal::DecodeError const actual = NetGlobal::Validate_In_Game_Packet(packet, length, context); Check(actual == expected, what); if (actual != expected) { - std::printf(" got %s\n", Net_Global_Error_Name(actual)); + std::printf(" got %s\n", NetGlobal::Error_Name(actual)); } } @@ -654,12 +654,12 @@ void Check_Global_Error( void Test_Global_Packets(void) { constexpr std::size_t packet_size = sizeof(GlobalPacketType); - NetGlobalValidationContext member = Member_Context(); - NetGlobalValidationContext outsider; + NetGlobal::ValidationContext member = Member_Context(); + NetGlobal::ValidationContext outsider; GlobalPacketType packet = Global_Packet(NET_QUERY_GAME); GlobalPacketType poisoned; std::memset(&poisoned, 0xA5, sizeof(poisoned)); - Initialize_Global_Packet(poisoned, NET_PROPOSE_KICK); + 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); @@ -672,89 +672,89 @@ void Test_Global_Packets(void) Check(fully_initialized, "global packet initialization overwrites poison across the current packet shape"); - Check_Global_Error(packet, packet_size - 1, outsider, NetGlobalDecodeError::INVALID_LENGTH, + 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, NetGlobalDecodeError::INVALID_LENGTH, + 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, NetGlobalDecodeError::NONE, + 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, NetGlobalDecodeError::NONE, + 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, NetGlobalDecodeError::UNTERMINATED_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, NetGlobalDecodeError::SENDER_NOT_MEMBER, + 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, NetGlobalDecodeError::NONE, + 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, NetGlobalDecodeError::INVALID_COMMAND, + 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, NetGlobalDecodeError::UNTERMINATED_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, NetGlobalDecodeError::UNTERMINATED_MESSAGE, + 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, NetGlobalDecodeError::NONE, + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::NONE, "chat ignores the wire color in favor of the matched member's color"); - NetGlobalValidationContext bad_color = member; + NetGlobal::ValidationContext bad_color = member; bad_color.SenderPlayerColor = MAX_MPLAYER_COLORS; - Check_Global_Error(packet, packet_size, bad_color, NetGlobalDecodeError::INVALID_COLOR, + 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, NetGlobalDecodeError::INVALID_PROGRESS, + 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, NetGlobalDecodeError::INVALID_PROGRESS, + 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, NetGlobalDecodeError::NONE, + 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, NetGlobalDecodeError::NONE, + 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, NetGlobalDecodeError::SELF_KICK, + 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, NetGlobalDecodeError::INVALID_KICK_PLAYER, + Check_Global_Error(packet, packet_size, member, NetGlobal::DecodeError::INVALID_KICK_PLAYER, "a kick target must be a current session member"); - NetGlobalRejectionCounters counters; - NetGlobalRejectionRecord first = counters.Record(NetGlobalDecodeError::INVALID_LENGTH); - NetGlobalRejectionRecord second = counters.Record(NetGlobalDecodeError::INVALID_LENGTH); - NetGlobalRejectionRecord third = counters.Record(NetGlobalDecodeError::INVALID_LENGTH); - NetGlobalRejectionRecord fourth = counters.Record(NetGlobalDecodeError::INVALID_LENGTH); + 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(NetGlobalDecodeError::INVALID_LENGTH) == 4, + Check(counters.Count(NetGlobal::DecodeError::INVALID_LENGTH) == 4, "global rejection counters retain a stable per-error total"); - Check(counters.Record(NetGlobalDecodeError::NONE).Count == 0, + Check(counters.Record(NetGlobal::DecodeError::NONE).Count == 0, "successful packets do not enter rejection counters"); } From 4dd596bf7818bccfcda83a7e420b9f39bc5cd41e Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 21:13:14 +0300 Subject: [PATCH 07/13] Complete network event authorization --- code/conquer.cpp | 34 ++++++++++++++--- code/event.cpp | 63 ++++++++++++++++++++++++++++++- code/netdlg.cpp | 5 ++- code/netglobal.cpp | 33 +++++++++++++++++ code/netglobal.h | 27 ++++++++++++++ code/netpacket.cpp | 61 ++++++++++++++++++++++++++++++ code/netpacket.h | 5 +++ code/netsemantic.cpp | 66 +++++++++++++++++++++++++++++++++ code/netsemantic.h | 10 +++++ code/queue.cpp | 12 +++++- code/session.cpp | 19 ++++++++++ code/session.h | 1 + tests/netpacket/netcontract.cpp | 51 +++++++++++++++++++++++++ tests/nettiming/nettiming.cpp | 28 +++++++++++++- 14 files changed, 404 insertions(+), 11 deletions(-) diff --git a/code/conquer.cpp b/code/conquer.cpp index 676088e..36eec57 100644 --- a/code/conquer.cpp +++ b/code/conquer.cpp @@ -115,6 +115,7 @@ #include "special.hh" +#include #include #include #include @@ -124,6 +125,7 @@ #include #include #include +#include /**************************************** @@ -538,17 +540,33 @@ static void Record_Global_Packet_Rejection(NetGlobal::DecodeError error) /// Resolves a registered packet source. -static NodeNameType * Session_Member_From_Address(IPXAddressClass & address, int & player_index) +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 && player->Address == address) { - player_index = index; - return(player); + if (player != NULL && count < endpoints.size()) { + endpoints[count] = {player->Address.Get_IP(), player->Address.Get_Port()}; + players[count] = player; + player_indices[count] = index; + count++; } } - return(NULL); + + 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]); } @@ -593,9 +611,13 @@ void IPX_Call_Back(void) if (Session.GProductID == IPXGlobalConnClass::COMMAND_AND_CONQUER2) { int sender_index = -1; - NodeNameType * sender = Session_Member_From_Address(Session.GAddress, sender_index); + 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; + } if (error != NetGlobal::DecodeError::NONE) { Record_Global_Packet_Rejection(error); diff --git a/code/event.cpp b/code/event.cpp index e322af6..6536e65 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -90,6 +90,8 @@ namespace { InvalidGameSpeed, InvalidRemovedHouse, InvalidLatencyFudge, + UnauthorizedSubject, + UnauthorizedRemoval, UnauthorizedTiming, InvalidTimingArithmetic, InvalidTimingValues, @@ -108,6 +110,8 @@ namespace { "invalid game speed", "invalid removed house", "invalid latency fudge", + "unauthorized subject", + "unauthorized removal", "unauthorized timing", "invalid timing arithmetic", "invalid timing values", @@ -130,6 +134,33 @@ namespace { 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) + { + 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: + return(NULL); + } + } } @@ -620,6 +651,17 @@ void EventClass::Execute(void) } HouseClass * house = Houses[ID]; + if ((Session.Type == GAME_IPX || Session.Type == GAME_INTERNET) && NetSemantic::Event_Requires_Owned_Subject(Type)) { + TechnoClass * subject = Event_Subject(*this); + 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; @@ -1103,8 +1145,20 @@ void EventClass::Execute(void) ** Adjust connection timing for multiplayer games */ case RESPONSE_TIME: + { + 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.MaxAhead = Data.FrameInfo.Delay; break; + } /* ** Save a multiplayer game (this event is only generated in multiplayer mode) @@ -1137,6 +1191,13 @@ void EventClass::Execute(void) Log_Event_Rejection(EventRejectReason::InvalidRemovedHouse, Type, ID, index); break; } + if (!Houses[index]->Is_Human_Player()) { + break; + } + if (!Session.Play && ID != Session.Removal_Authority_Player_ID(index)) { + Log_Event_Rejection(EventRejectReason::UnauthorizedRemoval, Type, ID, index); + break; + } DebugString("Executing REMOVEPLAYER event. Frame is %d\n", ::Frame); Disable_Multiplayer_Saving(); @@ -1178,7 +1239,7 @@ void EventClass::Execute(void) case TIMING: { int const master_id = Session.Master_Player_ID(); - if (!NetSemantic::Timing_Authority_Is_Valid(ID, master_id)) { + if (!Session.Play && !NetSemantic::Timing_Authority_Is_Valid(ID, master_id)) { Log_Event_Rejection(EventRejectReason::UnauthorizedTiming, Type, ID, master_id); break; } diff --git a/code/netdlg.cpp b/code/netdlg.cpp index e120ea7..3437e0b 100644 --- a/code/netdlg.cpp +++ b/code/netdlg.cpp @@ -231,6 +231,7 @@ void Destroy_Connection(int id, int error) int i; HouseClass *housep; char txt[80]; + int const removal_authority = Session.Removal_Authority_Player_ID(id); housep = Houses[(HousesType)id]; @@ -284,7 +285,9 @@ void Destroy_Connection(int id, int error) Ipx.Delete_Connection(id); if (error) { - OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::REMOVEPLAYER, id)); + if (PlayerPtr != NULL && PlayerPtr->HeapID == removal_authority) { + OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::REMOVEPLAYER, id)); + } } else if (Session.Type == GAME_INTERNET && WestwoodOnline_Tournament) { housep->Flag_To_Die(); } else { diff --git a/code/netglobal.cpp b/code/netglobal.cpp index e90483e..18b65b0 100644 --- a/code/netglobal.cpp +++ b/code/netglobal.cpp @@ -48,6 +48,38 @@ namespace NetGlobal } + /// 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) { @@ -173,6 +205,7 @@ namespace NetGlobal 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; } diff --git a/code/netglobal.h b/code/netglobal.h index f9a7b1b..47b8128 100644 --- a/code/netglobal.h +++ b/code/netglobal.h @@ -14,6 +14,7 @@ #include #include #include +#include namespace NetGlobal @@ -32,10 +33,34 @@ namespace NetGlobal 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; @@ -65,6 +90,8 @@ namespace NetGlobal 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 index c1f3286..136309a 100644 --- a/code/netpacket.cpp +++ b/code/netpacket.cpp @@ -32,7 +32,9 @@ namespace NetPacket 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); @@ -114,6 +116,22 @@ namespace NetPacket } + /// 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) { @@ -292,8 +310,18 @@ namespace NetPacket 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; @@ -447,6 +475,11 @@ namespace NetPacket 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)) { @@ -475,6 +508,11 @@ namespace NetPacket 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)); } @@ -573,6 +611,28 @@ namespace NetPacket } + /// 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) { @@ -604,6 +664,7 @@ namespace NetPacket 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"); diff --git a/code/netpacket.h b/code/netpacket.h index de9bd15..c9dcc3b 100644 --- a/code/netpacket.h +++ b/code/netpacket.h @@ -13,6 +13,7 @@ #include #include +#include #include #include @@ -36,6 +37,7 @@ namespace NetPacket FRAMESYNC_NOT_ALONE, NESTED_ENVELOPE, SENDER_MISMATCH, + INVALID_FRAME_ARITHMETIC, INVALID_EVENT_LENGTH, TRUNCATED_EVENT, ZERO_MEGAMISSION_COUNT, @@ -88,5 +90,8 @@ namespace NetPacket 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/netsemantic.cpp b/code/netsemantic.cpp index af319e2..4f68383 100644 --- a/code/netsemantic.cpp +++ b/code/netsemantic.cpp @@ -9,6 +9,8 @@ #include "netsemantic.h" +#include "event.h" + namespace NetSemantic { /// Checks a signed index against a collection size. @@ -18,6 +20,36 @@ namespace NetSemantic } + /// Identifies events whose resolved object must belong to their sender. + bool Event_Requires_Owned_Subject(unsigned int event_type) noexcept + { + switch (event_type) { + case EventClass::POWERON: + case EventClass::POWEROFF: + case EventClass::ARCHIVE: + case EventClass::REPAIR: + case EventClass::PRIMARY: + case EventClass::MEGAMISSION: + case EventClass::MEGAMISSION_F: + case EventClass::IDLE: + case EventClass::DEPLOY: + case EventClass::SCATTER: + case EventClass::SELL: + return(true); + + default: + return(false); + } + } + + + /// 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 { @@ -53,6 +85,40 @@ namespace NetSemantic } + /// 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(frame_send_rate >= NetTiming::MINIMUM_TIMING_RUNG && frame_send_rate <= NetTiming::MAXIMUM_TIMING_RUNG + && delay >= 2 * frame_send_rate && delay % frame_send_rate == 0); + } + + + /// Resolves the synchronized authority for one player removal. + int Removal_Authority(int target, int master, int successor) noexcept + { + if (target < 0 || master < 0) { + return(-1); + } + if (target != master) { + return(master); + } + return(successor >= 0 && successor != target ? successor : -1); + } + + + /// Checks a player-removal sender against the deterministic authority. + bool Removal_Authority_Is_Valid(int sender, int target, int master, int successor) noexcept + { + return(sender != target && sender == Removal_Authority(target, master, successor)); + } + + /// 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 { diff --git a/code/netsemantic.h b/code/netsemantic.h index 1fb4e0b..f6c5f05 100644 --- a/code/netsemantic.h +++ b/code/netsemantic.h @@ -20,6 +20,10 @@ namespace NetSemantic { bool Index_Is_Valid(int index, std::size_t count) noexcept; + bool Event_Requires_Owned_Subject(unsigned int event_type) 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; @@ -30,6 +34,12 @@ namespace NetSemantic 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; + + int Removal_Authority(int target, int master, int successor) noexcept; + + bool Removal_Authority_Is_Valid(int sender, int target, int master, int successor) noexcept; + std::optional Decode_Timing_Settings(std::uint16_t desired_frame_rate, std::uint16_t max_ahead, std::uint8_t frame_send_rate) noexcept; bool Network_Report_Is_Valid(std::uint16_t process_milliseconds, std::uint16_t round_trip_milliseconds) noexcept; diff --git a/code/queue.cpp b/code/queue.cpp index 28080f9..f447053 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -171,7 +171,9 @@ #include #include +#include #include +#include /********************************** Defines *********************************/ @@ -1878,6 +1880,11 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, } 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 @@ -1891,7 +1898,7 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, //------------------------------------------------------------------------ // Compute the other player's frame # (at the time this packet was sent) //------------------------------------------------------------------------ - int const frame = event.Frame - event.Data.FrameInfo.Delay; + int const frame = static_cast(*reported_frame); if (their[index].frame < frame) { //..................................................................... @@ -1919,7 +1926,8 @@ static RetcodeType Process_Receive_Packet(ConnManClass *net, //------------------------------------------------------------------------ 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) { diff --git a/code/session.cpp b/code/session.cpp index 2723d18..ce9235b 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -61,6 +61,7 @@ #include "ipxmgr.h" #include "language\language.h" #include "msgloop.h" +#include "netsemantic.h" #include "progress.h" #include "queue.h" #include "rules.h" @@ -458,6 +459,24 @@ int SessionClass::Master_Player_ID(void) const } +/// Returns the player authorized to remove a synchronized peer. +int SessionClass::Removal_Authority_Player_ID(int target) const +{ + int const master = Master_Player_ID(); + int successor = -1; + if (target == master) { + for (int i = 0; i < Houses.Count(); i++) { + HouseClass const * house = Houses[i]; + if (house != NULL && house->HeapID != target && house->IsHuman && Is_Network_Player_ID(house->HeapID)) { + successor = house->HeapID; + break; + } + } + } + return(NetSemantic::Removal_Authority(target, master, successor)); +} + + /// Tests whether a player ID still belongs to the network session. bool SessionClass::Is_Network_Player_ID(int id) const { diff --git a/code/session.h b/code/session.h index 84d7583..d9d175c 100644 --- a/code/session.h +++ b/code/session.h @@ -463,6 +463,7 @@ class SessionClass int Create_Connections(void); bool Am_I_Master(void); int Master_Player_ID(void) const; + int Removal_Authority_Player_ID(int target) const; bool Is_Network_Player_ID(int id) const; void Reset_Network_Timing(void); bool Record_Network_Report(int id, unsigned int process_milliseconds, unsigned int round_trip_milliseconds, unsigned int frame); diff --git a/tests/netpacket/netcontract.cpp b/tests/netpacket/netcontract.cpp index 7ba5b97..f5e74db 100644 --- a/tests/netpacket/netcontract.cpp +++ b/tests/netpacket/netcontract.cpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -33,7 +34,9 @@ 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); @@ -231,6 +234,37 @@ void Test_Envelope_Rules(void) } +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(); @@ -672,6 +706,22 @@ void Test_Global_Packets(void) 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, @@ -766,6 +816,7 @@ int main(void) Test_Reader(); Test_Event_Contract(); Test_Envelope_Rules(); + Test_Frame_Arithmetic(); Test_Full_Compressed_Table(); Test_Mega_Mission(); Test_Add_Player(); diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index 06de337..3b6cfb1 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -8,6 +8,7 @@ ******************************************************************************/ +#include "event.h" #include "netsemantic.h" #include "nettiming.h" @@ -322,6 +323,31 @@ namespace Expect("guest timing authority is rejected", !Timing_Authority_Is_Valid(3, 2)); Expect("unresolved timing authority is rejected", !Timing_Authority_Is_Valid(2, -1)); + for (unsigned int type = 0; type < EventClass::LAST_EVENT; type++) { + bool const expected = type == EventClass::POWERON || type == EventClass::POWEROFF || type == EventClass::ARCHIVE + || type == EventClass::REPAIR || type == EventClass::PRIMARY || type == EventClass::MEGAMISSION + || type == EventClass::MEGAMISSION_F || type == EventClass::IDLE || type == EventClass::DEPLOY + || type == EventClass::SCATTER || type == EventClass::SELL; + Expect("ownership-required event classification is exact", Event_Requires_Owned_Subject(type) == expected); + } + 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 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 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)); + + Expect_Equal("master removes a guest", Removal_Authority(4, 2, -1), 2); + Expect_Equal("successor removes the master", Removal_Authority(2, 2, 3), 3); + Expect_Equal("master removal without a successor is unresolved", Removal_Authority(2, 2, -1), -1); + Expect("resolved removal authority is accepted", Removal_Authority_Is_Valid(2, 4, 2, -1)); + Expect("unauthorized removal is rejected", !Removal_Authority_Is_Valid(3, 4, 2, -1)); + Expect("self-removal is rejected", !Removal_Authority_Is_Valid(4, 4, 4, 2)); + 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)); @@ -475,7 +501,7 @@ namespace 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)); Expect("overflowing staging rejected", !Stage_Timing_Update({10, 30}, {9, 27}, - std::numeric_limits::max() - 10)); + (std::numeric_limits::max)() - 10)); } } From 95eb1b0e2f995aafac53bcb952f8802bea3adfa8 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 21:37:08 +0300 Subject: [PATCH 08/13] Make adaptive timing transitions safe --- code/event.cpp | 4 +- code/init.cpp | 2 + code/ipxmgr.cpp | 25 ++- code/netdlg.cpp | 13 +- code/netdlg2.cpp | 45 +++--- code/nettiming.cpp | 166 +++++++++++++++----- code/nettiming.h | 42 +++++- code/queue.cpp | 44 ++---- code/session.cpp | 187 ++++++++++++++--------- code/session.h | 32 ++-- tests/nettiming/nettiming.cpp | 277 +++++++++++++++++++++++++++++----- 11 files changed, 603 insertions(+), 234 deletions(-) diff --git a/code/event.cpp b/code/event.cpp index 6536e65..9e2380f 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -1156,7 +1156,7 @@ void EventClass::Execute(void) Log_Event_Rejection(EventRejectReason::InvalidTimingValues, Type, ID, Data.FrameInfo.Delay); break; } - Session.MaxAhead = Data.FrameInfo.Delay; + Session.Apply_Network_Response_Time(Data.FrameInfo.Delay, Frame >= 0 ? static_cast(Frame) : 0u); break; } @@ -1201,7 +1201,7 @@ void EventClass::Execute(void) DebugString("Executing REMOVEPLAYER event. Frame is %d\n", ::Frame); Disable_Multiplayer_Saving(); - Session.Remove_Network_Timing_Player(index); + 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(); diff --git a/code/init.cpp b/code/init.cpp index c4543ac..dff056f 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/ipxmgr.cpp b/code/ipxmgr.cpp index 778501a..0f8d4cb 100644 --- a/code/ipxmgr.cpp +++ b/code/ipxmgr.cpp @@ -82,6 +82,7 @@ #include "wspudp.h" #include +#include /*************************************************************************** @@ -1353,21 +1354,29 @@ unsigned int IPXManagerClass::Response_Time(void) } /* end of Response_Time */ -/// Returns the worst measured private-link round trip, once all links have a sample. +/// Returns the worst measured round trip among active private links. std::optional IPXManagerClass::Worst_Local_Round_Trip_MS(void) const { - if (NumConnections == 0) { - return(std::nullopt); - } - - std::optional worst; + NetTiming::Milliseconds worst = 0; + std::array connected = {}; for (int i = 0; i < NumConnections; i++) { + int const id = Connection[i]->ID; + if (!Session.Is_Network_Timing_Player_Active(id)) { + continue; + } + connected[id] = true; + std::optional const round_trip = Connection[i]->Smoothed_Round_Trip_MS(); if (!round_trip) { return(std::nullopt); } - if (!worst || *round_trip > *worst) { - worst = round_trip; + worst = std::max(worst, *round_trip); + } + + int const local_id = PlayerPtr != NULL ? PlayerPtr->HeapID : -1; + for (unsigned int id = 0; id < connected.size(); id++) { + if (static_cast(id) != local_id && Session.Is_Network_Timing_Player_Active(id) && !connected[id]) { + return(std::nullopt); } } diff --git a/code/netdlg.cpp b/code/netdlg.cpp index 3437e0b..40c36cb 100644 --- a/code/netdlg.cpp +++ b/code/netdlg.cpp @@ -284,17 +284,8 @@ void Destroy_Connection(int id, int error) //------------------------------------------------------------------------ Ipx.Delete_Connection(id); - if (error) { - if (PlayerPtr != NULL && PlayerPtr->HeapID == removal_authority) { - 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(); + if (PlayerPtr != NULL && PlayerPtr->HeapID == removal_authority) { + OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::REMOVEPLAYER, id)); } Session.NumPlayers--; diff --git a/code/netdlg2.cpp b/code/netdlg2.cpp index ac1d053..da9fabf 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); } @@ -2833,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; diff --git a/code/nettiming.cpp b/code/nettiming.cpp index 15825f6..728dc3f 100644 --- a/code/nettiming.cpp +++ b/code/nettiming.cpp @@ -36,12 +36,12 @@ namespace NetTiming /// Selects timing for the current report census. TimingSettings Desired_Settings(TimingCensus const & census, unsigned int target_fps, LatencyFudge fudge, 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)); } - if (!census.Complete) { - return(TimingSettings{MAXIMUM_TIMING_RUNG, MAXIMUM_MAX_AHEAD}); - } return(Select_Timing_Settings(census.WorstRoundTrip, target_fps, fudge, require_headroom)); } @@ -151,6 +151,15 @@ namespace NetTiming } + /// 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); + } + + /// Applies the selected RTT safety margin. Milliseconds Apply_Latency_Fudge(Milliseconds round_trip, LatencyFudge fudge) { @@ -243,7 +252,7 @@ namespace NetTiming /// Adds or removes a player from the census. - bool TimingReportCensus::Set_Player_Active(unsigned int player, bool active) + bool TimingReportCensus::Set_Player_Active(unsigned int player, bool active, std::uint32_t frame) { if (player >= Reports.size()) { return(false); @@ -253,36 +262,34 @@ namespace NetTiming if (report.Active != active) { report = {}; report.Active = active; + report.ActiveSinceFrame = frame; } return(true); } - /// Records one active player's fresh RTT report. - bool TimingReportCensus::Record_Report(unsigned int player, Milliseconds round_trip, std::uint32_t frame) + /// Checks whether a player belongs to the timing census. + bool TimingReportCensus::Is_Player_Active(unsigned int player) const { - if (player >= Reports.size() || !Reports[player].Active || round_trip > MAXIMUM_REPORTED_RTT) { - return(false); - } - - PlayerReport & report = Reports[player]; - report.Present = true; - report.RoundTrip = round_trip; - report.Frame = frame; - return(true); + return(player < Reports.size() && Reports[player].Active); } - /// Marks an active player's RTT as unavailable. - bool TimingReportCensus::Clear_Report(unsigned int player) + /// 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) { + if (player >= Reports.size() || !Reports[player].Active || process_milliseconds > MAXIMUM_PROCESS_MILLISECONDS + || (round_trip && *round_trip > MAXIMUM_REPORTED_RTT)) { return(false); } - Reports[player].Present = false; - Reports[player].RoundTrip = 0; - Reports[player].Frame = 0; + 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); } @@ -297,18 +304,43 @@ namespace NetTiming } result.ActivePlayers++; - if (!report.Present || frame - report.Frame >= REPORT_EXPIRY) { - result.Complete = false; - continue; + 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; } - result.FreshReports++; - result.WorstRoundTrip = std::max(result.WorstRoundTrip, report.RoundTrip); + 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(void) { @@ -320,7 +352,20 @@ namespace NetTiming LastChangeFrame = 0; HasEvaluated = false; HasChanged = false; - HasCompleteCensus = false; + } + + + /// Restores synchronized policy state after a master handoff. + void BalancedTimingPolicy::Reset_From(TimingSettings settings, unsigned int reversible_changes, std::uint32_t frame) + { + CurrentRung = std::clamp(settings.FrameSendRate, MINIMUM_TIMING_RUNG, MAXIMUM_TIMING_RUNG); + CurrentSettings = settings; + GoodEvaluations = 0; + ReversibleChanges = std::min(reversible_changes, REVERSIBLE_CHANGE_LIMIT); + LastEvaluationFrame = frame; + LastChangeFrame = frame; + HasEvaluated = true; + HasChanged = true; } @@ -349,10 +394,8 @@ namespace NetTiming HasEvaluated = true; LastEvaluationFrame = frame; result.Evaluated = true; - if (census.Complete && census.ActivePlayers > 0) { - HasCompleteCensus = true; - } - if (!HasCompleteCensus && !census.Complete) { + if (!census.RequiresConservativeTiming && census.ActivePlayers > 0 && !census.RoundTripComplete) { + GoodEvaluations = 0; return(result); } @@ -388,16 +431,15 @@ namespace NetTiming /// Delays decreases until the old scheduling horizon drains. std::optional Stage_Timing_Update(TimingSettings current, TimingSettings requested, std::uint32_t event_frame) { - if (!Timing_Settings_Are_Valid(current) || !Timing_Settings_Are_Valid(requested)) { + 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, event_frame, false}); + return(StagedTimingUpdate{requested, requested.MaxAhead, event_frame, false}); } - // Aligning to both periods keeps already scheduled commands on the old horizon. 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; @@ -405,7 +447,61 @@ namespace NetTiming return(std::nullopt); } - return(StagedTimingUpdate{requested, static_cast(activation), true}); + 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); } diff --git a/code/nettiming.h b/code/nettiming.h index 1ac4085..7e82149 100644 --- a/code/nettiming.h +++ b/code/nettiming.h @@ -80,6 +80,7 @@ namespace NetTiming TimingSettings Settings_For_Rung(unsigned int rung); bool Timing_Settings_Are_Valid(TimingSettings settings); + bool Timing_Transition_Source_Is_Valid(TimingSettings settings); Milliseconds Apply_Latency_Fudge(Milliseconds round_trip, LatencyFudge fudge); 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, LatencyFudge fudge, bool require_headroom = false); @@ -87,31 +88,41 @@ namespace NetTiming struct TimingCensus { unsigned int ActivePlayers = 0; - unsigned int FreshReports = 0; + unsigned int FreshProcessReports = 0; + unsigned int FreshRoundTripReports = 0; + Milliseconds WorstProcessMilliseconds = 0; Milliseconds WorstRoundTrip = 0; - bool Complete = true; + bool ProcessComplete = true; + bool RoundTripComplete = true; + bool RequiresConservativeTiming = false; }; class TimingReportCensus { public: void Reset(void); - bool Set_Player_Active(unsigned int player, bool active); - bool Record_Report(unsigned int player, Milliseconds round_trip, std::uint32_t frame); - bool Clear_Report(unsigned int player); + 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 Present = false; + bool HasReport = false; + bool HasRoundTrip = false; + bool EverHadRoundTrip = false; + Milliseconds ProcessMilliseconds = 0; Milliseconds RoundTrip = 0; - std::uint32_t Frame = 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; @@ -123,6 +134,7 @@ namespace NetTiming { public: void Reset(void); + void Reset_From(TimingSettings settings, unsigned int reversible_changes, std::uint32_t frame); TimingEvaluation Evaluate(TimingCensus const & census, unsigned int target_fps, LatencyFudge fudge, std::uint32_t frame); unsigned int Current_Rung(void) const {return(CurrentRung);} @@ -141,15 +153,27 @@ namespace NetTiming std::uint32_t LastChangeFrame = 0; bool HasEvaluated = false; bool HasChanged = false; - bool HasCompleteCensus = false; }; 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, @@ -158,5 +182,7 @@ namespace NetTiming }; 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 f447053..fb878f4 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -483,8 +483,8 @@ bool Queue_Exit(void) *=========================================================================*/ void Queue_AI(void) { - if (Frame >= 0) { - Session.Apply_Staged_Network_Timing(static_cast(Frame)); + if (Frame >= 0 && (Session.Type == GAME_IPX || Session.Type == GAME_INTERNET)) { + Session.Advance_Network_Timing(static_cast(Frame)); } if (Session.Play) { @@ -828,7 +828,7 @@ static void Queue_AI_Multiplayer(void) // Adjust connection timing parameters every 128 frames. //------------------------------------------------------------------------ - else if ( (Frame & 0x007f) == 0) { + else if (Frame % NetTiming::REPORT_INTERVAL == 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 @@ -848,8 +848,8 @@ static void Queue_AI_Multiplayer(void) } // The deterministic master periodically evaluates the shared reports. - if (Session.Am_I_Master() && (Session.PrecalcMaxAhead != 0 || Session.PrecalcDesiredFrameRate != 0 || - (Frame & (NetTiming::EVALUATION_INTERVAL - 1)) == 0)) { + int const timing_master = Session.Master_Player_ID(); + if (PlayerPtr != NULL && PlayerPtr->HeapID == timing_master && Frame % NetTiming::EVALUATION_INTERVAL == 0) { Generate_Real_Timing_Event(); } @@ -1573,35 +1573,21 @@ static void Generate_Real_Timing_Event(void) EventClass event; memset(&event, 0, sizeof(event)); - if (Session.PrecalcMaxAhead != 0 || Session.PrecalcDesiredFrameRate != 0) { - NetTiming::TimingSettings const settings{Session.PrecalcDesiredFrameRate > 30u ? 10u : 5u, static_cast(Session.PrecalcMaxAhead)}; - if (Session.PrecalcDesiredFrameRate > 0 && Session.PrecalcDesiredFrameRate <= 60 && NetTiming::Timing_Settings_Are_Valid(settings)) { - event.Type = EventClass::TIMING; - event.Data.Timing.DesiredFrameRate = Session.PrecalcDesiredFrameRate; - event.Data.Timing.MaxAhead = settings.MaxAhead; - event.Data.Timing.FrameSendRate = settings.FrameSendRate; - OutList.push_back(event); - } else { - DebugString("Ignoring invalid precalculated network timing values\n"); - } - - Session.PrecalcMaxAhead = 0; - Session.PrecalcDesiredFrameRate = 0; + if (Frame < 0) { return; } - int highest_process_milliseconds = 0; - for (int index = 0; index < Session.Players.Count(); index++) { - NodeNameType const * player = Session.Players[index]; - if (player == NULL || player->Player.ProcessTime < 0) { - return; - } - highest_process_milliseconds = std::max(highest_process_milliseconds, player->Player.ProcessTime); + 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); - unsigned int process_frame_rate = highest_process_milliseconds == 0 ? 60u : static_cast(std::max(1, 1000 / highest_process_milliseconds)); - unsigned int const desired_frame_rate = std::min(process_frame_rate, static_cast(Game_Speed_Frame_Rate())); - NetTiming::TimingEvaluation const evaluation = Session.Evaluate_Network_Timing(desired_frame_rate, static_cast(Frame)); + 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; } diff --git a/code/session.cpp b/code/session.cpp index ce9235b..a8685cd 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -186,17 +186,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(); + Reset_Network_Timing(0); memset(ConnectionStats, 0, sizeof(ConnectionStats)); - PrecalcMaxAhead = 0; - PrecalcDesiredFrameRate = 0; - ShowInternetDebug = false; LoadGame = 0; @@ -335,8 +332,6 @@ int SessionClass::Create_Connections(void) if (Session.Type != GAME_IPX && Session.Type != GAME_INTERNET) { return(0); } - Reset_Network_Timing(); - //------------------------------------------------------------------------ // Loop through all entries in 'Players' //------------------------------------------------------------------------ @@ -373,6 +368,8 @@ int SessionClass::Create_Connections(void) } } + Reset_Network_Timing(Frame >= 0 ? static_cast(Frame) : 0u); + DebugString("Leaving Create_Connections\n"); return(1); @@ -480,74 +477,75 @@ int SessionClass::Removal_Authority_Player_ID(int target) const /// Tests whether a player ID still belongs to the network session. bool SessionClass::Is_Network_Player_ID(int id) const { - if (id < 0 || id >= (int)NetTiming::MAX_TIMING_PLAYERS || RemovedNetworkTimingPlayers[id]) { - return(false); - } - for (int i = 0; i < Players.Count(); i++) { - if (Players[i] != NULL && Players[i]->Player.ID == id) { - return(true); - } - } - return(false); + 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. -void SessionClass::Reset_Network_Timing(void) +/// 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(); PendingNetworkTiming.reset(); - PendingNetworkDesiredFrameRate = 0; - for (bool & removed : RemovedNetworkTimingPlayers) { - removed = false; - } + NetworkTimingChangeCount = 0; + 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); + 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) { - if (id < 0 || id >= (int)NetTiming::MAX_TIMING_PLAYERS || RemovedNetworkTimingPlayers[id] || + if (!Is_Network_Timing_Player_Active(id) || process_milliseconds > NetTiming::MAXIMUM_PROCESS_MILLISECONDS || (round_trip_milliseconds != EventClass::NETWORK_RTT_UNAVAILABLE && round_trip_milliseconds > NetTiming::MAXIMUM_REPORTED_RTT)) { return(false); } - NodeNameType * player = NULL; - for (int i = 0; i < Players.Count(); i++) { - if (Players[i] != NULL && Players[i]->Player.ID == id) { - player = Players[i]; - break; - } + std::optional round_trip; + if (round_trip_milliseconds != EventClass::NETWORK_RTT_UNAVAILABLE) { + round_trip = round_trip_milliseconds; } - if (player == NULL) { + if (!NetworkTimingReports.Record_Report(id, process_milliseconds, round_trip, frame)) { return(false); } - player->Player.ProcessTime = process_milliseconds; - NetworkTimingReports.Set_Player_Active(id, true); - if (round_trip_milliseconds == EventClass::NETWORK_RTT_UNAVAILABLE) { - // The player remains in the census while its missing RTT prevents a complete sample set. - return(NetworkTimingReports.Clear_Report(id)); + 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(NetworkTimingReports.Record_Report(id, round_trip_milliseconds, frame)); + return(true); } /// Removes a departed player from the timing census. -void SessionClass::Remove_Network_Timing_Player(int id) +void SessionClass::Remove_Network_Timing_Player(int id, unsigned int frame) { - if (id >= 0 && id < (int)NetTiming::MAX_TIMING_PLAYERS) { - RemovedNetworkTimingPlayers[id] = true; - NetworkTimingReports.Set_Player_Active(id, false); + if (Is_Network_Timing_Player_Active(id)) { + NetworkTimingReports.Set_Player_Active(id, false, frame); + Prepare_Network_Timing_Master(Master_Player_ID(), frame); } } @@ -555,25 +553,47 @@ void SessionClass::Remove_Network_Timing_Player(int id) /// Returns a freshness-aware census of seated players. NetTiming::TimingCensus SessionClass::Network_Timing_Census(unsigned int frame) { - bool active[NetTiming::MAX_TIMING_PLAYERS] = {}; - 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) { - active[id] = true; - } - } - for (unsigned int id = 0; id < NetTiming::MAX_TIMING_PLAYERS; id++) { - NetworkTimingReports.Set_Player_Active(id, active[id] && !RemovedNetworkTimingPlayers[id]); - } return(NetworkTimingReports.Inspect(frame)); } /// Evaluates the adaptive-timing policy against the current census. -NetTiming::TimingEvaluation SessionClass::Evaluate_Network_Timing(unsigned int target_fps, unsigned int frame) +NetTiming::TimingEvaluation SessionClass::Evaluate_Network_Timing(NetTiming::TimingCensus const & census, unsigned int target_fps, unsigned int frame) { int const fudge = std::clamp(LatencyFudge, 0, 3); - return(NetworkTimingPolicy.Evaluate(Network_Timing_Census(frame), target_fps, static_cast(fudge), frame)); + return(NetworkTimingPolicy.Evaluate(census, target_fps, static_cast(fudge), 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(), NetworkTimingChangeCount, 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}, NetworkTimingChangeCount, event_frame); + } } @@ -585,25 +605,36 @@ NetTiming::ScheduleResult SessionClass::Schedule_Network_Timing(NetTiming::Timin } NetTiming::TimingSettings const current{FrameSendRate, MaxAhead}; - std::optional staged; - if (NetTiming::Timing_Settings_Are_Valid(current)) { - staged = NetTiming::Stage_Timing_Update(current, settings, event_frame); - } else { - staged = NetTiming::StagedTimingUpdate{settings, event_frame, false}; + if (!NetTiming::Timing_Transition_Source_Is_Valid(current)) { + return(NetTiming::ScheduleResult::Rejected); + } + + NetTiming::TimingSettings const old_target = Network_Timing_Target(); + 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 (settings != old_target && NetworkTimingChangeCount < NetTiming::REVERSIBLE_CHANGE_LIMIT) { + NetworkTimingChangeCount++; + } - // Decreases wait until commands scheduled under the old horizon have drained. if (staged->Deferred) { - PendingNetworkTiming = staged; - PendingNetworkDesiredFrameRate = desired_frame_rate; + NetworkTimingTransition transition; + transition.Timing.Plan = *staged; + transition.DesiredFrameRate = desired_frame_rate; + PendingNetworkTiming = transition; return(NetTiming::ScheduleResult::Staged); } PendingNetworkTiming.reset(); - PendingNetworkDesiredFrameRate = 0; DesiredFrameRate = desired_frame_rate; FrameSendRate = settings.FrameSendRate; MaxAhead = settings.MaxAhead; @@ -612,20 +643,30 @@ NetTiming::ScheduleResult SessionClass::Schedule_Network_Timing(NetTiming::Timin } -/// Activates a staged timing decrease once its safe frame is reached. -bool SessionClass::Apply_Staged_Network_Timing(unsigned int frame) +/// Advances a deterministic drain/catch-up timing transition. +bool SessionClass::Advance_Network_Timing(unsigned int frame) { - if (!PendingNetworkTiming || !NetTiming::Timing_Update_Is_Due(frame, PendingNetworkTiming->ActivationFrame)) { + if (!PendingNetworkTiming) { return(false); } - NetTiming::TimingSettings const settings = PendingNetworkTiming->Settings; - DesiredFrameRate = PendingNetworkDesiredFrameRate; - FrameSendRate = settings.FrameSendRate; - MaxAhead = settings.MaxAhead; + 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); - PendingNetworkTiming.reset(); - PendingNetworkDesiredFrameRate = 0; + if (advance->Complete) { + PendingNetworkTiming.reset(); + } return(true); } diff --git a/code/session.h b/code/session.h index d9d175c..3061bd7 100644 --- a/code/session.h +++ b/code/session.h @@ -437,6 +437,12 @@ class SessionClass // Public interface //------------------------------------------------------------------------ public: + struct NetworkTimingTransition + { + NetTiming::TimingTransitionState Timing; + unsigned int DesiredFrameRate = 30; + }; + //..................................................................... // Constructor/Destructor //..................................................................... @@ -465,13 +471,17 @@ class SessionClass int Master_Player_ID(void) const; int Removal_Authority_Player_ID(int target) const; bool Is_Network_Player_ID(int id) const; - void Reset_Network_Timing(void); + 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); + void Remove_Network_Timing_Player(int id, unsigned int frame); NetTiming::TimingCensus Network_Timing_Census(unsigned int frame); - NetTiming::TimingEvaluation Evaluate_Network_Timing(unsigned int target_fps, 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 Apply_Staged_Network_Timing(unsigned int frame); + bool Advance_Network_Timing(unsigned int frame); unsigned int Compute_Unique_ID(void); void Update_Progress(int percent); void Init_Fixed_Alliances(void); @@ -544,9 +554,9 @@ class SessionClass unsigned int FrameSendRate; NetTiming::TimingReportCensus NetworkTimingReports; NetTiming::BalancedTimingPolicy NetworkTimingPolicy; - std::optional PendingNetworkTiming; - unsigned int PendingNetworkDesiredFrameRate; - bool RemovedNetworkTimingPlayers[NetTiming::MAX_TIMING_PLAYERS]; + std::optional PendingNetworkTiming; + unsigned int NetworkTimingChangeCount; + int NetworkTimingPolicyOwner; int DesiredFrameRate; @@ -560,14 +570,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. diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index 3b6cfb1..b028067 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -15,7 +15,10 @@ #include #include #include +#include #include +#include +#include namespace @@ -225,37 +228,83 @@ namespace using namespace NetTiming; TimingReportCensus census; - Expect("activate first peer", census.Set_Player_Active(1, true)); - Expect("activate second peer", census.Set_Player_Active(2, true)); - Expect("reject out of range peer", !census.Set_Player_Active(MAX_TIMING_PLAYERS, true)); - Expect("record first peer", census.Record_Report(1, 80, 100)); - Expect("record second peer", census.Record_Report(2, 180, 100)); - Expect("accept RTT above retransmit clamp", census.Record_Report(2, MAXIMUM_RTO + 1, 100)); - Expect("reject RTT beyond wire range", !census.Record_Report(2, MAXIMUM_REPORTED_RTT + 1, 100)); + 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 report count", result.FreshReports, 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 census complete", result.Complete); + 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, LatencyFudge::None, 200); - Expect("a guest-to-guest slow path worsens the master policy", - guest_degradation.Changed && guest_degradation.Rung == MAXIMUM_TIMING_RUNG); + TimingEvaluation const guest_degradation = aggregate.Evaluate(result, 60, LatencyFudge::None, 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("reports expire on boundary", !result.Complete); - Expect_Equal("expired reports not fresh", result.FreshReports, 0u); - - Expect("departed peer removed", census.Set_Player_Active(2, false)); - Expect("remaining peer refreshed", census.Record_Report(1, 90, 700)); + 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 census", result.Complete); + 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("clear unavailable report", census.Clear_Report(1)); - Expect("cleared active report makes census incomplete", !census.Inspect(700).Complete); + + 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); } @@ -269,6 +318,7 @@ namespace 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("no latency fudge", Apply_Latency_Fudge(100, LatencyFudge::None), 100u); @@ -364,10 +414,9 @@ namespace } - void Record_One(NetTiming::TimingReportCensus & census, NetTiming::Milliseconds rtt, - std::uint32_t frame) + void Record_One(NetTiming::TimingReportCensus & census, NetTiming::Milliseconds rtt, std::uint32_t frame) { - census.Record_Report(1, rtt, frame); + census.Record_Report(1, 10, rtt, frame); } @@ -376,7 +425,7 @@ namespace using namespace NetTiming; TimingReportCensus reports; - reports.Set_Player_Active(1, true); + reports.Set_Player_Active(1, true, 0); BalancedTimingPolicy policy; Record_One(reports, 0, 0); @@ -397,7 +446,7 @@ namespace BalancedTimingPolicy headroom; TimingReportCensus edge; - edge.Set_Player_Active(1, true); + edge.Set_Player_Active(1, true, 0); for (std::uint32_t frame : {0u, 256u, 512u}) { Record_One(edge, 120, frame); headroom.Evaluate(edge.Inspect(frame), 60, LatencyFudge::None, frame); @@ -426,27 +475,28 @@ namespace using namespace NetTiming; TimingReportCensus stale; - stale.Set_Player_Active(1, true); + stale.Set_Player_Active(1, true, 0); BalancedTimingPolicy stale_policy; TimingEvaluation result = stale_policy.Evaluate(stale.Inspect(0), 60, LatencyFudge::None, 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, 100, 256); + stale.Record_Report(1, 10, 100, 256); stale_policy.Evaluate(stale.Inspect(256), 60, LatencyFudge::None, 256); result = stale_policy.Evaluate(stale.Inspect(256 + REPORT_EXPIRY), 60, LatencyFudge::None, 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); + stale.Set_Player_Active(1, false, 1024); for (std::uint32_t frame : {1024u, 1280u, 1536u}) { stale_policy.Evaluate(stale.Inspect(frame), 60, LatencyFudge::None, frame); } Expect_Equal("departed peer allows recovery", stale_policy.Current_Rung(), 9u); TimingReportCensus reports; - reports.Set_Player_Active(1, true); + reports.Set_Player_Active(1, true, 0); BalancedTimingPolicy policy; std::uint32_t frame = 0; @@ -478,6 +528,62 @@ namespace } + 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}, REVERSIBLE_CHANGE_LIMIT + 5, 1000); + Expect("handoff restores authoritative settings", policy.Current_Settings() == TimingSettings{10, 70}); + Expect_Equal("handoff saturates the transition budget", policy.Reversible_Changes(), REVERSIBLE_CHANGE_LIMIT); + Expect_Equal("handoff discards improvement evidence", policy.Good_Evaluations(), 0u); + + Record_One(reports, 0, 1000); + TimingEvaluation result = policy.Evaluate(reports.Inspect(1000), 60, LatencyFudge::None, 1000); + Expect("handoff starts an evaluation cooldown", !result.Evaluated); + for (std::uint32_t frame : {1256u, 1512u, 1768u, 2024u}) { + Record_One(reports, 0, frame); + result = policy.Evaluate(reports.Inspect(frame), 60, LatencyFudge::None, frame); + } + Expect("restored transition budget prevents improvement", policy.Current_Settings() == TimingSettings{10, 70}); + + Record_One(reports, MAXIMUM_REPORTED_RTT, 2280); + result = policy.Evaluate(reports.Inspect(2280), 60, LatencyFudge::Triple, 2280); + Expect("conservative worsening remains after handoff budget", result.Changed && policy.Current_Settings() == TimingSettings{10, 250}); + Expect_Equal("worsening leaves saturated budget intact", policy.Reversible_Changes(), REVERSIBLE_CHANGE_LIMIT); + + TimingReportCensus recovery_reports; + recovery_reports.Set_Player_Active(1, true, 0); + BalancedTimingPolicy recover; + recover.Reset_From({10, 250}, 2, 0); + for (std::uint32_t frame : {256u, 512u, 768u}) { + Record_One(recovery_reports, 0, frame); + result = recover.Evaluate(recovery_reports.Inspect(frame), 60, LatencyFudge::None, 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, 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, LatencyFudge::None, 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, 0); + result = legacy.Evaluate(legacy_reports.Inspect(256), 60, LatencyFudge::None, 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; @@ -485,23 +591,124 @@ namespace 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}); + 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)); - Expect("overflowing staging rejected", !Stage_Timing_Update({10, 30}, {9, 27}, - (std::numeric_limits::max)() - 10)); + 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); } } @@ -517,7 +724,9 @@ int main(void) Test_Event_Semantics(); Test_Hysteresis_And_Cooldown(); Test_Stale_And_Transition_Budget(); + Test_Master_Handoff_State(); Test_Staged_Decrease(); + Test_Transition_Sequences(); if (Failures != 0) { std::cerr << Failures << " network timing checks failed\n"; From aee358f5912477db4328c7e3373b3ea10531a3cb Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 21:49:41 +0300 Subject: [PATCH 09/13] Clean up network hardening changes --- code/connect.h | 4 +- code/netdlg2.cpp | 18 +-- code/netshare.cpp | 8 +- code/queue.cpp | 112 +----------------- code/wsproto.cpp | 35 ------ code/wsproto.h | 1 - manual/changes/adaptive-network-timing.md | 17 ++- manual/changes/network-packet-validation.md | 22 +++- .../systems/network-synchronization.md | 80 +++++++++---- 9 files changed, 107 insertions(+), 190 deletions(-) diff --git a/code/connect.h b/code/connect.h index 20ddbde..3c5070b 100644 --- a/code/connect.h +++ b/code/connect.h @@ -300,9 +300,7 @@ class ConnectionClass .....................................................................*/ unsigned int Timeout; - /*..................................................................... - The adaptive retry estimator and its monotonic millisecond clock. - .....................................................................*/ + // An injected clock must outlive the connection. NetTiming::MillisecondClock const *MillisecondTime; NetTiming::RttEstimator RoundTripEstimator; diff --git a/code/netdlg2.cpp b/code/netdlg2.cpp index da9fabf..aaedad9 100644 --- a/code/netdlg2.cpp +++ b/code/netdlg2.cpp @@ -302,7 +302,7 @@ void Net2ServiceGameList(void) Net2DisplayUsers(); } else if (TickCount - Session.Chat[i]->Chat.LastTime > 5 * TIMER_SECOND && Session.Chat[i]->Chat.LastChance == 0) { - GlobalPacketType packet = {}; + GlobalPacketType packet; memset (&packet, 0, sizeof(GlobalPacketType)); strcpy(packet.Name, Session.Handle); packet.Command = NET_CHAT_REQUEST; @@ -748,7 +748,7 @@ bool Net2Remote_Connect(void) // If I'm not joined to a game, send a SIGN_OFF to all players // in my Chat vector (but not to myself, index 0) //............................................................... - GlobalPacketType gpacket = {}; + GlobalPacketType gpacket; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_SIGN_OFF; strcpy(gpacket.Name, Session.Handle); @@ -976,7 +976,7 @@ bool Net2Remote_Connect(void) // Send all players the NET_GO packet. Wait until all ACK's have been // received. //..................................................................... - GlobalPacketType gpacket = {}; + GlobalPacketType gpacket; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_GO; gpacket.ResponseTime.OneWay = Session.MaxAhead; @@ -1150,7 +1150,7 @@ BOOL CALLBACK MPlayer_Game_List_Dialog_Proc(HWND window, UINT message, WPARAM wp PMessagePrintf(ColorMe, "[%s] %s", Session.Handle, text); - GlobalPacketType gpacket = {}; + GlobalPacketType gpacket; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_MESSAGE; @@ -1543,7 +1543,7 @@ BOOL CALLBACK MPlayer_Host_Dialog_Proc(HWND window, UINT message, WPARAM wparam, PMessagePrintf(ColorMe, "[%s] %s", Session.Handle, text); - GlobalPacketType gpacket = {}; + GlobalPacketType gpacket; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_MESSAGE; @@ -1978,7 +1978,7 @@ static int Request_To_Join(int join_index) static void Unjoin_Game(int game_index) { int i; - GlobalPacketType packet = {}; + GlobalPacketType packet; //------------------------------------------------------------------------ // Fill in a SIGN_OFF packet @@ -2554,7 +2554,7 @@ static void Get_Join_Responses(void) // properly removed from their dialogs. //..................................................................... if ( JoinState == JOIN_CONFIRMED) { - GlobalPacketType packet = {}; + GlobalPacketType packet; memset (&packet, 0, sizeof(GlobalPacketType)); packet.Command = NET_SIGN_OFF; @@ -2918,7 +2918,7 @@ static void Get_Join_Responses(void) //------------------------------------------------------------------------ if (Session.GPacket.Command==NET_CHAT_REQUEST) { if (JoinState != JOIN_WAIT_CONFIRM && JoinState != JOIN_CONFIRMED) { - GlobalPacketType packet = {}; + GlobalPacketType packet; memset (&packet, 0, sizeof(GlobalPacketType)); @@ -3382,7 +3382,7 @@ BOOL CALLBACK MPlayer_Guest_Dialog_Proc(HWND window, UINT message, WPARAM wparam if (len > 2) { PMessagePrintf(ColorMe, "[%s] %s", Session.Handle, text); - GlobalPacketType gpacket = {}; + GlobalPacketType gpacket; memset(&gpacket, 0, sizeof(gpacket)); gpacket.Command = NET_MESSAGE; diff --git a/code/netshare.cpp b/code/netshare.cpp index ca5adf2..694e5b3 100644 --- a/code/netshare.cpp +++ b/code/netshare.cpp @@ -662,7 +662,7 @@ void PumpGameopts(bool force, bool now) /// The encoded option string to send. void SendPublicGameopts(char const * options) { - GlobalPacketType packet = {}; + GlobalPacketType packet; memset(&packet, 0, sizeof(packet)); packet.Command = NET_PUB_GAMEOPT; strcpy(packet.Name, Session.Handle); @@ -1297,7 +1297,7 @@ void Update_Network_Dialog_Preview(HWND win) switch (Session.Type) { case GAME_IPX: if (WS_Top_Window_ID() == IDD_MPLAYER_GUEST && !Find_Local_Scenario(Session.ScenarioFileName, Session.ScenarioFileLength, Session.ScenarioDigest, Session.ScenarioIsOfficial)) { - GlobalPacketType packet = {}; + GlobalPacketType packet; memset(&packet, 0, sizeof(packet)); packet.Command = NET_REQ_PREVIEW; while (true) { @@ -1346,7 +1346,7 @@ void Receive_Random_Map_Preview(void) Ipx.Set_Timing(50, -1, 5000); DebugString("Starting map preview download\n"); - GlobalPacketType packet = {}; + GlobalPacketType packet; memset(&packet, 0, sizeof(packet)); packet.Command = NET_PREVIEW_ACK; DebugString("Sending preview mode acks\n"); @@ -1454,7 +1454,7 @@ void Send_Preview_To_Guests(void) if (MultiplayerMapPreview != NULL && stricmp(Session.ScenarioFileName, RANDOM_MAP_FILE_NAME) == 0 && Session.Players.Count() > 1) { DebugString("Starting map preview upload\n"); - GlobalPacketType packet = {}; + GlobalPacketType packet; memset(&packet, 0, sizeof(packet)); packet.Command = NET_PREVIEW_MODE; strcpy(packet.Name, Session.Handle); diff --git a/code/queue.cpp b/code/queue.cpp index fb878f4..3e47115 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -53,6 +53,9 @@ * Build_Send_Packet -- Builds a big packet from a bunch of little ones. * * Add_Uncompressed_Events -- adds uncompressed events to a packet * * Add_Compressed_Events -- adds compressed events to a packet * + * Breakup_Receive_Packet -- Splits a big packet into little ones. * + * Extract_Uncompressed_Events -- extracts events from a packet * + * Extract_Compressed_Events -- extracts events from a packet * * * * DoList Management: * * Execute_DoList -- Executes commands from the DoList * @@ -299,7 +302,6 @@ 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 multi_packet_max, int my_sent, FrameSyncStruct *their); -static void Generate_Timing_Event(ConnManClass *net, int my_sent); static void Generate_Real_Timing_Event(void); static void Generate_Network_Report_Event(ConnManClass *net); static int Process_Send_Period(ConnManClass *net); //, int init); @@ -829,22 +831,7 @@ static void Queue_AI_Multiplayer(void) //------------------------------------------------------------------------ else if (Frame % NetTiming::REPORT_INTERVAL == 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) { - - // Every peer reports its processing time and worst local RTT. - Generate_Network_Report_Event(net); - - //} else { - // // - // // For the older protocols, do the old broken timing handling. - // // - // Generate_Timing_Event(net, SentCommandCount); - // } + Generate_Network_Report_Event(net); } // The deterministic master periodically evaluates the shared reports. @@ -1462,81 +1449,6 @@ 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) -{ - 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); - } - } - -} // end of Generate_Timing_Event - - /// Maps the validated game-speed setting to its historical frame-rate target. static int Game_Speed_Frame_Rate(void) { @@ -1553,21 +1465,7 @@ static int Game_Speed_Frame_Rate(void) } -/*************************************************************************** - * Generate_Real_Timing_Event -- Generates a TIMING event * - * * - * INPUT: * - * none. * - * * - * OUTPUT: * - * none. * - * * - * WARNINGS: * - * Only the deterministic session master may call this routine. * - * * - * HISTORY: * - * 07/02/1996 BRR : Created. * - *=========================================================================*/ +/// Queues timing selected from the synchronized report census. static void Generate_Real_Timing_Event(void) { EventClass event; diff --git a/code/wsproto.cpp b/code/wsproto.cpp index 085283f..77ba4f4 100644 --- a/code/wsproto.cpp +++ b/code/wsproto.cpp @@ -431,41 +431,6 @@ void WinsockInterfaceClass::Build_Packet_CRC(WinsockBufferType * packet) } -/*********************************************************************************************** - * 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) -{ - fw_assert (packet->InUse); - fw_assert (packet->BufferLen <= WS_INTERNET_BUFFER_LEN); - - if (packet->BufferLen <= 0 || packet->BufferLen > WS_INTERNET_BUFFER_LEN) { - return(false); - } - - unsigned int crc = Calculate_Packet_CRC(packet->Buffer, packet->BufferLen); - - if (crc == packet->CRC) { - return(true); - } - - fw_assert (crc == packet->CRC); - DebugString("Error in Winsock packet CRC\n"); - return(false); -} - - /// Calculates the transport checksum. unsigned int WinsockInterfaceClass::Calculate_Packet_CRC(void const * buffer, int buffer_len) const { diff --git a/code/wsproto.h b/code/wsproto.h index acbf170..5ed1705 100644 --- a/code/wsproto.h +++ b/code/wsproto.h @@ -186,7 +186,6 @@ 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); diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md index 4b5e751..868c2be 100644 --- a/manual/changes/adaptive-network-timing.md +++ b/manual/changes/adaptive-network-timing.md @@ -15,7 +15,20 @@ links stand for the whole match. Healthy links use their own retransmission timers, and the synchronized command delay can return toward a more responsive setting after a temporary slowdown clears. +Compressed matches begin with a three-frame send period and nine-frame +look-ahead. Each timing report records process time and optional round-trip +time together. A player receives an initial grace period while no round-trip +sample exists; a later missing or expired established sample selects the most +conservative timing. Accepted departure removes the player's whole report. + +Timing reductions drain the previous scheduling horizon, switch on a frame +shared by the old and new send periods, and then reduce temporary look-ahead by +one new send period at each send boundary. A new target safely replaces that +transition. A successor master inherits the synchronized target and exhausted +change budget, then starts with fresh improvement evidence and a cooldown. + Timing reports extend the network and multiplayer-recording event stream. Run every player with the same OpenTS snapshot and play a recording with the -snapshot that created it. Timing events carry the selected look-ahead directly; -fog of war no longer adds an offset. There is no configuration to migrate. +snapshot that created it. Existing event IDs and packet layouts are unchanged; +timing events carry the selected look-ahead directly, and fog of war adds no +offset. There is no configuration to migrate. diff --git a/manual/changes/network-packet-validation.md b/manual/changes/network-packet-validation.md index 619fd22..a4beaaa 100644 --- a/manual/changes/network-packet-validation.md +++ b/manual/changes/network-packet-validation.md @@ -14,10 +14,22 @@ could crash or corrupt. A rejected command packet can still make an uncooperative peer stall a lockstep match, but it cannot make the receiver use bytes outside that packet. +The connection that delivered a command owns its event origin. Power, +archive-target, repair, primary-factory, mission, idle, deploy, scatter, and +sell commands also require their object to belong to the sender when the +command executes, so capturing it while the command is in flight does not +transfer control. Network timing events require the session master. Player +removal requires either the current master or, when the master is leaving, the +first remaining human player in house order. + In-game chat, progress, sign-off, ready, and kick packets now have to come from -an address in the match's player list. Chat identity and kick votes are taken -from that membership record, so changing the corresponding fields in a packet -cannot impersonate another player. +one uniquely matched address in the match's player list. An exact IP address +and port wins; one same-IP entry with a stored port of zero is the legacy +fallback. Ambiguous matches are rejected. Chat identity and kick votes are +taken from that membership record, so changing the corresponding fields in a +packet cannot impersonate another player. -The packet layout used before this change remains accepted. All players should -still use the same OpenTS snapshot. +Address matching is roster attribution, not authentication: this global sender +check neither pins nor updates the matched address, and a participant can still +disrupt a lockstep match. These checks do not change the existing packet +layouts or event IDs. All players should still use the same OpenTS snapshot. diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index 02da28c..8c07fb7 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -13,22 +13,39 @@ available for delivery and the delay before a player's command takes effect. ## Packet admission -Every packet passes through bounded transport, connection, and event decoders -before it can change the simulation. The receiver checks the packet envelope, -the declared event sizes, the complete event stream, and the player identity of -the connection that delivered it. A malformed, truncated, oversized, or +Every synchronized-event packet passes through bounded transport, connection, +and event decoders before it can change the simulation. Its envelope is the +leading `FRAMEINFO` or sole `FRAMESYNC` record that carries the sender and frame +information for the packet. The receiver checks that envelope, the declared +event sizes, the complete event stream, and the player identity of the +connection that delivered it. A malformed, truncated, oversized, or misattributed packet is discarded as one packet; no events from it enter the simulation queue. +The connection identity becomes the origin of every compressed event in that +packet. Object commands for power, archive targets, repair, primary factories, +missions, idle, deployment, scattering, and selling also require the resolved +object to belong to that origin when the event executes. A missing or destroyed +object remains a no-op; an object captured before execution is rejected. + +Only the resolved session master may change timing. The master may remove any +other player. When the master is the player being removed, the first remaining +network-human house in house order becomes the removal authority. Every +machine recomputes that rule when the removal event executes, and a repeated +removal has no further effect. + Public game and player discovery still accepts queries from outside the session. Once a match is running, chat, loading progress, sign-off, ready, and -kick-control packets are accepted only from an address recorded in the player -list. The recorded player identity, rather than the name or voter claimed by -the packet, owns that action. +kick-control packets require one unique player-list endpoint. Resolution first +looks for an exact IP address and port. If none exists, it accepts one same-IP +entry whose stored port is zero; duplicate exact or fallback matches are +rejected. The recorded player identity, rather than the name or voter claimed +by the packet, owns that action. The packet checksum detects damaged bytes. It is not authentication or -encryption, and a network game still assumes that its players and the network -path carrying their traffic are trusted. +encryption. Global sender resolution does not pin or update the matched roster +address, and a network game still assumes that its players and the path carrying +their traffic are trusted. ## Link measurement and retransmission @@ -41,23 +58,38 @@ other link in the match. ## Match timing -Every player periodically reports two bounded measurements: the processing -time of its simulation frames and the worst round-trip time among its own -connections. The deterministic session master combines the reports, chooses -one timing rung, and sends the resulting frame rate, send period, and -look-ahead as a synchronized event. Reports from other players can influence -that decision, but a timing event sent by any of them is ignored. +Every player periodically reports two bounded measurements as one record: the +processing time of its simulation frames and an optional worst round-trip time +among its active connections. The deterministic session master combines fresh +reports, chooses one timing rung, and sends the resulting frame rate, send +period, and look-ahead as a synchronized event. Reports from other players can +influence that decision, but a timing event sent by any of them is ignored. The match begins with commands sent every three frames and a nine-frame -look-ahead. A worse measured path can move directly to a more conservative -rung. Returning toward a more responsive rung requires sustained headroom and -moves one rung at a time, so short spikes do not make the timing oscillate. A -decrease waits until commands scheduled with the previous look-ahead have -cleared that horizon. - -A connected player whose established report expires is treated -conservatively. Removing that player removes its report as well, allowing the -remaining links to determine later timing decisions. +look-ahead; a guest accepts a compressed start only when the host advertises +that same look-ahead. A worse measured path can move directly to a more +conservative rung. Returning toward a more responsive rung requires sustained +headroom and moves one rung at a time, so short spikes do not make the timing +oscillate. A decrease waits until commands scheduled with the previous +look-ahead have cleared that horizon. It activates on a frame aligned to both +send periods, uses a temporary look-ahead that preserves the next command +target, and then removes one new send period at each later send boundary until +it reaches the requested value. A replacement target rebases the remaining +transition. + +A player without an initial round-trip sample receives a 512-frame grace +period. A missing or expired sample after one was established is conservative +immediately; a sample that never becomes available is conservative when that +grace expires. Process time expires with the same report, and incomplete +process data retains the last synchronized frame rate. Timing membership begins +from the seated roster. An authorized removal clears the departing player's +whole report, allowing the remaining links to determine later decisions. + +If the master leaves, its successor inherits the authoritative timing target +and the saturated eight-change budget. The successor discards prior improvement +evidence and begins with a cooldown instead of restarting from the initial +timing state. Once that budget is exhausted, later measurements can still make +timing more conservative but cannot reduce its rung or look-ahead. The latency-margin setting keeps its existing four steps. They apply one, one-and-a-half, two, or three times the measured round trip before a rung is From 2f73e1b2163cbcc20f6d7877b4e3c6177e584adb Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 22:04:38 +0300 Subject: [PATCH 10/13] Tighten network synchronization documentation --- manual/changes/adaptive-network-timing.md | 32 ++--- manual/changes/network-packet-validation.md | 35 ++---- .../systems/network-synchronization.md | 109 ++++++------------ 3 files changed, 60 insertions(+), 116 deletions(-) diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md index 868c2be..8dd4be8 100644 --- a/manual/changes/adaptive-network-timing.md +++ b/manual/changes/adaptive-network-timing.md @@ -10,25 +10,17 @@ credit: - ZivDero --- -Network games measure every peer-to-peer path instead of letting the host's own -links stand for the whole match. Healthy links use their own retransmission -timers, and the synchronized command delay can return toward a more responsive -setting after a temporary slowdown clears. +Each connection measures its own round trip and retry timing. Peers report +process time and optional RTT together, letting the deterministic master adapt +the shared command delay from the whole match. Compressed games start at a +three-frame send period and nine-frame look-ahead; missing established reports +select conservative timing. -Compressed matches begin with a three-frame send period and nine-frame -look-ahead. Each timing report records process time and optional round-trip -time together. A player receives an initial grace period while no round-trip -sample exists; a later missing or expired established sample selects the most -conservative timing. Accepted departure removes the player's whole report. +Reductions drain the old scheduling horizon and step down at aligned send +boundaries. A successor master inherits the synchronized target and change +budget before restarting improvement hysteresis. -Timing reductions drain the previous scheduling horizon, switch on a frame -shared by the old and new send periods, and then reduce temporary look-ahead by -one new send period at each send boundary. A new target safely replaces that -transition. A successor master inherits the synchronized target and exhausted -change budget, then starts with fresh improvement evidence and a cooldown. - -Timing reports extend the network and multiplayer-recording event stream. Run -every player with the same OpenTS snapshot and play a recording with the -snapshot that created it. Existing event IDs and packet layouts are unchanged; -timing events carry the selected look-ahead directly, and fog of war adds no -offset. There is no configuration to migrate. +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 index a4beaaa..aa0b829 100644 --- a/manual/changes/network-packet-validation.md +++ b/manual/changes/network-packet-validation.md @@ -7,29 +7,16 @@ credit: - ZivDero --- -Malformed network traffic is rejected before it can enter the simulation. -Undersized and oversized envelopes, truncated events, invalid indices, and -packets claiming another player's identity no longer reach the state they -could crash or corrupt. A rejected command packet can still make an -uncooperative peer stall a lockstep match, but it cannot make the receiver use -bytes outside that packet. +Malformed, oversized, truncated, and misattributed network packets are rejected +before they change peer state or enter the simulation queue. -The connection that delivered a command owns its event origin. Power, -archive-target, repair, primary-factory, mission, idle, deploy, scatter, and -sell commands also require their object to belong to the sender when the -command executes, so capturing it while the command is in flight does not -transfer control. Network timing events require the session master. Player -removal requires either the current master or, when the master is leaving, the -first remaining human player in house order. +Private events inherit the delivering connection's identity. Covered object +commands require the object to still belong to that sender; network timing is +master-only, and player removal uses deterministic master-or-successor +authority. -In-game chat, progress, sign-off, ready, and kick packets now have to come from -one uniquely matched address in the match's player list. An exact IP address -and port wins; one same-IP entry with a stored port of zero is the legacy -fallback. Ambiguous matches are rejected. Chat identity and kick votes are -taken from that membership record, so changing the corresponding fields in a -packet cannot impersonate another player. - -Address matching is roster attribution, not authentication: this global sender -check neither pins nor updates the matched address, and a participant can still -disrupt a lockstep match. These checks do not change the existing packet -layouts or event IDs. All players should still use the same OpenTS snapshot. +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 it does not change existing packet layouts or event IDs. All players must +use the same OpenTS snapshot. diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index 8c07fb7..7c4ccae 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -14,87 +14,52 @@ 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 before it can change the simulation. Its envelope is the -leading `FRAMEINFO` or sole `FRAMESYNC` record that carries the sender and frame -information for the packet. The receiver checks that envelope, the declared -event sizes, the complete event stream, and the player identity of the -connection that delivered it. A malformed, truncated, oversized, or -misattributed packet is discarded as one packet; no events from it enter the -simulation queue. +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. -The connection identity becomes the origin of every compressed event in that -packet. Object commands for power, archive targets, repair, primary factories, -missions, idle, deployment, scattering, and selling also require the resolved -object to belong to that origin when the event executes. A missing or destroyed -object remains a no-op; an object captured before execution is rejected. +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. -Only the resolved session master may change timing. The master may remove any -other player. When the master is the player being removed, the first remaining -network-human house in house order becomes the removal authority. Every -machine recomputes that rule when the removal event executes, and a repeated -removal has no further effect. +Network timing is master-only. The master removes other players; the first +remaining network-human house in house order removes a departing master. Each +machine recomputes that authority when the event executes. -Public game and player discovery still accepts queries from outside the -session. Once a match is running, chat, loading progress, sign-off, ready, and -kick-control packets require one unique player-list endpoint. Resolution first -looks for an exact IP address and port. If none exists, it accepts one same-IP -entry whose stored port is zero; duplicate exact or fallback matches are -rejected. The recorded player identity, rather than the name or voter claimed -by the packet, owns that action. - -The packet checksum detects damaged bytes. It is not authentication or -encryption. Global sender resolution does not pin or update the matched roster -address, and a network game still assumes that its players and the path carrying -their traffic are trusted. +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. ## Link measurement and retransmission -Each connection measures its own round-trip time. An acknowledgement measures -the link only when its packet was transmitted once, because an acknowledgement -after a retry cannot identify which transmission it answers. Lost packets use -progressively longer retry intervals, while a healthy connection keeps the -interval derived from its own measurements instead of inheriting the slowest -other link in the match. +Each connection estimates its own round trip. Only first-transmission +acknowledgements contribute samples; retries use exponential backoff. One slow +link therefore does not set every connection's retry interval. ## Match timing -Every player periodically reports two bounded measurements as one record: the -processing time of its simulation frames and an optional worst round-trip time -among its active connections. The deterministic session master combines fresh -reports, chooses one timing rung, and sends the resulting frame rate, send -period, and look-ahead as a synchronized event. Reports from other players can -influence that decision, but a timing event sent by any of them is ignored. - -The match begins with commands sent every three frames and a nine-frame -look-ahead; a guest accepts a compressed start only when the host advertises -that same look-ahead. A worse measured path can move directly to a more -conservative rung. Returning toward a more responsive rung requires sustained -headroom and moves one rung at a time, so short spikes do not make the timing -oscillate. A decrease waits until commands scheduled with the previous -look-ahead have cleared that horizon. It activates on a frame aligned to both -send periods, uses a temporary look-ahead that preserves the next command -target, and then removes one new send period at each later send boundary until -it reaches the requested value. A replacement target rebases the remaining -transition. - -A player without an initial round-trip sample receives a 512-frame grace -period. A missing or expired sample after one was established is conservative -immediately; a sample that never becomes available is conservative when that -grace expires. Process time expires with the same report, and incomplete -process data retains the last synchronized frame rate. Timing membership begins -from the seated roster. An authorized removal clears the departing player's -whole report, allowing the remaining links to determine later decisions. - -If the master leaves, its successor inherits the authoritative timing target -and the saturated eight-change budget. The successor discards prior improvement -evidence and begins with a cooldown instead of restarting from the initial -timing state. Once that budget is exhausted, later measurements can still make -timing more conservative but cannot reduce its rung or look-ahead. - -The latency-margin setting keeps its existing four steps. They apply one, -one-and-a-half, two, or three times the measured round trip before a rung is -chosen. The game-speed setting also keeps its existing frame-rate mapping, -including speed zero as 60 frames per second. +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 authorized removal clears +the player's report. + +Compressed matches start at send rate 3 with nine frames of look-ahead. Worse +conditions apply immediately. Improvement needs three evaluations with 20% +headroom and a cooldown, moving one rung at a time. After eight changes, only +conservative increases remain. + +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 and change count, then clears +improvement evidence and starts a cooldown. + +Latency margin remains 1×, 1.5×, 2×, or 3× RTT. Game speed keeps its existing +mapping, including speed zero as 60 FPS. ## Compatibility From 3f31c6daf16aa53158009371189fa38359b32f30 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Sun, 30 Aug 2026 23:20:34 +0300 Subject: [PATCH 11/13] Show adaptive connection quality --- code/event.cpp | 12 ++++ code/goptions.cpp | 58 +++++++++---------- code/goptions.h | 2 + code/language/language.h | 1 + code/language/language.rc | 1 + code/nettiming.cpp | 19 ++++++ code/nettiming.h | 8 +++ manual/changes/adaptive-network-timing.md | 5 ++ .../systems/network-synchronization.md | 14 +++-- tests/nettiming/nettiming.cpp | 22 +++++++ 10 files changed, 107 insertions(+), 35 deletions(-) diff --git a/code/event.cpp b/code/event.cpp index 9e2380f..3a58b8f 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -1256,6 +1256,7 @@ void EventClass::Execute(void) 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; @@ -1265,6 +1266,17 @@ void EventClass::Execute(void) 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) // // If MaxAhead is about to increase, we're vulnerable to a Packet- diff --git a/code/goptions.cpp b/code/goptions.cpp index c22beb6..c4bacbe 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 c090f7a..a2c69c1 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/language/language.h b/code/language/language.h index e2eddd6..1e90a75 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 af1c818..f467398 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/nettiming.cpp b/code/nettiming.cpp index 728dc3f..bc14151 100644 --- a/code/nettiming.cpp +++ b/code/nettiming.cpp @@ -142,6 +142,25 @@ namespace NetTiming } + /// 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) { diff --git a/code/nettiming.h b/code/nettiming.h index 7e82149..212d3c1 100644 --- a/code/nettiming.h +++ b/code/nettiming.h @@ -78,7 +78,15 @@ namespace NetTiming 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); Milliseconds Apply_Latency_Fudge(Milliseconds round_trip, LatencyFudge fudge); diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md index 8dd4be8..ab80ae4 100644 --- a/manual/changes/adaptive-network-timing.md +++ b/manual/changes/adaptive-network-timing.md @@ -20,6 +20,11 @@ Reductions drain the old scheduling horizon and step down at aligned send boundaries. A successor master inherits the synchronized target and change budget before restarting improvement hysteresis. +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; the menu no longer sends manual `LATENCYFUDGE` changes, and +new matches start with a 1× RTT margin. + 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 diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index 7c4ccae..03f45b2 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -58,12 +58,18 @@ one new send period at each boundary. Replacement targets rebase this process. A successor master inherits the target and change count, then clears improvement evidence and starts a cooldown. -Latency margin remains 1×, 1.5×, 2×, or 3× RTT. Game speed keeps its existing -mapping, including speed zero as 60 FPS. +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. + +New matches start with a 1× RTT margin. The menu no longer emits the legacy +`LATENCYFUDGE` event. ## 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. There is no player-facing timing setting to -migrate. +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/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index b028067..1797cf1 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -346,6 +346,27 @@ namespace } + 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("initial 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; @@ -721,6 +742,7 @@ int main(void) Test_Loss_Jitter_And_Reordering(); Test_Census(); Test_Rungs_And_Fudge(); + Test_Connection_Quality(); Test_Event_Semantics(); Test_Hysteresis_And_Cooldown(); Test_Stale_And_Transition_Budget(); From 00af20f3e47e617567fbbf3fd356d0d2854da4c3 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 00:03:42 +0300 Subject: [PATCH 12/13] Start adaptive timing with a faster bootstrap --- code/nettiming.cpp | 57 +++++- code/nettiming.h | 21 ++- code/queue.cpp | 10 +- code/session.cpp | 2 +- manual/changes/adaptive-network-timing.md | 9 +- .../systems/network-synchronization.md | 11 +- tests/nettiming/nettiming.cpp | 176 ++++++++++++++++-- 7 files changed, 250 insertions(+), 36 deletions(-) diff --git a/code/nettiming.cpp b/code/nettiming.cpp index bc14151..36575bd 100644 --- a/code/nettiming.cpp +++ b/code/nettiming.cpp @@ -263,6 +263,22 @@ namespace NetTiming } + /// 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) { @@ -361,16 +377,18 @@ namespace NetTiming /// Restores the balanced policy's initial state. - void BalancedTimingPolicy::Reset(void) + void BalancedTimingPolicy::Reset(std::uint32_t frame) { CurrentRung = INITIAL_TIMING_RUNG; CurrentSettings = Settings_For_Rung(INITIAL_TIMING_RUNG); GoodEvaluations = 0; ReversibleChanges = 0; - LastEvaluationFrame = 0; + BootstrapStartFrame = frame; + LastEvaluationFrame = frame; LastChangeFrame = 0; HasEvaluated = false; HasChanged = false; + Bootstrapping = true; } @@ -385,6 +403,7 @@ namespace NetTiming LastChangeFrame = frame; HasEvaluated = true; HasChanged = true; + Bootstrapping = false; } @@ -402,10 +421,44 @@ namespace NetTiming } + /// 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 the change budget. TimingEvaluation BalancedTimingPolicy::Evaluate(TimingCensus const & census, unsigned int target_fps, LatencyFudge fudge, 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, fudge, false) + : complete ? Desired_Settings(census, target_fps, fudge, 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); } diff --git a/code/nettiming.h b/code/nettiming.h index 212d3c1..27b0abd 100644 --- a/code/nettiming.h +++ b/code/nettiming.h @@ -30,9 +30,13 @@ namespace NetTiming 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 = 3; + 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; @@ -72,8 +76,8 @@ namespace NetTiming unsigned int prior_retransmissions, Milliseconds maximum_delay = MAXIMUM_RTO); struct TimingSettings { - unsigned int FrameSendRate = 3; - unsigned int MaxAhead = 9; + unsigned int FrameSendRate = 2; + unsigned int MaxAhead = 6; bool operator==(TimingSettings const &) const = default; }; @@ -93,6 +97,8 @@ namespace NetTiming 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, LatencyFudge fudge, bool require_headroom = false); unsigned int Select_Timing_Rung(Milliseconds worst_round_trip, unsigned int target_fps, LatencyFudge fudge, 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; @@ -141,7 +147,7 @@ namespace NetTiming class BalancedTimingPolicy { public: - void Reset(void); + void Reset(std::uint32_t frame = 0); void Reset_From(TimingSettings settings, unsigned int reversible_changes, std::uint32_t frame); TimingEvaluation Evaluate(TimingCensus const & census, unsigned int target_fps, LatencyFudge fudge, std::uint32_t frame); @@ -149,18 +155,23 @@ namespace NetTiming TimingSettings Current_Settings(void) const {return(CurrentSettings);} unsigned int Reversible_Changes(void) const {return(ReversibleChanges);} 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 = {3, 9}; + TimingSettings CurrentSettings = {2, 6}; unsigned int GoodEvaluations = 0; unsigned int ReversibleChanges = 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 { diff --git a/code/queue.cpp b/code/queue.cpp index 3e47115..9af77c5 100644 --- a/code/queue.cpp +++ b/code/queue.cpp @@ -730,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 @@ -827,16 +829,16 @@ 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 % NetTiming::REPORT_INTERVAL == 0) { + else if (Frame > 0 && NetTiming::Report_Is_Due(network_timing_frame)) { Generate_Network_Report_Event(net); } - // The deterministic master periodically evaluates the shared reports. + // 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 % NetTiming::EVALUATION_INTERVAL == 0) { + if (PlayerPtr != NULL && PlayerPtr->HeapID == timing_master && Frame > 0 && NetTiming::Evaluation_Is_Due(network_timing_frame)) { Generate_Real_Timing_Event(); } diff --git a/code/session.cpp b/code/session.cpp index a8685cd..bac7b1f 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -498,7 +498,7 @@ void SessionClass::Reset_Network_Timing(unsigned int frame) MaxMaxAhead = MaxAhead; } NetworkTimingReports.Reset(); - NetworkTimingPolicy.Reset(); + NetworkTimingPolicy.Reset(frame); PendingNetworkTiming.reset(); NetworkTimingChangeCount = 0; NetworkTimingPolicyOwner = -1; diff --git a/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md index ab80ae4..df6ba2d 100644 --- a/manual/changes/adaptive-network-timing.md +++ b/manual/changes/adaptive-network-timing.md @@ -10,11 +10,10 @@ credit: - ZivDero --- -Each connection measures its own round trip and retry timing. Peers report -process time and optional RTT together, letting the deterministic master adapt -the shared command delay from the whole match. Compressed games start at a -three-frame send period and nine-frame look-ahead; missing established reports -select conservative timing. +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 diff --git a/manual/content/systems/network-synchronization.md b/manual/content/systems/network-synchronization.md index 03f45b2..0dfab20 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -47,10 +47,13 @@ missing or stale established RTT selects `10/250` immediately. Stale process data retains the last synchronized frame rate, and authorized removal clears the player's report. -Compressed matches start at send rate 3 with nine frames of look-ahead. Worse -conditions apply immediately. Improvement needs three evaluations with 20% -headroom and a cooldown, moving one rung at a time. After eight changes, only -conservative increases remain. +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. After eight changes, only conservative increases remain. 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 diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index 1797cf1..b49e6f6 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -312,8 +312,9 @@ namespace { using namespace NetTiming; - Expect_Equal("initial FSR", Settings_For_Rung(INITIAL_TIMING_RUNG).FrameSendRate, 3u); - Expect_Equal("initial MaxAhead", Settings_For_Rung(INITIAL_TIMING_RUNG).MaxAhead, 9u); + 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))); @@ -360,7 +361,8 @@ namespace 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("initial settings report normal", Connection_Quality_For_Settings({3, 9}) == ConnectionQuality::Normal); + 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); @@ -441,6 +443,144 @@ namespace } + 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, LatencyFudge::None, 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, LatencyFudge::None, 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}); + Expect_Equal("changed bootstrap consumes one transition", low.Reversible_Changes(), 1u); + result = low.Evaluate(low_reports.Inspect(255), 60, LatencyFudge::None, 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, LatencyFudge::None, 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, LatencyFudge::None, false) == TimingSettings{1, 4}); + Expect("100 ms retains 2/6 with bootstrap headroom", + Select_Timing_Settings(100, 60, LatencyFudge::None, 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, LatencyFudge::None, 64); + Expect("marginal bootstrap completes without changing 2/6", result.Evaluated && !result.Changed && !marginal.Is_Bootstrapping()); + Expect_Equal("equal bootstrap target preserves transition budget", marginal.Reversible_Changes(), 0u); + + 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, LatencyFudge::None, 64); + Expect("high-latency bootstrap worsens directly", result.Changed && high.Current_Settings() == TimingSettings{10, 90}); + Expect_Equal("high-latency bootstrap consumes one transition", high.Reversible_Changes(), 1u); + + 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, LatencyFudge::None, 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, LatencyFudge::None, 100); + Expect("completed census waits for frame 128", !result.Evaluated && delayed.Is_Bootstrapping()); + result = delayed.Evaluate(delayed_reports.Inspect(128), 60, LatencyFudge::None, 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, LatencyFudge::None, 64); + incomplete_reports.Record_Report(1, 10, std::nullopt, 70); + result = incomplete.Evaluate(incomplete_reports.Inspect(128), 60, LatencyFudge::None, 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}); + Expect_Equal("fallback consumes one transition", incomplete.Reversible_Changes(), 1u); + + 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, LatencyFudge::None, 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, LatencyFudge::None, 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, LatencyFudge::None, 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, LatencyFudge::None, 1024); + Expect("normal hysteresis resumes after bootstrap cooldown", result.Changed && high.Current_Settings() == TimingSettings{9, 27}); + Expect_Equal("post-bootstrap improvement consumes another transition", high.Reversible_Changes(), 2u); + + high.Reset(); + Expect("reset starts a new bootstrap", high.Is_Bootstrapping()); + Expect("reset restores 2/6", high.Current_Settings() == TimingSettings{2, 6}); + Expect_Equal("reset restores transition budget", high.Reversible_Changes(), 0u); + + BalancedTimingPolicy handoff; + handoff.Reset_From({10, 70}, 5, 0); + Expect("handoff does not regain bootstrap", !handoff.Is_Bootstrapping()); + Record_One(high_reports, 0, 64); + result = handoff.Evaluate(high_reports.Inspect(64), 60, LatencyFudge::None, 64); + Expect("handoff ignores bootstrap evaluation", !result.Evaluated && handoff.Current_Settings() == TimingSettings{10, 70}); + Expect_Equal("handoff preserves its transition budget", handoff.Reversible_Changes(), 5u); + + 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, LatencyFudge::None, 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, LatencyFudge::None, 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; @@ -448,40 +588,42 @@ namespace TimingReportCensus reports; reports.Set_Player_Active(1, true, 0); BalancedTimingPolicy policy; + policy.Reset_From({3, 9}, 0, 0); - Record_One(reports, 0, 0); - TimingEvaluation result = policy.Evaluate(reports.Inspect(0), 60, LatencyFudge::None, 0); - Expect("first good evaluation does not change", !result.Changed); Record_One(reports, 0, 256); - result = policy.Evaluate(reports.Inspect(256), 60, LatencyFudge::None, 256); - Expect("second good evaluation does not change", !result.Changed); + TimingEvaluation result = policy.Evaluate(reports.Inspect(256), 60, LatencyFudge::None, 256); + Expect("first good evaluation does not change", !result.Changed); Record_One(reports, 0, 512); result = policy.Evaluate(reports.Inspect(512), 60, LatencyFudge::None, 512); + Expect("second good evaluation does not change", !result.Changed); + Record_One(reports, 0, 768); + result = policy.Evaluate(reports.Inspect(768), 60, LatencyFudge::None, 768); Expect("third good evaluation improves one rung", result.Changed); Expect_Equal("one-rung improvement", policy.Current_Rung(), 2u); - Record_One(reports, 0, 600); - result = policy.Evaluate(reports.Inspect(600), 60, LatencyFudge::None, 600); + Record_One(reports, 0, 800); + result = policy.Evaluate(reports.Inspect(800), 60, LatencyFudge::None, 800); Expect("evaluation interval enforced", !result.Evaluated); Expect_Equal("cooldown leaves rung", policy.Current_Rung(), 2u); BalancedTimingPolicy headroom; + headroom.Reset_From({3, 9}, 0, 0); TimingReportCensus edge; edge.Set_Player_Active(1, true, 0); - for (std::uint32_t frame : {0u, 256u, 512u}) { + for (std::uint32_t frame : {256u, 512u, 768u}) { Record_One(edge, 120, frame); headroom.Evaluate(edge.Inspect(frame), 60, LatencyFudge::None, frame); } Expect_Equal("20 percent headroom blocks marginal improvement", headroom.Current_Rung(), 3u); - Record_One(reports, 2000, 768); - result = policy.Evaluate(reports.Inspect(768), 60, LatencyFudge::None, 768); + Record_One(reports, 2000, 1024); + result = policy.Evaluate(reports.Inspect(1024), 60, LatencyFudge::None, 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 : {1024u, 1280u, 1536u}) { + for (std::uint32_t frame : {1280u, 1536u, 1792u}) { Record_One(reports, 1300, frame); result = policy.Evaluate(reports.Inspect(frame), 60, LatencyFudge::None, frame); } @@ -498,6 +640,7 @@ namespace TimingReportCensus stale; stale.Set_Player_Active(1, true, 0); BalancedTimingPolicy stale_policy; + stale_policy.Reset_From({3, 9}, 0, 0); TimingEvaluation result = stale_policy.Evaluate(stale.Inspect(0), 60, LatencyFudge::None, 0); Expect("startup waits for a complete census", !result.Changed); Expect_Equal("startup keeps initial rung", stale_policy.Current_Rung(), 3u); @@ -519,7 +662,8 @@ namespace TimingReportCensus reports; reports.Set_Player_Active(1, true, 0); BalancedTimingPolicy policy; - std::uint32_t frame = 0; + policy.Reset_From({3, 9}, 0, 0); + std::uint32_t frame = EVALUATION_INTERVAL; auto evaluate = [&](Milliseconds rtt) { Record_One(reports, rtt, frame); @@ -744,6 +888,8 @@ int main(void) Test_Rungs_And_Fudge(); Test_Connection_Quality(); Test_Event_Semantics(); + Test_Bootstrap_Cadence(); + Test_Bootstrap_Policy(); Test_Hysteresis_And_Cooldown(); Test_Stale_And_Transition_Budget(); Test_Master_Handoff_State(); From 4230f5aa4983bbc2e3a2e54ce9e47d7acbdac140 Mon Sep 17 00:00:00 2001 From: ZivDero Date: Mon, 31 Aug 2026 01:28:36 +0300 Subject: [PATCH 13/13] Simplify network hardening safeguards --- code/connect.cpp | 33 ++-- code/conquer.cpp | 8 + code/event.cpp | 33 ++-- code/ipxmgr.cpp | 62 ------ code/netdlg.cpp | 4 +- code/netsemantic.cpp | 55 +----- code/netsemantic.h | 8 - code/nettime.cpp | 3 +- code/nettiming.cpp | 64 ++---- code/nettiming.h | 18 +- code/session.cpp | 38 +--- code/session.h | 8 +- manual/changes/adaptive-network-timing.md | 8 +- manual/changes/network-packet-validation.md | 11 +- .../systems/network-synchronization.md | 29 +-- tests/nettiming/CMakeLists.txt | 2 +- tests/nettiming/nettiming.cpp | 185 +++++++----------- 17 files changed, 163 insertions(+), 406 deletions(-) diff --git a/code/connect.cpp b/code/connect.cpp index eb9f67c..b6a20b1 100644 --- a/code/connect.cpp +++ b/code/connect.cpp @@ -805,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); @@ -813,17 +823,6 @@ int ConnectionClass::Service_Send_Queue (void) continue; } - // New packets send immediately; retransmissions follow the connection's current timeout. - 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))); - if (send_entry->SendCount != 0 && timeout_enabled && NetTiming::Milliseconds_Have_Elapsed(send_entry->FirstTimeMilliseconds, current_milliseconds, connection_timeout)) { bad_conn = 1; @@ -831,11 +830,14 @@ int ConnectionClass::Service_Send_Queue (void) continue; } - NetTiming::Milliseconds const retry_timeout = send_entry->SendCount == 0 - ? (adaptive_timing ? RoundTripEstimator.Retransmit_Timeout() : Ticks_To_Milliseconds(RetryDelta)) : send_entry->RetransmitTimeoutMilliseconds; + 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; - if (send_entry->SendCount == 0 || - NetTiming::Retransmit_Is_Due(send_entry->LastTimeMilliseconds, current_milliseconds, retry_timeout, prior_retransmissions, connection_timeout)) { + // 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 @@ -846,7 +848,6 @@ int ConnectionClass::Service_Send_Queue (void) /*.................................................................. Fill in Time fields ..................................................................*/ - curtime = Time(); send_entry->LastTime = curtime; send_entry->LastTimeMilliseconds = current_milliseconds; if (send_entry->SendCount==0) { diff --git a/code/conquer.cpp b/code/conquer.cpp index 36eec57..563613f 100644 --- a/code/conquer.cpp +++ b/code/conquer.cpp @@ -657,7 +657,15 @@ void IPX_Call_Back(void) 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; diff --git a/code/event.cpp b/code/event.cpp index 3a58b8f..8727b40 100644 --- a/code/event.cpp +++ b/code/event.cpp @@ -91,7 +91,6 @@ namespace { InvalidRemovedHouse, InvalidLatencyFudge, UnauthorizedSubject, - UnauthorizedRemoval, UnauthorizedTiming, InvalidTimingArithmetic, InvalidTimingValues, @@ -111,7 +110,6 @@ namespace { "invalid removed house", "invalid latency fudge", "unauthorized subject", - "unauthorized removal", "unauthorized timing", "invalid timing arithmetic", "invalid timing values", @@ -137,8 +135,9 @@ namespace { /// Resolves the object controlled by an ownership-gated event. - TechnoClass * Event_Subject(EventClass const & event) + TechnoClass * Event_Subject(EventClass const & event, bool & requires_ownership) { + requires_ownership = true; switch (event.Type) { case EventClass::POWERON: case EventClass::POWEROFF: @@ -158,6 +157,7 @@ namespace { return(event.Data.MegaMission.Whom.As_Techno()); default: + requires_ownership = false; return(NULL); } } @@ -651,15 +651,18 @@ void EventClass::Execute(void) } HouseClass * house = Houses[ID]; - if ((Session.Type == GAME_IPX || Session.Type == GAME_INTERNET) && NetSemantic::Event_Requires_Owned_Subject(Type)) { - TechnoClass * subject = Event_Subject(*this); - 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; + 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; @@ -1194,11 +1197,6 @@ void EventClass::Execute(void) if (!Houses[index]->Is_Human_Player()) { break; } - if (!Session.Play && ID != Session.Removal_Authority_Player_ID(index)) { - Log_Event_Rejection(EventRejectReason::UnauthorizedRemoval, Type, ID, index); - break; - } - DebugString("Executing REMOVEPLAYER event. Frame is %d\n", ::Frame); Disable_Multiplayer_Saving(); Session.Remove_Network_Timing_Player(index, Frame >= 0 ? static_cast(Frame) : 0u); @@ -1313,7 +1311,6 @@ void EventClass::Execute(void) case NETWORK_REPORT: if (Frame < 0 || - !NetSemantic::Network_Report_Is_Valid(Data.NetworkReport.AverageProcessMilliseconds, Data.NetworkReport.WorstRoundTripMilliseconds) || !Session.Record_Network_Report(ID, Data.NetworkReport.AverageProcessMilliseconds, Data.NetworkReport.WorstRoundTripMilliseconds, (unsigned int)Frame)) { Log_Event_Rejection(EventRejectReason::InvalidNetworkReport, Type, ID, Data.NetworkReport.WorstRoundTripMilliseconds); } diff --git a/code/ipxmgr.cpp b/code/ipxmgr.cpp index 0f8d4cb..433902d 100644 --- a/code/ipxmgr.cpp +++ b/code/ipxmgr.cpp @@ -82,7 +82,6 @@ #include "wspudp.h" #include -#include /*************************************************************************** @@ -1045,53 +1044,6 @@ int IPXManagerClass::Service(void) 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. - */ - int frame_info_size = sizeof(CommHeaderType) + offsetof(EventClass, Data) + size_of(EventClass, Data.FrameInfo); - if (Frame > 8 && packetlen >= frame_info_size) { - if (packet->Code == ConnectionClass::PACKET_DATA_NOACK){ - /* - ** Magic number and packet code are valid. It's probably a C&C packet. - */ - unsigned char event_type; - int id; - memcpy(&event_type, temp_receive_buffer + sizeof(CommHeaderType), sizeof(event_type)); - memcpy(&id, temp_receive_buffer + sizeof(CommHeaderType) + offsetof(EventClass, ID), sizeof(id)); - - /* - ** If this is a framesync packet then grab the address and match it to an existing player. - */ - if (event_type == EventClass::FRAMESYNC) { - 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; - } - } - } - } - } - } - } } } } @@ -1358,14 +1310,7 @@ unsigned int IPXManagerClass::Response_Time(void) std::optional IPXManagerClass::Worst_Local_Round_Trip_MS(void) const { NetTiming::Milliseconds worst = 0; - std::array connected = {}; for (int i = 0; i < NumConnections; i++) { - int const id = Connection[i]->ID; - if (!Session.Is_Network_Timing_Player_Active(id)) { - continue; - } - connected[id] = true; - std::optional const round_trip = Connection[i]->Smoothed_Round_Trip_MS(); if (!round_trip) { return(std::nullopt); @@ -1373,13 +1318,6 @@ std::optional IPXManagerClass::Worst_Local_Round_Trip_M worst = std::max(worst, *round_trip); } - int const local_id = PlayerPtr != NULL ? PlayerPtr->HeapID : -1; - for (unsigned int id = 0; id < connected.size(); id++) { - if (static_cast(id) != local_id && Session.Is_Network_Timing_Player_Active(id) && !connected[id]) { - return(std::nullopt); - } - } - return(worst); } diff --git a/code/netdlg.cpp b/code/netdlg.cpp index 40c36cb..8997e39 100644 --- a/code/netdlg.cpp +++ b/code/netdlg.cpp @@ -231,7 +231,6 @@ void Destroy_Connection(int id, int error) int i; HouseClass *housep; char txt[80]; - int const removal_authority = Session.Removal_Authority_Player_ID(id); housep = Houses[(HousesType)id]; @@ -284,7 +283,8 @@ void Destroy_Connection(int id, int error) //------------------------------------------------------------------------ Ipx.Delete_Connection(id); - if (PlayerPtr != NULL && PlayerPtr->HeapID == removal_authority) { + // Every survivor reports the departure; execution makes later copies no-ops. + if (PlayerPtr != NULL) { OutList.push_back(EventClass(PlayerPtr->HeapID, EventClass::REMOVEPLAYER, id)); } diff --git a/code/netsemantic.cpp b/code/netsemantic.cpp index 4f68383..db01870 100644 --- a/code/netsemantic.cpp +++ b/code/netsemantic.cpp @@ -9,8 +9,6 @@ #include "netsemantic.h" -#include "event.h" - namespace NetSemantic { /// Checks a signed index against a collection size. @@ -20,29 +18,6 @@ namespace NetSemantic } - /// Identifies events whose resolved object must belong to their sender. - bool Event_Requires_Owned_Subject(unsigned int event_type) noexcept - { - switch (event_type) { - case EventClass::POWERON: - case EventClass::POWEROFF: - case EventClass::ARCHIVE: - case EventClass::REPAIR: - case EventClass::PRIMARY: - case EventClass::MEGAMISSION: - case EventClass::MEGAMISSION_F: - case EventClass::IDLE: - case EventClass::DEPLOY: - case EventClass::SCATTER: - case EventClass::SELL: - return(true); - - default: - return(false); - } - } - - /// Checks that a synchronized object's current owner matches its sender. bool Subject_Owner_Is_Valid(int sender, int owner) noexcept { @@ -94,28 +69,7 @@ namespace NetSemantic if (!compressed) { return(true); } - return(frame_send_rate >= NetTiming::MINIMUM_TIMING_RUNG && frame_send_rate <= NetTiming::MAXIMUM_TIMING_RUNG - && delay >= 2 * frame_send_rate && delay % frame_send_rate == 0); - } - - - /// Resolves the synchronized authority for one player removal. - int Removal_Authority(int target, int master, int successor) noexcept - { - if (target < 0 || master < 0) { - return(-1); - } - if (target != master) { - return(master); - } - return(successor >= 0 && successor != target ? successor : -1); - } - - - /// Checks a player-removal sender against the deterministic authority. - bool Removal_Authority_Is_Valid(int sender, int target, int master, int successor) noexcept - { - return(sender != target && sender == Removal_Authority(target, master, successor)); + return(NetTiming::Timing_Transition_Source_Is_Valid({frame_send_rate, delay})); } @@ -130,11 +84,4 @@ namespace NetSemantic return(NetTiming::Timing_Settings_Are_Valid(settings) ? std::optional(settings) : std::nullopt); } - - /// Checks reported process and round-trip times. - bool Network_Report_Is_Valid(std::uint16_t process_milliseconds, std::uint16_t round_trip_milliseconds) noexcept - { - return(process_milliseconds <= NetTiming::MAXIMUM_PROCESS_MILLISECONDS - && (round_trip_milliseconds <= NetTiming::MAXIMUM_REPORTED_RTT || round_trip_milliseconds == UINT16_MAX)); - } } diff --git a/code/netsemantic.h b/code/netsemantic.h index f6c5f05..06eacd8 100644 --- a/code/netsemantic.h +++ b/code/netsemantic.h @@ -20,8 +20,6 @@ namespace NetSemantic { bool Index_Is_Valid(int index, std::size_t count) noexcept; - bool Event_Requires_Owned_Subject(unsigned int event_type) noexcept; - bool Subject_Owner_Is_Valid(int sender, int owner) noexcept; bool Game_Speed_Is_Valid(int game_speed) noexcept; @@ -36,11 +34,5 @@ namespace NetSemantic bool Response_Time_Is_Valid(unsigned int delay, unsigned int minimum_delay, unsigned int frame_send_rate, bool compressed) noexcept; - int Removal_Authority(int target, int master, int successor) noexcept; - - bool Removal_Authority_Is_Valid(int sender, int target, int master, int successor) noexcept; - std::optional Decode_Timing_Settings(std::uint16_t desired_frame_rate, std::uint16_t max_ahead, std::uint8_t frame_send_rate) noexcept; - - bool Network_Report_Is_Valid(std::uint16_t process_milliseconds, std::uint16_t round_trip_milliseconds) noexcept; } diff --git a/code/nettime.cpp b/code/nettime.cpp index 7e4b8f3..a1b82f3 100644 --- a/code/nettime.cpp +++ b/code/nettime.cpp @@ -11,6 +11,7 @@ #include "nettime.h" #include +#include namespace NetTiming @@ -28,7 +29,7 @@ namespace NetTiming /// Reads the system's wrapping millisecond clock. Milliseconds SystemMillisecondClock::Now(void) const { - return(static_cast(GetTickCount())); + return(static_cast(::timeGetTime())); } diff --git a/code/nettiming.cpp b/code/nettiming.cpp index 36575bd..0109993 100644 --- a/code/nettiming.cpp +++ b/code/nettiming.cpp @@ -34,7 +34,7 @@ namespace NetTiming /// Selects timing for the current report census. - TimingSettings Desired_Settings(TimingCensus const & census, unsigned int target_fps, LatencyFudge fudge, bool require_headroom) + TimingSettings Desired_Settings(TimingCensus const & census, unsigned int target_fps, bool require_headroom) { if (census.RequiresConservativeTiming) { return(TimingSettings{MAXIMUM_TIMING_RUNG, MAXIMUM_MAX_AHEAD}); @@ -42,7 +42,7 @@ namespace NetTiming if (census.ActivePlayers == 0) { return(Settings_For_Rung(INITIAL_TIMING_RUNG)); } - return(Select_Timing_Settings(census.WorstRoundTrip, target_fps, fudge, require_headroom)); + return(Select_Timing_Settings(census.WorstRoundTrip, target_fps, require_headroom)); } @@ -179,31 +179,6 @@ namespace NetTiming } - /// Applies the selected RTT safety margin. - Milliseconds Apply_Latency_Fudge(Milliseconds round_trip, LatencyFudge fudge) - { - std::uint64_t numerator = round_trip; - std::uint64_t denominator = 1; - - switch (fudge) { - case LatencyFudge::None: - break; - case LatencyFudge::Half: - numerator *= 3; - denominator = 2; - break; - case LatencyFudge::Double: - numerator *= 2; - break; - case LatencyFudge::Triple: - numerator *= 3; - break; - } - - return(static_cast(std::min(Divide_Round_Up(numerator, denominator), std::numeric_limits::max()))); - } - - /// Rounds a scheduling horizon up to a complete send period. std::optional Align_Max_Ahead(unsigned int required, unsigned int frame_send_rate) { @@ -220,11 +195,11 @@ namespace NetTiming /// Chooses the lowest rung that covers the adjusted RTT. - TimingSettings Select_Timing_Settings(Milliseconds worst_round_trip, unsigned int target_fps, LatencyFudge fudge, bool require_headroom) + 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 = Apply_Latency_Fudge(worst_round_trip, fudge); + std::uint64_t adjusted = worst_round_trip; if (require_headroom) { adjusted = Divide_Round_Up(adjusted * 5, 4); } @@ -256,13 +231,6 @@ namespace NetTiming } - /// Returns the policy rung selected for an adjusted RTT. - unsigned int Select_Timing_Rung(Milliseconds worst_round_trip, unsigned int target_fps, LatencyFudge fudge, bool require_headroom) - { - return(Select_Timing_Settings(worst_round_trip, target_fps, fudge, require_headroom).FrameSendRate); - } - - /// Uses two early reports before settling on the normal cadence. bool Report_Is_Due(std::uint32_t elapsed_frames) { @@ -382,7 +350,6 @@ namespace NetTiming CurrentRung = INITIAL_TIMING_RUNG; CurrentSettings = Settings_For_Rung(INITIAL_TIMING_RUNG); GoodEvaluations = 0; - ReversibleChanges = 0; BootstrapStartFrame = frame; LastEvaluationFrame = frame; LastChangeFrame = 0; @@ -393,12 +360,11 @@ namespace NetTiming /// Restores synchronized policy state after a master handoff. - void BalancedTimingPolicy::Reset_From(TimingSettings settings, unsigned int reversible_changes, std::uint32_t frame) + 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; - ReversibleChanges = std::min(reversible_changes, REVERSIBLE_CHANGE_LIMIT); LastEvaluationFrame = frame; LastChangeFrame = frame; HasEvaluated = true; @@ -415,9 +381,6 @@ namespace NetTiming GoodEvaluations = 0; LastChangeFrame = frame; HasChanged = true; - if (ReversibleChanges < REVERSIBLE_CHANGE_LIMIT) { - ReversibleChanges++; - } } @@ -431,8 +394,8 @@ namespace NetTiming } - /// Applies cadence, hysteresis, and the change budget. - TimingEvaluation BalancedTimingPolicy::Evaluate(TimingCensus const & census, unsigned int target_fps, LatencyFudge fudge, std::uint32_t frame) + /// 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) { @@ -446,8 +409,8 @@ namespace NetTiming 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, fudge, false) - : complete ? Desired_Settings(census, target_fps, fudge, true) : Settings_For_Rung(BOOTSTRAP_FALLBACK_RUNG); + 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; @@ -471,14 +434,13 @@ namespace NetTiming return(result); } - // Worsening is immediate; improvement must clear the headroom, cadence, and change-budget gates. - TimingSettings const desired_settings = Desired_Settings(census, target_fps, fudge, false); + // 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) && ReversibleChanges < REVERSIBLE_CHANGE_LIMIT - && (!HasChanged || frame - LastChangeFrame >= CHANGE_COOLDOWN)) { - TimingSettings const headroom = Desired_Settings(census, target_fps, fudge, 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) { diff --git a/code/nettiming.h b/code/nettiming.h index 27b0abd..cc34b8b 100644 --- a/code/nettiming.h +++ b/code/nettiming.h @@ -42,14 +42,6 @@ namespace NetTiming constexpr std::uint32_t CHANGE_COOLDOWN = 256; constexpr std::uint32_t REPORT_EXPIRY = 512; constexpr unsigned int GOOD_EVALUATIONS_REQUIRED = 3; - constexpr unsigned int REVERSIBLE_CHANGE_LIMIT = 8; - - enum class LatencyFudge : unsigned char { - None, - Half, - Double, - Triple, - }; class RttEstimator { @@ -93,10 +85,8 @@ namespace NetTiming ConnectionQuality Connection_Quality_For_Settings(TimingSettings settings); bool Timing_Settings_Are_Valid(TimingSettings settings); bool Timing_Transition_Source_Is_Valid(TimingSettings settings); - Milliseconds Apply_Latency_Fudge(Milliseconds round_trip, LatencyFudge fudge); 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, LatencyFudge fudge, bool require_headroom = false); - unsigned int Select_Timing_Rung(Milliseconds worst_round_trip, unsigned int target_fps, LatencyFudge fudge, bool require_headroom = false); + 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); @@ -148,12 +138,11 @@ namespace NetTiming { public: void Reset(std::uint32_t frame = 0); - void Reset_From(TimingSettings settings, unsigned int reversible_changes, std::uint32_t frame); - TimingEvaluation Evaluate(TimingCensus const & census, unsigned int target_fps, LatencyFudge fudge, std::uint32_t frame); + 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 Reversible_Changes(void) const {return(ReversibleChanges);} unsigned int Good_Evaluations(void) const {return(GoodEvaluations);} bool Is_Bootstrapping(void) const {return(Bootstrapping);} std::uint32_t Cadence_Origin(void) const {return(BootstrapStartFrame);} @@ -165,7 +154,6 @@ namespace NetTiming unsigned int CurrentRung = INITIAL_TIMING_RUNG; TimingSettings CurrentSettings = {2, 6}; unsigned int GoodEvaluations = 0; - unsigned int ReversibleChanges = 0; std::uint32_t BootstrapStartFrame = 0; std::uint32_t LastEvaluationFrame = 0; std::uint32_t LastChangeFrame = 0; diff --git a/code/session.cpp b/code/session.cpp index bac7b1f..6dfde86 100644 --- a/code/session.cpp +++ b/code/session.cpp @@ -61,7 +61,6 @@ #include "ipxmgr.h" #include "language\language.h" #include "msgloop.h" -#include "netsemantic.h" #include "progress.h" #include "queue.h" #include "rules.h" @@ -456,24 +455,6 @@ int SessionClass::Master_Player_ID(void) const } -/// Returns the player authorized to remove a synchronized peer. -int SessionClass::Removal_Authority_Player_ID(int target) const -{ - int const master = Master_Player_ID(); - int successor = -1; - if (target == master) { - for (int i = 0; i < Houses.Count(); i++) { - HouseClass const * house = Houses[i]; - if (house != NULL && house->HeapID != target && house->IsHuman && Is_Network_Player_ID(house->HeapID)) { - successor = house->HeapID; - break; - } - } - } - return(NetSemantic::Removal_Authority(target, master, successor)); -} - - /// Tests whether a player ID still belongs to the network session. bool SessionClass::Is_Network_Player_ID(int id) const { @@ -500,7 +481,6 @@ void SessionClass::Reset_Network_Timing(unsigned int frame) NetworkTimingReports.Reset(); NetworkTimingPolicy.Reset(frame); PendingNetworkTiming.reset(); - NetworkTimingChangeCount = 0; NetworkTimingPolicyOwner = -1; for (int i = 0; i < Players.Count(); i++) { @@ -516,12 +496,6 @@ void SessionClass::Reset_Network_Timing(unsigned int 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) { - if (!Is_Network_Timing_Player_Active(id) || - process_milliseconds > NetTiming::MAXIMUM_PROCESS_MILLISECONDS || - (round_trip_milliseconds != EventClass::NETWORK_RTT_UNAVAILABLE && round_trip_milliseconds > NetTiming::MAXIMUM_REPORTED_RTT)) { - return(false); - } - std::optional round_trip; if (round_trip_milliseconds != EventClass::NETWORK_RTT_UNAVAILABLE) { round_trip = round_trip_milliseconds; @@ -560,8 +534,7 @@ NetTiming::TimingCensus SessionClass::Network_Timing_Census(unsigned int 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) { - int const fudge = std::clamp(LatencyFudge, 0, 3); - return(NetworkTimingPolicy.Evaluate(census, target_fps, static_cast(fudge), frame)); + return(NetworkTimingPolicy.Evaluate(census, target_fps, frame)); } @@ -579,7 +552,7 @@ void SessionClass::Prepare_Network_Timing_Master(int master_id, unsigned int fra return; } if (NetworkTimingPolicyOwner >= 0 && master_id >= 0) { - NetworkTimingPolicy.Reset_From(Network_Timing_Target(), NetworkTimingChangeCount, frame); + NetworkTimingPolicy.Reset_From(Network_Timing_Target(), frame); } NetworkTimingPolicyOwner = master_id; } @@ -592,7 +565,7 @@ void SessionClass::Apply_Network_Response_Time(unsigned int max_ahead, unsigned MaxAhead = max_ahead; MaxMaxAhead = std::max(MaxMaxAhead, static_cast(MaxAhead)); if (CommProtocol == COMM_PROTOCOL_MULTI_E_COMP) { - NetworkTimingPolicy.Reset_From({FrameSendRate, MaxAhead}, NetworkTimingChangeCount, event_frame); + NetworkTimingPolicy.Reset_From({FrameSendRate, MaxAhead}, event_frame); } } @@ -609,7 +582,6 @@ NetTiming::ScheduleResult SessionClass::Schedule_Network_Timing(NetTiming::Timin return(NetTiming::ScheduleResult::Rejected); } - NetTiming::TimingSettings const old_target = Network_Timing_Target(); if (PendingNetworkTiming && settings == PendingNetworkTiming->Timing.Plan.Settings) { PendingNetworkTiming->DesiredFrameRate = desired_frame_rate; if (PendingNetworkTiming->Timing.Activated) { @@ -622,10 +594,6 @@ NetTiming::ScheduleResult SessionClass::Schedule_Network_Timing(NetTiming::Timin if (!staged) { return(NetTiming::ScheduleResult::Rejected); } - if (settings != old_target && NetworkTimingChangeCount < NetTiming::REVERSIBLE_CHANGE_LIMIT) { - NetworkTimingChangeCount++; - } - if (staged->Deferred) { NetworkTimingTransition transition; transition.Timing.Plan = *staged; diff --git a/code/session.h b/code/session.h index 3061bd7..f79deea 100644 --- a/code/session.h +++ b/code/session.h @@ -469,7 +469,6 @@ class SessionClass int Create_Connections(void); bool Am_I_Master(void); int Master_Player_ID(void) const; - int Removal_Authority_Player_ID(int target) 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); @@ -555,7 +554,6 @@ class SessionClass NetTiming::TimingReportCensus NetworkTimingReports; NetTiming::BalancedTimingPolicy NetworkTimingPolicy; std::optional PendingNetworkTiming; - unsigned int NetworkTimingChangeCount; int NetworkTimingPolicyOwner; int DesiredFrameRate; @@ -710,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/manual/changes/adaptive-network-timing.md b/manual/changes/adaptive-network-timing.md index df6ba2d..d6a9989 100644 --- a/manual/changes/adaptive-network-timing.md +++ b/manual/changes/adaptive-network-timing.md @@ -17,12 +17,14 @@ missing initial measurements fall back to a three-frame period and nine-frame lo Reductions drain the old scheduling horizon and step down at aligned send boundaries. A successor master inherits the synchronized target and change -budget before restarting improvement hysteresis. +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; the menu no longer sends manual `LATENCYFUDGE` changes, and -new matches start with a 1× RTT margin. +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 diff --git a/manual/changes/network-packet-validation.md b/manual/changes/network-packet-validation.md index aa0b829..3782784 100644 --- a/manual/changes/network-packet-validation.md +++ b/manual/changes/network-packet-validation.md @@ -11,12 +11,13 @@ 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; network timing is -master-only, and player removal uses deterministic master-or-successor -authority. +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 it does not change existing packet layouts or event IDs. All players must -use the same OpenTS snapshot. +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 index 0dfab20..b5ad043 100644 --- a/manual/content/systems/network-synchronization.md +++ b/manual/content/systems/network-synchronization.md @@ -23,29 +23,32 @@ 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. The master removes other players; the first -remaining network-human house in house order removes a departing master. Each -machine recomputes that authority when the event executes. +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; retries use exponential backoff. One slow -link therefore does not set every connection's retry interval. +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 authorized removal clears -the player's report. +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, @@ -53,13 +56,13 @@ again after 128. The first complete census selects its target directly with 20% 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. After eight changes, only conservative increases remain. +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 and change count, then clears -improvement evidence and starts a cooldown. +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 @@ -67,8 +70,10 @@ 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. -New matches start with a 1× RTT margin. The menu no longer emits the legacy -`LATENCYFUDGE` event. +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 diff --git a/tests/nettiming/CMakeLists.txt b/tests/nettiming/CMakeLists.txt index 337f589..1b61ab9 100644 --- a/tests/nettiming/CMakeLists.txt +++ b/tests/nettiming/CMakeLists.txt @@ -27,7 +27,7 @@ target_compile_options(NetTiming PRIVATE $<$:/MT /EHsc /Zc:__cplusplus> ) -target_link_libraries(NetTiming PRIVATE kernel32) +target_link_libraries(NetTiming PRIVATE kernel32 winmm) set_target_properties(NetTiming PROPERTIES RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/bin" diff --git a/tests/nettiming/nettiming.cpp b/tests/nettiming/nettiming.cpp index b49e6f6..add692e 100644 --- a/tests/nettiming/nettiming.cpp +++ b/tests/nettiming/nettiming.cpp @@ -8,7 +8,6 @@ ******************************************************************************/ -#include "event.h" #include "netsemantic.h" #include "nettiming.h" @@ -249,7 +248,7 @@ namespace 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, LatencyFudge::None, 200); + 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); @@ -308,7 +307,7 @@ namespace } - void Test_Rungs_And_Fudge(void) + void Test_Rungs(void) { using namespace NetTiming; @@ -322,22 +321,14 @@ namespace 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("no latency fudge", Apply_Latency_Fudge(100, LatencyFudge::None), 100u); - Expect_Equal("half latency fudge", Apply_Latency_Fudge(100, LatencyFudge::Half), 150u); - Expect_Equal("double latency fudge", Apply_Latency_Fudge(100, LatencyFudge::Double), 200u); - Expect_Equal("triple latency fudge", Apply_Latency_Fudge(100, LatencyFudge::Triple), 300u); - Expect_Equal("half fudge rounds up", Apply_Latency_Fudge(1, LatencyFudge::Half), 2u); - - Expect_Equal("zero RTT selects best rung", Select_Timing_Rung(0, 60, LatencyFudge::None), 1u); - Expect_Equal("100 ms fits best rung", Select_Timing_Rung(100, 60, LatencyFudge::None), 1u); - Expect_Equal("101 ms advances a rung", Select_Timing_Rung(101, 60, LatencyFudge::None), 2u); - Expect_Equal("300 ms selects balanced rung", Select_Timing_Rung(300, 60, LatencyFudge::None), 5u); - Expect_Equal("fudge raises selected rung", Select_Timing_Rung(100, 60, LatencyFudge::Half), 3u); - TimingSettings const high_rtt = Select_Timing_Settings(2000, 60, LatencyFudge::None); + 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, LatencyFudge::Triple); + 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); @@ -396,31 +387,21 @@ namespace Expect("guest timing authority is rejected", !Timing_Authority_Is_Valid(3, 2)); Expect("unresolved timing authority is rejected", !Timing_Authority_Is_Valid(2, -1)); - for (unsigned int type = 0; type < EventClass::LAST_EVENT; type++) { - bool const expected = type == EventClass::POWERON || type == EventClass::POWEROFF || type == EventClass::ARCHIVE - || type == EventClass::REPAIR || type == EventClass::PRIMARY || type == EventClass::MEGAMISSION - || type == EventClass::MEGAMISSION_F || type == EventClass::IDLE || type == EventClass::DEPLOY - || type == EventClass::SCATTER || type == EventClass::SELL; - Expect("ownership-required event classification is exact", Event_Requires_Owned_Subject(type) == expected); - } 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)); - Expect_Equal("master removes a guest", Removal_Authority(4, 2, -1), 2); - Expect_Equal("successor removes the master", Removal_Authority(2, 2, 3), 3); - Expect_Equal("master removal without a successor is unresolved", Removal_Authority(2, 2, -1), -1); - Expect("resolved removal authority is accepted", Removal_Authority_Is_Valid(2, 4, 2, -1)); - Expect("unauthorized removal is rejected", !Removal_Authority_Is_Valid(3, 4, 2, -1)); - Expect("self-removal is rejected", !Removal_Authority_Is_Valid(4, 4, 4, 2)); - 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)); @@ -430,10 +411,6 @@ namespace 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)); - - Expect("bounded report is valid", Network_Report_Is_Valid(1000, 65534)); - Expect("unavailable RTT sentinel is valid", Network_Report_Is_Valid(0, UINT16_MAX)); - Expect("process time above engine cap is rejected", !Network_Report_Is_Valid(1001, 10)); } @@ -475,49 +452,46 @@ namespace 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, LatencyFudge::None, 32); + 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, LatencyFudge::None, 64); + 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}); - Expect_Equal("changed bootstrap consumes one transition", low.Reversible_Changes(), 1u); - result = low.Evaluate(low_reports.Inspect(255), 60, LatencyFudge::None, 255); + 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, LatencyFudge::None, 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, LatencyFudge::None, false) == TimingSettings{1, 4}); + Select_Timing_Settings(100, 60, false) == TimingSettings{1, 4}); Expect("100 ms retains 2/6 with bootstrap headroom", - Select_Timing_Settings(100, 60, LatencyFudge::None, true) == TimingSettings{2, 6}); + 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, LatencyFudge::None, 64); + result = marginal.Evaluate(marginal_reports.Inspect(64), 60, 64); Expect("marginal bootstrap completes without changing 2/6", result.Evaluated && !result.Changed && !marginal.Is_Bootstrapping()); - Expect_Equal("equal bootstrap target preserves transition budget", marginal.Reversible_Changes(), 0u); 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, LatencyFudge::None, 64); + result = high.Evaluate(high_reports.Inspect(64), 60, 64); Expect("high-latency bootstrap worsens directly", result.Changed && high.Current_Settings() == TimingSettings{10, 90}); - Expect_Equal("high-latency bootstrap consumes one transition", high.Reversible_Changes(), 1u); 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, LatencyFudge::None, 64); + 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, LatencyFudge::None, 100); + 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, LatencyFudge::None, 128); + 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}); @@ -525,12 +499,11 @@ namespace 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, LatencyFudge::None, 64); + 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, LatencyFudge::None, 128); + 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}); - Expect_Equal("fallback consumes one transition", incomplete.Reversible_Changes(), 1u); TimingReportCensus lost_reports; lost_reports.Set_Player_Active(1, true, 0); @@ -538,44 +511,41 @@ namespace 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, LatencyFudge::None, 64); + 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, LatencyFudge::None, 128); + 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, LatencyFudge::None, 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, LatencyFudge::None, 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}); - Expect_Equal("post-bootstrap improvement consumes another transition", high.Reversible_Changes(), 2u); high.Reset(); Expect("reset starts a new bootstrap", high.Is_Bootstrapping()); Expect("reset restores 2/6", high.Current_Settings() == TimingSettings{2, 6}); - Expect_Equal("reset restores transition budget", high.Reversible_Changes(), 0u); BalancedTimingPolicy handoff; - handoff.Reset_From({10, 70}, 5, 0); + 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, LatencyFudge::None, 64); + result = handoff.Evaluate(high_reports.Inspect(64), 60, 64); Expect("handoff ignores bootstrap evaluation", !result.Evaluated && handoff.Current_Settings() == TimingSettings{10, 70}); - Expect_Equal("handoff preserves its transition budget", handoff.Reversible_Changes(), 5u); 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, LatencyFudge::None, 1056); + 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, LatencyFudge::None, 1088); + 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}); } @@ -588,36 +558,36 @@ namespace TimingReportCensus reports; reports.Set_Player_Active(1, true, 0); BalancedTimingPolicy policy; - policy.Reset_From({3, 9}, 0, 0); + policy.Reset_From({3, 9}, 0); Record_One(reports, 0, 256); - TimingEvaluation result = policy.Evaluate(reports.Inspect(256), 60, LatencyFudge::None, 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, LatencyFudge::None, 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, LatencyFudge::None, 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, LatencyFudge::None, 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, 0); + 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, LatencyFudge::None, 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, LatencyFudge::None, 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", @@ -625,7 +595,7 @@ namespace for (std::uint32_t frame : {1280u, 1536u, 1792u}) { Record_One(reports, 1300, frame); - result = policy.Evaluate(reports.Inspect(frame), 60, LatencyFudge::None, 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", @@ -633,63 +603,54 @@ namespace } - void Test_Stale_And_Transition_Budget(void) + 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, 0); - TimingEvaluation result = stale_policy.Evaluate(stale.Inspect(0), 60, LatencyFudge::None, 0); + 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, LatencyFudge::None, 256); - result = stale_policy.Evaluate(stale.Inspect(256 + REPORT_EXPIRY), 60, - LatencyFudge::None, 256 + REPORT_EXPIRY); + 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, LatencyFudge::None, frame); + 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, 0); + 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, LatencyFudge::None, frame); + policy.Evaluate(reports.Inspect(frame), 60, frame); frame += EVALUATION_INTERVAL; }; - evaluate(2000); // 1: 3 -> 10 - for (int cycle = 0; cycle < 3; cycle++) { + for (int cycle = 0; cycle < 5; cycle++) { + evaluate(2000); + evaluate(0); evaluate(0); evaluate(0); - evaluate(0); // even transition: 10 -> 9 - evaluate(2000); // odd transition: 9 -> 10 } + Expect_Equal("repeated degradation and recovery remains stable", policy.Current_Rung(), 9u); evaluate(0); evaluate(0); - evaluate(0); // 8: 10 -> 9 - - Expect_Equal("transition budget reached", policy.Reversible_Changes(), REVERSIBLE_CHANGE_LIMIT); - Expect_Equal("eighth transition leaves rung nine", policy.Current_Rung(), 9u); - for (int i = 0; i < 6; i++) { - evaluate(0); - } - Expect_Equal("budget locks further improvement", policy.Current_Rung(), 9u); - evaluate(2000); - Expect_Equal("worsening remains available after budget", policy.Current_Rung(), 10u); + evaluate(0); + Expect_Equal("recovery remains possible after more than eight changes", policy.Current_Rung(), 8u); } @@ -700,42 +661,34 @@ namespace TimingReportCensus reports; reports.Set_Player_Active(1, true, 1000); BalancedTimingPolicy policy; - policy.Reset_From({10, 70}, REVERSIBLE_CHANGE_LIMIT + 5, 1000); + policy.Reset_From({10, 70}, 1000); Expect("handoff restores authoritative settings", policy.Current_Settings() == TimingSettings{10, 70}); - Expect_Equal("handoff saturates the transition budget", policy.Reversible_Changes(), REVERSIBLE_CHANGE_LIMIT); Expect_Equal("handoff discards improvement evidence", policy.Good_Evaluations(), 0u); Record_One(reports, 0, 1000); - TimingEvaluation result = policy.Evaluate(reports.Inspect(1000), 60, LatencyFudge::None, 1000); + TimingEvaluation result = policy.Evaluate(reports.Inspect(1000), 60, 1000); Expect("handoff starts an evaluation cooldown", !result.Evaluated); - for (std::uint32_t frame : {1256u, 1512u, 1768u, 2024u}) { - Record_One(reports, 0, frame); - result = policy.Evaluate(reports.Inspect(frame), 60, LatencyFudge::None, frame); - } - Expect("restored transition budget prevents improvement", policy.Current_Settings() == TimingSettings{10, 70}); - - Record_One(reports, MAXIMUM_REPORTED_RTT, 2280); - result = policy.Evaluate(reports.Inspect(2280), 60, LatencyFudge::Triple, 2280); - Expect("conservative worsening remains after handoff budget", result.Changed && policy.Current_Settings() == TimingSettings{10, 250}); - Expect_Equal("worsening leaves saturated budget intact", policy.Reversible_Changes(), REVERSIBLE_CHANGE_LIMIT); + 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}, 2, 0); + 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, LatencyFudge::None, 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, 0); + 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, LatencyFudge::None, 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}); @@ -743,8 +696,8 @@ namespace legacy_reports.Set_Player_Active(1, true, 0); legacy_reports.Record_Report(1, 10, 200, 256); BalancedTimingPolicy legacy; - legacy.Reset_From({3, 6}, 0, 0); - result = legacy.Evaluate(legacy_reports.Inspect(256), 60, LatencyFudge::None, 256); + 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}); } @@ -885,13 +838,13 @@ int main(void) Test_Retransmit_Backoff(); Test_Loss_Jitter_And_Reordering(); Test_Census(); - Test_Rungs_And_Fudge(); + Test_Rungs(); Test_Connection_Quality(); Test_Event_Semantics(); Test_Bootstrap_Cadence(); Test_Bootstrap_Policy(); Test_Hysteresis_And_Cooldown(); - Test_Stale_And_Transition_Budget(); + Test_Stale_And_Long_Term_Recovery(); Test_Master_Handoff_State(); Test_Staged_Decrease(); Test_Transition_Sequences();