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
5 changes: 4 additions & 1 deletion code/framework/src/networking/network_peer.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include "replication/replication_manager.h"

#include <logging/logger.h>
#include <mafianet/GetTime.h>

namespace Framework::Networking {
NetworkPeer::NetworkPeer() {
Expand Down Expand Up @@ -65,9 +66,11 @@ namespace Framework::Networking {
return;
}

// Rebuild the spatial index before ReplicaManager3 computes per-connection relevance.
// Rebuild the spatial index before ReplicaManager3 computes per-connection relevance, then
// elect syncers off the fresh positions.
if (_replicationManager) {
_replicationManager->RebuildInterest();
_replicationManager->RunDelegation(MafiaNet::GetTime());
}

for (_packet = _peer->Receive(); _packet; _peer->DeallocatePacket(_packet), _packet = _peer->Receive()) {
Expand Down
87 changes: 87 additions & 0 deletions code/framework/src/networking/replication/delegation_policy.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
/*
* MafiaHub OSS license
* Copyright (c) 2021-2023, MafiaHub. All rights reserved.
*
* This file comes from MafiaHub, hosted at https://github.com/MafiaHub/Framework.
* See LICENSE file in the source repository for information regarding licensing.
*/

#pragma once

#include <mafianet/types.h>

#include <glm/glm.hpp>

#include <cstdint>
#include <vector>

namespace Framework::Networking::Replication {
// Tunables for proximity syncer election. Distances are ground-plane world units. acquireRange <
// dropRange gives hysteresis: a candidate must reach acquireRange to be elected, but the current
// owner is held until it drifts past dropRange, so a boundary owner doesn't thrash.
struct DelegationParams {
float acquireRange = 80.0f;
float dropRange = 130.0f;
uint32_t electionIntervalMs = 500;
int loadSoftCap = 0; // max delegated entities newly granted per client (0 = off)
};

struct DelegationCandidate {
MafiaNet::PeerGuid guid = MafiaNet::UNASSIGNED_PEER_GUID;
glm::vec3 position {0.0f};
int ownedLoad = 0;
bool eligible = true; // caller-applied dimension + game veto
};

// Pure, networking-free election. Keeps the current owner within dropRange, else elects the
// least-loaded eligible candidate within acquireRange (nearest breaking ties), else UNASSIGNED.
// groundXY picks the second ground axis (Y for Z-up, else Z).
inline MafiaNet::PeerGuid ElectOwner(const glm::vec3 &entityPos, MafiaNet::PeerGuid currentOwner, const std::vector<DelegationCandidate> &candidates, const DelegationParams &params, bool groundXY) {
const auto ground = [groundXY](const glm::vec3 &p) {
return groundXY ? p.y : p.z;
};
const auto dist2 = [&](const glm::vec3 &p) {
const float dx = p.x - entityPos.x;
const float dv = ground(p) - ground(entityPos);
return dx * dx + dv * dv;
};

// Hysteresis: hold the current owner while eligible and within dropRange (soft cap ignored).
if (currentOwner != MafiaNet::UNASSIGNED_PEER_GUID) {
const float dropSq = params.dropRange * params.dropRange;
for (const auto &candidate : candidates) {
if (candidate.guid != currentOwner) {
continue;
}
if (candidate.eligible && dist2(candidate.position) <= dropSq) {
return currentOwner;
}
break;
}
}

const float acquireSq = params.acquireRange * params.acquireRange;
MafiaNet::PeerGuid best = MafiaNet::UNASSIGNED_PEER_GUID;
int bestLoad = 0;
float bestDist = 0.0f;
for (const auto &candidate : candidates) {
if (!candidate.eligible || candidate.guid == MafiaNet::UNASSIGNED_PEER_GUID) {
continue;
}
const float d = dist2(candidate.position);
if (d > acquireSq) {
continue;
}
if (params.loadSoftCap > 0 && candidate.ownedLoad >= params.loadSoftCap) {
continue;
}
const bool better = best == MafiaNet::UNASSIGNED_PEER_GUID || candidate.ownedLoad < bestLoad || (candidate.ownedLoad == bestLoad && d < bestDist);
if (better) {
best = candidate.guid;
bestLoad = candidate.ownedLoad;
bestDist = d;
}
}
return best;
}
} // namespace Framework::Networking::Replication
26 changes: 22 additions & 4 deletions code/framework/src/networking/replication/network_entity.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -41,10 +41,22 @@ namespace Framework::Networking::Replication {
}

void NetworkEntity::AdoptIncomingOwner(MafiaNet::PeerGuid incomingOwner) {
// The server keeps its own authoritative owner assignment and must not let an owning client
// dictate it back; clients adopt whatever the server sends.
if (!IsServerPeer()) {
ownerGUID = incomingOwner;
SetOwnerFromServer(incomingOwner);
}

void NetworkEntity::SetOwnerFromServer(MafiaNet::PeerGuid newOwner) {
if (IsServerPeer()) {
return;
}
const bool was = IsOwner();
ownerGUID = newOwner;
// Construction defers the callback (see DeserializeConstruction) until the pose is read.
if (_constructing) {
return;
}
const bool now = IsOwner();
if (now != was) {
OnOwnershipChanged(now);
}
}

Expand Down Expand Up @@ -94,12 +106,18 @@ namespace Framework::Networking::Replication {
uint8_t incomingEpoch = stateEpoch;
constructionBitstream->Read(incomingEpoch);
ApplyIncomingEpoch(incomingEpoch);
_constructing = true;
FieldSerializer seed(constructionBitstream, false);
SerializeBaseFields(seed);
SerializeTransform(seed);
OnSerializeConstruction(seed);
SerializeFields(seed);
_constructing = false;
OnConstructed();
// Announce ownership if we arrive already owning it, now that the pose is populated.
if (!IsServerPeer() && IsOwner()) {
OnOwnershipChanged(true);
}
return true;
}

Expand Down
34 changes: 34 additions & 0 deletions code/framework/src/networking/replication/network_entity.h
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,21 @@ namespace Framework::Networking::Replication {
};
Streaming streaming;

// Fate of a delegatable entity when no client can take it.
enum class OrphanMode : uint8_t {
Freeze, // return to the server (UNASSIGNED), keep replicating its last pose
Destroy, // remove it
};

// Server-only, never replicated. Opt-in via delegatable; see ReplicationManager::RunDelegation.
struct Delegation {
bool delegatable = false;
OrphanMode orphanMode = OrphanMode::Freeze;
// Pin the syncer to a client, bypassing election. UNASSIGNED elects normally.
MafiaNet::PeerGuid pinnedOwner = MafiaNet::UNASSIGNED_PEER_GUID;
};
Delegation delegation;

// --- Game extension points ---
virtual void OnSerializeConstruction(FieldSerializer &fields) {
(void)fields;
Expand Down Expand Up @@ -164,6 +179,18 @@ namespace Framework::Networking::Replication {
// Called on the owning client after SerializeForcedState has applied the forced fields.
virtual void OnStateForced() {}

// Client-only: authority over this entity crossed our peer. On gain, the replicated pose is
// already current, so seed the local sim from it; on loss, tear it down.
virtual void OnOwnershipChanged(bool nowOwner) {
(void)nowOwner;
}

// Server: veto electing `candidate` as syncer (proximity/dimension already applied).
virtual bool CanDelegateTo(MafiaNet::PeerGuid candidate) const {
(void)candidate;
return true;
}

// Server: push this entity's forced state to its owner. No-op for unowned (server-owned)
// entities, which replicate to everyone normally.
void ForceState();
Expand All @@ -174,6 +201,10 @@ namespace Framework::Networking::Replication {
// MafiaNet::UNASSIGNED_PEER_GUID to return ownership to the server.
void SetOwner(MafiaNet::PeerGuid guid);

// Client-side: adopt an owner from the server, firing OnOwnershipChanged on a flip. No-op on
// the server. The single sink for client owner changes so the callback can't be bypassed.
void SetOwnerFromServer(MafiaNet::PeerGuid newOwner);

// True on the peer with authority over this entity: the owning client, or the server for
// server-owned entities. The game decides what owning means (bind the local avatar, drive
// updates upstream, ...); this just answers who holds authority.
Expand Down Expand Up @@ -226,6 +257,9 @@ namespace Framework::Networking::Replication {
// applied.
bool ApplyIncomingEpoch(uint8_t incomingEpoch);

// Set during DeserializeConstruction: defers OnOwnershipChanged to the end, once the pose is read.
bool _constructing = false;

// CRC32 of the registered name; stamped by EntityRegistry, not game-settable.
uint32_t typeId = 0;
friend class EntityRegistry;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,8 +66,9 @@ namespace Framework::Networking::Replication {
});
owner->RegisterRPC<SetOwnerRPC>([this](const SetOwnerRPC &payload, MafiaNet::Packet *) {
if (auto *entity = GetEntityByNetworkID(payload.networkId)) {
entity->ownerGUID = payload.ownerGUID;
// Epoch before the flip: a gained owner sends from inside OnOwnershipChanged.
entity->stateEpoch = payload.stateEpoch;
entity->SetOwnerFromServer(payload.ownerGUID);
}
});
_clientRPCsRegistered = true;
Expand Down Expand Up @@ -211,6 +212,83 @@ namespace Framework::Networking::Replication {
_interest.CollectVisible(viewer, viewerGUID, out);
}

void ReplicationManager::RunDelegation(uint64_t nowMs) {
if (!_isServer) {
return;
}
if (nowMs - _lastDelegationMs < _delegationParams.electionIntervalMs) {
return;
}
_lastDelegationMs = nowMs;

// Delegated entities each client owns, for load balancing.
std::unordered_map<MafiaNet::PeerGuid, int> load;
ForEachEntity([&load](NetworkEntity *entity) {
if (entity->delegation.delegatable && entity->ownerGUID != MafiaNet::UNASSIGNED_PEER_GUID) {
++load[entity->ownerGUID];
}
});

// Destroy deferred past the sweep: DestroyEntity mutates the index ForEachEntity walks.
std::vector<NetworkEntity *> toDestroy;
std::vector<DelegationCandidate> candidates;
ForEachEntity([&](NetworkEntity *entity) {
if (!entity->delegation.delegatable) {
return;
}

// A pinned syncer bypasses election.
if (entity->delegation.pinnedOwner != MafiaNet::UNASSIGNED_PEER_GUID) {
if (entity->ownerGUID != entity->delegation.pinnedOwner) {
SetOwner(entity, entity->delegation.pinnedOwner);
}
return;
}
Comment on lines +246 to +251

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Map the relevant file and surrounding symbols first.
ast-grep outline code/framework/src/networking/replication/replication_manager.cpp --view expanded || true

# Find all references to pinnedOwner, ownership changes, and disconnect handling.
rg -n --hidden --no-ignore-vcs \
  -e 'pinnedOwner|OnClosedConnection|SetOwner\(|ownerGUID|UNASSIGNED_PEER_GUID|GetViewer\(' \
  code/framework/src code -g '!**/build/**' -g '!**/dist/**' || true

# Read the relevant slices from the replication manager.
sed -n '200,320p' code/framework/src/networking/replication/replication_manager.cpp

# Inspect any disconnect/connection cleanup paths that might clear pinning.
rg -n --hidden --no-ignore-vcs -e 'clear.*pinnedOwner|pinnedOwner\s*=|delegation\.pinnedOwner' code/framework/src code -g '!**/build/**' -g '!**/dist/**' || true

Repository: MafiaHub/Framework

Length of output: 30953


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the pinning/delegation definitions and any public APIs around them.
sed -n '130,220p' code/framework/src/networking/replication/network_entity.h
sed -n '1,220p' code/framework/src/networking/replication/network_entity.cpp
sed -n '120,180p' code/framework/src/networking/replication/replication_manager.h

# Search for any code that explicitly clears or updates pinnedOwner.
rg -n --hidden --no-ignore-vcs \
  -e 'pinnedOwner\s*=|SetPinned|ClearPinned|delegation\.pinnedOwner|pinned syncer|pinned' \
  code/framework/src code/tests -g '!**/build/**' -g '!**/dist/**' || true

Repository: MafiaHub/Framework

Length of output: 18815


Clear or validate pinnedOwner when the pinned peer disconnects code/framework/src/networking/replication/replication_manager.cpp:241-243

pinnedOwner is never cleared on disconnect, so RunDelegation() keeps reassigning the entity to a disconnected peer and bypasses normal election/orphan handling. Gate the re-pin on a live viewer, or clear delegation.pinnedOwner in the disconnect path.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@code/framework/src/networking/replication/replication_manager.cpp` around
lines 246 - 251, The pinned-owner logic in RunDelegation() keeps forcing
ownership to delegation.pinnedOwner even after that peer disconnects, so update
the disconnect handling or the re-pin check to ensure only live peers are used.
In replication_manager.cpp, either clear entity->delegation.pinnedOwner when the
pinned peer disconnects, or gate the SetOwner path on a current
viewer/alive-peer check before reassigning. Keep the fix localized around
RunDelegation() and the disconnect path that manages delegation state.


candidates.clear();
candidates.reserve(_viewers.size());
const auto entityWorld = entity->GetVirtualWorld();
for (const auto &[guid, viewer] : _viewers) {
if (!viewer) {
continue;
}
DelegationCandidate candidate;
candidate.guid = guid;
candidate.position = viewer->position;
const auto loadIt = load.find(guid);
candidate.ownedLoad = loadIt != load.end() ? loadIt->second : 0;
candidate.eligible = MafiaNet::VirtualWorldsCanSee(viewer->GetVirtualWorld(), entityWorld) && entity->CanDelegateTo(guid);
candidates.push_back(candidate);
}

const MafiaNet::PeerGuid previous = entity->ownerGUID;
const MafiaNet::PeerGuid next = ElectOwner(entity->position, previous, candidates, _delegationParams, _groundXY);
if (next == previous) {
return;
}

if (next == MafiaNet::UNASSIGNED_PEER_GUID && entity->delegation.orphanMode == NetworkEntity::OrphanMode::Destroy) {
toDestroy.push_back(entity);
return;
}

SetOwner(entity, next);
// Keep load current within the pass so later entities balance against it.
if (previous != MafiaNet::UNASSIGNED_PEER_GUID) {
if (const auto it = load.find(previous); it != load.end() && --it->second <= 0) {
load.erase(it);
}
}
if (next != MafiaNet::UNASSIGNED_PEER_GUID) {
++load[next];
}
});

for (NetworkEntity *entity : toDestroy) {
DestroyEntity(entity);
}
}

void ReplicationManager::OnClosedConnection(const MafiaNet::SystemAddress &systemAddress, MafiaNet::RakNetGUID rakNetGUID, MafiaNet::PI2_LostConnectionReason lostConnectionReason) {
// The player's avatar is server-created, so the base PopConnection (which only tears down
// replicas a dropped peer itself created) leaves it behind. Notify the game while the avatar
Expand Down
17 changes: 17 additions & 0 deletions code/framework/src/networking/replication/replication_manager.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

#pragma once

#include "delegation_policy.h"
#include "entity_registry.h"
#include "interest_grid.h"
#include "network_entity.h"
Expand Down Expand Up @@ -106,6 +107,7 @@ namespace Framework::Networking::Replication {
// InterestGrid::SetGroundPlaneXY.
void SetInterestGroundPlaneXY(bool groundXY) {
_interest.SetGroundPlaneXY(groundXY);
_groundXY = groundXY; // election measures distance on the same plane
}
// Rebuild the spatial index from current entity positions. Server only; call once per tick
// before ReplicaManager3 serializes (driven from NetworkPeer::Update).
Expand All @@ -116,6 +118,18 @@ namespace Framework::Networking::Replication {
return _interest.Generation();
}

// --- Syncer delegation ---
// Server only. Runs proximity election over delegatable entities and calls SetOwner on a
// change, cadence-gated by DelegationParams::electionIntervalMs. Driven from NetworkPeer::Update
// after RebuildInterest; nowMs is MafiaNet::GetTime.
void RunDelegation(uint64_t nowMs);
void SetDelegationParams(const DelegationParams &params) {
_delegationParams = params;
}
const DelegationParams &GetDelegationParams() const {
return _delegationParams;
}

// Server: invoked from OnClosedConnection just before the dropped peer's avatar is destroyed,
// while it is still resolvable. The integration layer wires its player-disconnect notification
// here.
Expand Down Expand Up @@ -151,6 +165,9 @@ namespace Framework::Networking::Replication {
NetworkPeer *_owner = nullptr;
bool _clientRPCsRegistered = false;
InterestGrid _interest;
DelegationParams _delegationParams;
uint64_t _lastDelegationMs = 0; // last election pass; gates the cadence
bool _groundXY = false; // election distance plane, mirrored from the interest grid
std::unordered_map<MafiaNet::PeerGuid, NetworkEntity *> _viewers;
fu2::function<void(MafiaNet::PeerGuid) const> _onClientDisconnect;
fu2::function<void(uint64_t) const> _onEntityCreated;
Expand Down
4 changes: 3 additions & 1 deletion code/tests/framework_ut.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* See LICENSE file in the source repository for information regarding licensing.
*/

#define UNIT_MAX_MODULES 12
#define UNIT_MAX_MODULES 13
#include "logging/logger.h"
#include "unit.h"

Expand All @@ -17,6 +17,7 @@
#include "modules/network_packets_ut.h"
#include "modules/state_machine_ut.h"
#include "modules/persistent_config_ut.h"
#include "modules/delegation_ut.h"

// Scripting tests
#include "modules/engine_ut.h"
Expand All @@ -36,6 +37,7 @@ int main() {
UNIT_MODULE(network_packets);
UNIT_MODULE(state_machine);
UNIT_MODULE(persistent_config);
UNIT_MODULE(delegation);

// Scripting tests
UNIT_MODULE(engine);
Expand Down
Loading
Loading