Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions Core/GameEngine/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -505,6 +505,7 @@ set(GAMEENGINE_SRC
# Include/GameLogic/WeaponStatus.h
Include/GameNetwork/Connection.h
Include/GameNetwork/ConnectionManager.h
Include/GameNetwork/ConnectionStats.h
Include/GameNetwork/DisconnectManager.h
Include/GameNetwork/DownloadManager.h
Include/GameNetwork/FileTransfer.h
Expand Down Expand Up @@ -1099,6 +1100,7 @@ set(GAMEENGINE_SRC
Source/GameLogic/System/RankInfo.cpp
Source/GameNetwork/Connection.cpp
Source/GameNetwork/ConnectionManager.cpp
Source/GameNetwork/ConnectionStats.cpp
Source/GameNetwork/DisconnectManager.cpp
Source/GameNetwork/DownloadManager.cpp
Source/GameNetwork/FileTransfer.cpp
Expand Down
44 changes: 35 additions & 9 deletions Core/GameEngine/Include/GameNetwork/Connection.h
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,9 @@
#include "GameNetwork/User.h"
#include "GameNetwork/Transport.h"
#include "GameNetwork/NetPacket.h"

#include "GameNetwork/ConnectionStats.h"
#if defined(GENERALS_ONLINE)
#define CONNECTION_LATENCY_HISTORY_LENGTH 10
#else
#define CONNECTION_LATENCY_HISTORY_LENGTH 200
#include "GameNetwork/NetworkSettings.h"
#endif

class Connection : public MemoryPoolObject
Expand All @@ -66,6 +64,13 @@ class Connection : public MemoryPoolObject
void setUser(User *user);
User *getUser();
void setFrameGrouping(time_t frameGrouping);
#if defined(GENERALS_ONLINE)
void setNetworkSettings(const NetworkSettings &settings, Bool unreliableTransport) { m_networkSettings = settings; m_unreliableTransport = unreliableTransport; }
#endif
void setSlot(Int slot) { m_slot = slot; }
void setShortGrouping(Bool shortGrouping); ///< Send at one frame while the run ahead is close to its floor.
Int getRedundancyDepth() const { return m_redundancyDepth; }
time_t getFrameGrouping() const { return m_frameGrouping; } ///< The send interval in use, shorter than the configured one while redundancy is on.

void sendNetCommandMsg(NetCommandMsg *msg, UnsignedByte relay);

Expand All @@ -80,12 +85,27 @@ class Connection : public MemoryPoolObject
void setQuitting();
Bool isQuitting() { return m_isQuitting; }

const ConnectionStats& getStats() const { return m_stats; }

#if defined(RTS_DEBUG)
void debugPrintCommands();
#endif

protected:
void doRetryMetrics();
#if defined(GENERALS_ONLINE)
NetworkSettings m_networkSettings;
Bool m_unreliableTransport;
#endif

void resetRedundancy();
void updateRedundancy(time_t curtime, Bool &active, time_t &window, Int &depth);
Bool hasLossEvidence(); ///< A packet was lost in either direction lately, or nothing says otherwise
Bool getOutgoingLoss(float &lossPercent, Int &longestRun); ///< FALSE when the peer sent no packet feedback
void noteLossEvent(time_t now, Bool burst);
void setRedundancyDepth(Int depth, Int cap, time_t now);
Bool isQueueBuilding(time_t now); ///< Round trip at 2x its low while packets are lost: a full queue
void applyFrameGrouping();
void addRedundantCommands(NetPacket *packet, time_t curtime, time_t window, Int depth);

Bool m_isQuitting;
UnsignedInt m_quitTime;
Expand All @@ -95,11 +115,17 @@ class Connection : public MemoryPoolObject

NetCommandList *m_netCommandList;
time_t m_retryTime; ///< The time between sending retry packets for this connection. Time is in milliseconds.
Real m_averageLatency; ///< The average time between sending a command and receiving an ACK.
Real m_latencies[CONNECTION_LATENCY_HISTORY_LENGTH]; ///< List of the last 100 latencies.
ConnectionStats m_stats; ///< Latency, jitter and loss of this connection.

Int m_redundancyDepth; ///< How many later packets each un-acked command is repeated in, 0 on a clean connection.
Int m_redundancyCap; ///< The deepest depth, from the longest burst to repair and the send interval.
time_t m_lastLossEventTime; ///< What the decay counts from.
time_t m_lastDepthChangeTime; ///< A burst needs a round trip to show whether the new depth helped.
Bool m_queueSuspected; ///< A full queue on the path, redundancy steps down instead of up.
Int m_slot;

Bool m_shortGrouping;
time_t m_configuredFrameGrouping;
time_t m_frameGrouping; ///< The minimum time between packet sends.
time_t m_lastTimeSent; ///< The time of the last packet send.
Int m_numRetries; ///< The number of retries for the last second.
time_t m_retryMetricsTime; ///< The start time of the current retry metrics thing.
};
207 changes: 206 additions & 1 deletion Core/GameEngine/Include/GameNetwork/ConnectionManager.h

Large diffs are not rendered by default.

291 changes: 291 additions & 0 deletions Core/GameEngine/Include/GameNetwork/ConnectionStats.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,291 @@
/*
** Command & Conquer Generals Zero Hour(tm)
** Copyright 2025 Electronic Arts Inc.
**
** This program is free software: you can redistribute it and/or modify
** it under the terms of the GNU General Public License as published by
** the Free Software Foundation, either version 3 of the License, or
** (at your option) any later version.
**
** This program is distributed in the hope that it will be useful,
** but WITHOUT ANY WARRANTY; without even the implied warranty of
** MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
** GNU General Public License for more details.
**
** You should have received a copy of the GNU General Public License
** along with this program. If not, see <http://www.gnu.org/licenses/>.
*/

#pragma once

#include <time.h>

// Fixed size ring of items with a time member, oldest first, that drops the items older than a window.
template <typename T, Int N>
class TimedRing
{
public:
TimedRing() { clear(); }

void clear() { m_head = 0; m_count = 0; }
Int size() const { return m_count; }
const T& operator[](Int i) const { return m_items[(m_head + i) % N]; }

void push(const T& item)
{
if (m_count == N)
{
m_head = (m_head + 1) % N;
--m_count;
}
m_items[(m_head + m_count) % N] = item;
++m_count;
}

void prune(time_t now, time_t windowMs)
{
while (m_count > 0 && now - m_items[m_head].time > windowMs)
{
m_head = (m_head + 1) % N;
--m_count;
}
}

private:
T m_items[N];
Int m_head;
Int m_count;
};

// Tracks latency, jitter and loss of one connection over a sliding time window.
class ConnectionStats
{
public:
ConnectionStats();

void reset();
void noteAcked(Real rttMs, time_t now); ///< A command that was acked on its first send.
void noteRetry(time_t now); ///< A command that was not acked in time and had to be sent again.
void noteLateAck(time_t now); ///< A retried command was acked by its earlier send after all, the retry was not a loss.
void setRedundancyDepth(Int depth, Int cap);
void update(time_t now); ///< Drop the samples that have left the window.

Real getLatency() const { return m_srtt; }
Real getMinLatency() const;
Real getLatencyPercentile(Int percent, time_t since = 0) const; ///< Round trip that this share of the samples in the window, from since on, stays within
Int getSampleCountSince(time_t since) const;
Real getJitter() const { return m_jitter; }
Real getLossPercent() const; ///< Share of retries the earlier send was not acked for
Real getLateAckPercent() const; ///< Share of retries whose earlier send was acked after the retry
Real getRetryPercent() const; ///< Share of retries, lost and late acked together
Real getLatePercent() const; ///< Share of acks that took more than twice the smoothed round trip time.
Int getSampleCount() const { return m_samples.size(); }
enum { MAX_REDUNDANCY_DEPTH = 16 };
Int getRedundancyDepth() const { return m_depth; }
Int getRedundancyCap() const { return m_depthCap; }

private:
enum
{
MAX_SAMPLES = 1024, ///< Covers the window at 60 acked frames per second.
MAX_RETRIES = 1024 ///< As many as acks, under heavy loss a smaller ring undercounts the retries of the window.
};

struct Sample
{
time_t time;
Real rtt;
Bool late;
};

struct Stamp
{
time_t time;
};

TimedRing<Sample, MAX_SAMPLES> m_samples;
TimedRing<Stamp, MAX_RETRIES> m_retries;
TimedRing<Stamp, MAX_RETRIES> m_lateAcks;

Real m_srtt;
Real m_jitter;
Real m_previousRtt;
Bool m_hasRtt;

Int m_depth;
Int m_depthCap;
};

// Tracks how many frames before its execution frame the frame commands of one sender arrive,
// over the same sliding window as ConnectionStats. Zero or less means the frame had to wait.
class ArrivalCushionStats
{
public:
ArrivalCushionStats();

void reset();
void note(Int cushion, time_t now);
void update(time_t now);

Int getSampleCount() const { return m_samples.size(); }
Int getPercentile(Int percent) const; ///< Cushion that percent of the samples are at or above the lowest value of.

private:
enum { MAX_SAMPLES = 1024 };

struct Sample
{
time_t time;
Int cushion;
};

TimedRing<Sample, MAX_SAMPLES> m_samples;
};

// Frames the game wanted to advance but had to wait for commands, kept for a minute and counted over a window.
class StallStats
{
public:
StallStats();

void reset();
void note(Int durationMs, UnsignedInt missingSlotMask, time_t now); ///< One finished stall and the slots whose commands were missing when it began.
void update(time_t now);

Int getCount(time_t now, Int windowMs) const;
Int getCountForSlot(Int slot, time_t now, Int windowMs) const;

private:
enum { MAX_EVENTS = 512, HISTORY_MS = 60000 };

struct Event
{
time_t time;
Int ms;
UnsignedInt slotMask;
};

TimedRing<Event, MAX_EVENTS> m_events;
};

// Run ahead from the per window need: a forgetting quantile of normal windows is the baseline; spike episodes are bought only
// when they recur faster than one per lambda. Rules use window time, so the decision interval does not matter.
class RunAheadSpikeController
{
public:
enum Reason { REASON_HOLD, REASON_BASELINE, REASON_COVER, REASON_UNCOVER, REASON_CONGESTION };

struct Decision
{
Int runAhead;
Int reason; ///< Reason.
Int base; ///< Frames, the q quantile of the normal windows.
Int cover; ///< Frames bought on top of the baseline for spikes.
Int spikesPerMin10; ///< Spike episodes per minute over the horizon, times 10.
Int spikeSizeMs; ///< The median spike height over the baseline in the horizon.
Bool inSpike;
Bool fastDrop; ///< The target is the recent need, the next decision does not wait for the long dead time.
};

RunAheadSpikeController();

void reset();
void dropEpisode(); ///< An outage: the open episode is not the network's trend.
Decision onWindow(time_t now, Int windowMs, Int need, Bool stalled, Int stallMs, Real jitterMs, Int runAhead, Int marginFrames, Real frameMs, Int floorFrames, Int capFrames, Bool safeFastDrop, Bool boundRaise);

private:
enum
{
BUCKETS = 128, // one frame each, the need is 0 to 127 frames
SAMPLE_RING = 16, // need samples of the open episode
RECENT_RING = 8, // the last windows, for the median need of a fast drop
EPISODE_RING = 32 // more than the HORIZON_MS / LAMBDA_MS + 1 the cost rule looks at
};

struct Episode
{
time_t start;
Int height;
};

Real m_hist[BUCKETS]; ///< The normal windows, forgetting.
Real m_mass;
Real m_timeMs; ///< The time the normal windows cover, forgetting the same way.
Real m_saved[BUCKETS]; ///< The baseline before a level shift was adopted, for the undo.
Real m_savedMass;
Real m_savedTimeMs;
Int m_savedBase;
Int m_undoMs; ///< Window time left in which a return to the saved baseline undoes the adopted level.
Int m_undoCalmMs;
Episode m_pending; ///< The episode an adoption came from, a spike after all if it is undone.

Bool m_inSpike;
time_t m_spikeStart;
Int m_peakNeed;
Int m_episodeStallMs;
Int m_episodeMs; ///< Window time of the open episode after the window that opened it.
Int m_calmMs;
time_t m_sampleTime[SAMPLE_RING];
Int m_sampleNeed[SAMPLE_RING];
Int m_sampleMs[SAMPLE_RING];
Int m_sampleCount;

Episode m_episodes[EPISODE_RING];
Int m_episodeCount; ///< Recorded so far, the ring holds the last EPISODE_RING.
Int m_lastCover;
Int m_recentNeed[RECENT_RING];
Int m_recentMs[RECENT_RING];
Int m_recentCount;
Int m_gateCleanMs; ///< Window time in a row the need stayed within the target + 1.
time_t m_gateClusterStart; ///< Start of the current spike cluster.
time_t m_gatePrevClusterStart; ///< Start of the cluster before it.

static void forget(Real *hist, Real &mass, Real factor);
static void addSample(Real *hist, Real &mass, Int need);
static Int quantile(const Real *hist, Real mass, Real q);
Int baseline(Int runAhead, Int marginFrames) const;
void recordEpisode(const Episode &episode);
};

// What the network overlay and the connection rating need to know about one slot beyond its ConnectionStats.
struct NetworkOverlayInfo
{
NetworkOverlayInfo()
: cushionSamples(0), cushionP5(0), cushionP50(0)
, stallsWindow(0), stallsMinute(0), stalledNow(FALSE), waitingOnSlot(FALSE)
, runAhead(0), frameGroupingMs(0)
, isRouter(FALSE), controllerOn(FALSE), marginFrames(1), changeDir(0), changeReason(CHANGE_NONE), changeAgoMs(-1)
, ctlReason(CTL_NONE), ctlMinSum(NO_PAIR_SUM), ctlPairA(-1), ctlPairB(-1), ctlBase(0), ctlCover(0), ctlSpikesPerMin10(0), ctlSpikeSizeMs(0), ctlInSpike(FALSE), ctlSinceChangeMs(0) {}

enum ChangeReason { CHANGE_NONE, CHANGE_START, CHANGE_LAG_SPIKE, CHANGE_STALL, CHANGE_CLEAN };
enum { NO_PAIR_SUM = 999 }; ///< ctlMinSum when no pair reported
enum ControllerReason { CTL_NONE, CTL_SEED, CTL_HOLD, CTL_BASELINE, CTL_COVER, CTL_UNCOVER, CTL_CONGESTION, CTL_OUTAGE, CTL_FORMULA };

Int cushionSamples; ///< Frames before execution that the frame commands of this slot arrived, over the stats window.
Int cushionP5;
Int cushionP50;
Int stallsWindow; ///< Stalls the local game had to wait on this slot for, over the stats window.
Int stallsMinute; ///< The same over the last minute.
Bool stalledNow; ///< The local game is waiting for commands right now.
Bool waitingOnSlot; ///< ... and this slot is one it is waiting on.

Int runAhead; ///< Frames.
Int frameGroupingMs; ///< The send interval.

Bool isRouter; ///< This machine decides the run ahead, the controller fields are valid only here.
Bool controllerOn;
Int marginFrames; ///< Cushion frames each direction of a pair keeps spare.
Int changeDir; ///< Direction of the last applied run ahead change, 1 up, -1 down, 0 none yet.
Int changeReason; ///< ChangeReason, known on every client.
Int changeAgoMs; ///< Since that change took effect, -1 if none.
Int ctlReason; ///< ControllerReason of the last decision.
Int ctlMinSum; ///< Lowest sum of the two cushion directions of a pair, NO_PAIR_SUM if no pair reported.
Int ctlPairA;
Int ctlPairB;
Int ctlBase; ///< Frames, the baseline quantile of the normal windows.
Int ctlCover; ///< Frames bought for spikes on top of the baseline.
Int ctlSpikesPerMin10; ///< Spike episodes per minute, times 10.
Int ctlSpikeSizeMs; ///< The median spike height over the baseline.
Bool ctlInSpike; ///< A spike episode is open, the run ahead holds.
Int ctlSinceChangeMs;
};
Loading
Loading