From 62ec331fe10975f2a58305959f11964dac489945 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:54:25 +0200 Subject: [PATCH 1/3] Replication: add authority delegation (elected syncer) Server-side proximity election that hands a non-owned entity's authority to a nearby client -- the syncer -- reusing the existing ownerGUID model. Adds a Delegation trait (opt-in delegatable flag, OrphanMode, pinned owner), an OnOwnershipChanged client hook, and a CanDelegateTo veto. The decision is a pure, testable ElectOwner policy (acquire/drop hysteresis, least-loaded with nearest tiebreak, soft cap), driven each interest rebuild by ReplicationManager::RunDelegation and applied via SetOwner. Orphans fall back to the server (frozen) or destroy per OrphanMode. --- .../framework/src/networking/network_peer.cpp | 5 +- .../replication/delegation_policy.h | 102 ++++++++++++++++++ .../networking/replication/network_entity.cpp | 29 ++++- .../networking/replication/network_entity.h | 48 +++++++++ .../replication/replication_manager.cpp | 88 ++++++++++++++- .../replication/replication_manager.h | 21 ++++ 6 files changed, 288 insertions(+), 5 deletions(-) create mode 100644 code/framework/src/networking/replication/delegation_policy.h diff --git a/code/framework/src/networking/network_peer.cpp b/code/framework/src/networking/network_peer.cpp index 40ad6fe26..51486140d 100644 --- a/code/framework/src/networking/network_peer.cpp +++ b/code/framework/src/networking/network_peer.cpp @@ -12,6 +12,7 @@ #include "replication/replication_manager.h" #include +#include namespace Framework::Networking { NetworkPeer::NetworkPeer() { @@ -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 run + // syncer election off the fresh positions (both server-only no-ops on a client). if (_replicationManager) { _replicationManager->RebuildInterest(); + _replicationManager->RunDelegation(MafiaNet::GetTime()); } for (_packet = _peer->Receive(); _packet; _peer->DeallocatePacket(_packet), _packet = _peer->Receive()) { diff --git a/code/framework/src/networking/replication/delegation_policy.h b/code/framework/src/networking/replication/delegation_policy.h new file mode 100644 index 000000000..8be770af9 --- /dev/null +++ b/code/framework/src/networking/replication/delegation_policy.h @@ -0,0 +1,102 @@ +/* + * 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 + +#include + +#include +#include + +namespace Framework::Networking::Replication { + // Tunables for proximity syncer election. Distances are world units, measured on the ground plane + // (see ElectOwner's groundXY). The acquire/drop split is the anti-thrash hysteresis: a candidate + // must come within acquireRange to be newly elected, but the current owner is kept until it drifts + // past the wider dropRange — so an owner hovering at the boundary is not dropped-and-reacquired + // every pass. + struct DelegationParams { + float acquireRange = 80.0f; + float dropRange = 130.0f; + uint32_t electionIntervalMs = 500; + // Soft cap on how many delegated entities one client is newly given (0 = unlimited). The load + // balancer already prefers the least-loaded candidate; this refuses to pile more onto a client + // already at the cap when a lighter one exists, but never revokes what it holds. + int loadSoftCap = 0; + }; + + // One election candidate: a connected client that could simulate the entity, with its viewer + // position, how many delegated entities it already owns (load balancing), and whether the game or + // dimension check vetoes it (applied by the caller before handing the candidate in). + struct DelegationCandidate { + MafiaNet::PeerGuid guid = MafiaNet::UNASSIGNED_PEER_GUID; + glm::vec3 position {0.0f}; + int ownedLoad = 0; + bool eligible = true; + }; + + // Pure election decision — no networking, so it is unit-testable in isolation and the same rule + // serves any Framework mod. Given the entity position, its current owner, and the candidate set, + // returns the guid that should own it next: + // - keeps the current owner while it stays eligible and within dropRange (hysteresis), + // - otherwise elects the best candidate within acquireRange (fewest owned, nearest as tiebreak), + // - returns UNASSIGNED when none qualifies, so the server keeps the entity (frozen). + // groundXY selects the second ground-plane axis (Y for Z-up games like Mafia 2, else Z) so height + // never bleeds into the distance test. + inline MafiaNet::PeerGuid ElectOwner(const glm::vec3 &entityPos, MafiaNet::PeerGuid currentOwner, const std::vector &candidates, const DelegationParams ¶ms, 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; + }; + + // Hold the current owner while it is still eligible and inside the wider drop radius. Load is + // not re-checked here: an owner keeps what it already simulates regardless of the soft cap. + 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; // found the current owner but it no longer qualifies: fall through to re-elect + } + } + + // Elect afresh: the least-loaded eligible candidate within acquireRange, nearest breaking ties. + 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 diff --git a/code/framework/src/networking/replication/network_entity.cpp b/code/framework/src/networking/replication/network_entity.cpp index 0c80ea036..1a664240d 100644 --- a/code/framework/src/networking/replication/network_entity.cpp +++ b/code/framework/src/networking/replication/network_entity.cpp @@ -42,9 +42,25 @@ 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; + // dictate it back (SetOwnerFromServer no-ops on the server); clients adopt what the server + // sends and fire the ownership-change hook on a flip. + SetOwnerFromServer(incomingOwner); + } + + void NetworkEntity::SetOwnerFromServer(MafiaNet::PeerGuid newOwner) { + if (IsServerPeer()) { + return; + } + const bool was = IsOwner(); + ownerGUID = newOwner; + // During construction the pose/state fields aren't read yet; DeserializeConstruction fires the + // gained-ownership callback once at the end instead, so a seeding handler sees the real pose. + if (_constructing) { + return; + } + const bool now = IsOwner(); + if (now != was) { + OnOwnershipChanged(now); } } @@ -94,12 +110,19 @@ 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 now that the pose/state are populated (SetOwnerFromServer deferred it): + // covers an entity that arrives already owned by us, e.g. reconnect resuming a delegation. + if (!IsServerPeer() && IsOwner()) { + OnOwnershipChanged(true); + } return true; } diff --git a/code/framework/src/networking/replication/network_entity.h b/code/framework/src/networking/replication/network_entity.h index a498a4149..7067b8e86 100644 --- a/code/framework/src/networking/replication/network_entity.h +++ b/code/framework/src/networking/replication/network_entity.h @@ -137,6 +137,28 @@ namespace Framework::Networking::Replication { }; Streaming streaming; + // What becomes of a delegatable entity the server can no longer hand to any client (owner + // dropped, or nobody in range): keep it, replicated frozen from the server, or destroy it. + enum class OrphanMode : uint8_t { + Freeze, // return to the server (UNASSIGNED) and keep replicating its last pose (default) + Destroy, // remove it when no client can simulate it + }; + + // --- Server-only delegation metadata (never replicated) --- + // A delegatable entity is server-owned at rest but its physics/behaviour is handed to a + // nearby client (the elected "syncer") that simulates it and streams state up, the server + // relaying to everyone else. Election lives in ReplicationManager::RunDelegation; this struct + // only marks an entity as a candidate and carries its policy. Opt-in: plain entities + // (players, server-driven singletons) leave delegatable false and are untouched by election. + struct Delegation { + bool delegatable = false; + OrphanMode orphanMode = OrphanMode::Freeze; + // Pin the syncer to a specific client, bypassing proximity election (scripted override, + // à la MTASA's persistent syncer). UNASSIGNED means elect normally. + MafiaNet::PeerGuid pinnedOwner = MafiaNet::UNASSIGNED_PEER_GUID; + }; + Delegation delegation; + // --- Game extension points --- virtual void OnSerializeConstruction(FieldSerializer &fields) { (void)fields; @@ -164,6 +186,21 @@ namespace Framework::Networking::Replication { // Called on the owning client after SerializeForcedState has applied the forced fields. virtual void OnStateForced() {} + // Fired on a client when authority over this entity crosses our peer: nowOwner=true when we + // gained it (spin up the local simulation — the replicated position/velocity/rotation already + // hold the latest pose, so seed from them), false when we lost it (tear the local sim down). + // Never fires on the server. The seam a delegated entity uses to start/stop simulating. + virtual void OnOwnershipChanged(bool nowOwner) { + (void)nowOwner; + } + + // Server: game veto on electing `candidate` as this entity's syncer. Proximity and dimension + // are already applied by RunDelegation; override to add game rules (role, line-of-sight, ...). + 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(); @@ -174,6 +211,12 @@ namespace Framework::Networking::Replication { // MafiaNet::UNASSIGNED_PEER_GUID to return ownership to the server. void SetOwner(MafiaNet::PeerGuid guid); + // Client-side: adopt an owner value pushed by the server (over the wire or the SetOwner RPC), + // firing OnOwnershipChanged when our authority flips. No-op on the server, which is + // authoritative and never adopts. The single sink for client owner changes so the transition + // callback cannot be bypassed. See AdoptIncomingOwner for the construction/delta path. + 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. @@ -226,6 +269,11 @@ namespace Framework::Networking::Replication { // applied. bool ApplyIncomingEpoch(uint8_t incomingEpoch); + // True only while DeserializeConstruction runs: suppresses the OnOwnershipChanged callback so + // an entity that arrives already owned by us announces ownership once, at the end of + // construction, after its pose/state fields are populated — not mid-read with a stale pose. + bool _constructing = false; + // CRC32 of the registered name; stamped by EntityRegistry, not game-settable. uint32_t typeId = 0; friend class EntityRegistry; diff --git a/code/framework/src/networking/replication/replication_manager.cpp b/code/framework/src/networking/replication/replication_manager.cpp index ecf378568..cdd5b9782 100644 --- a/code/framework/src/networking/replication/replication_manager.cpp +++ b/code/framework/src/networking/replication/replication_manager.cpp @@ -66,8 +66,10 @@ namespace Framework::Networking::Replication { }); owner->RegisterRPC([this](const SetOwnerRPC &payload, MafiaNet::Packet *) { if (auto *entity = GetEntityByNetworkID(payload.networkId)) { - entity->ownerGUID = payload.ownerGUID; + // Adopt the epoch before the owner flip: a gained owner starts sending from inside + // OnOwnershipChanged, and those updates must already carry the current epoch. entity->stateEpoch = payload.stateEpoch; + entity->SetOwnerFromServer(payload.ownerGUID); } }); _clientRPCsRegistered = true; @@ -211,6 +213,90 @@ namespace Framework::Networking::Replication { _interest.CollectVisible(viewer, viewerGUID, out); } + void ReplicationManager::RunDelegation(uint64_t nowMs) { + if (!_isServer) { + return; + } + // Cadence gate: election is coarse (a syncer holds for many ticks), so run it on its own + // interval rather than every tick. uint64 subtraction, so a zeroed clock still passes once. + if (nowMs - _lastDelegationMs < _delegationParams.electionIntervalMs) { + return; + } + _lastDelegationMs = nowMs; + + // How many delegated entities each client currently owns, for load balancing. + std::unordered_map load; + ForEachEntity([&load](NetworkEntity *entity) { + if (entity->delegation.delegatable && entity->ownerGUID != MafiaNet::UNASSIGNED_PEER_GUID) { + ++load[entity->ownerGUID]; + } + }); + + // Orphans whose OrphanMode is Destroy are collected and removed after the sweep: DestroyEntity + // mutates the replica set ForEachEntity walks by index, so deleting mid-iteration would skip + // entities or read past the end. + std::vector toDestroy; + std::vector candidates; + ForEachEntity([&](NetworkEntity *entity) { + if (!entity->delegation.delegatable) { + return; + } + + // A pinned syncer bypasses proximity election entirely (scripted/persistent override). + if (entity->delegation.pinnedOwner != MafiaNet::UNASSIGNED_PEER_GUID) { + if (entity->ownerGUID != entity->delegation.pinnedOwner) { + SetOwner(entity, entity->delegation.pinnedOwner); + } + return; + } + + 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; + } + + // Owner lost with no taker in range: Destroy mode removes it (ambient/relevancy entities), + // else it falls to the server frozen. This fires on a move-away orphan; a disconnect + // orphan is set to UNASSIGNED by OnClosedConnection first, so it settles frozen. + if (next == MafiaNet::UNASSIGNED_PEER_GUID && entity->delegation.orphanMode == NetworkEntity::OrphanMode::Destroy) { + toDestroy.push_back(entity); + return; + } + + SetOwner(entity, next); + // Keep the load map roughly 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 diff --git a/code/framework/src/networking/replication/replication_manager.h b/code/framework/src/networking/replication/replication_manager.h index 0902216cf..54e6877a3 100644 --- a/code/framework/src/networking/replication/replication_manager.h +++ b/code/framework/src/networking/replication/replication_manager.h @@ -8,6 +8,7 @@ #pragma once +#include "delegation_policy.h" #include "entity_registry.h" #include "interest_grid.h" #include "network_entity.h" @@ -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). @@ -116,6 +118,20 @@ namespace Framework::Networking::Replication { return _interest.Generation(); } + // --- Syncer delegation --- + // Server only, driven from NetworkPeer::Update after RebuildInterest (so it sees this tick's + // positions). Runs the proximity election over delegatable entities, cadence-gated by + // DelegationParams::electionIntervalMs, and calls SetOwner when an entity's syncer should + // change. Cheap no-op when no entity opts in (delegatable stays false). `nowMs` is a + // monotonic millisecond clock (MafiaNet::GetTime). + void RunDelegation(uint64_t nowMs); + void SetDelegationParams(const DelegationParams ¶ms) { + _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. @@ -151,6 +167,11 @@ namespace Framework::Networking::Replication { NetworkPeer *_owner = nullptr; bool _clientRPCsRegistered = false; InterestGrid _interest; + DelegationParams _delegationParams; + // Last election pass time (MafiaNet::GetTime ms); gates the cadence in RunDelegation. + uint64_t _lastDelegationMs = 0; + // Ground plane for election distances, mirrored from SetInterestGroundPlaneXY. + bool _groundXY = false; std::unordered_map _viewers; fu2::function _onClientDisconnect; fu2::function _onEntityCreated; From 763fc9e3a31b46346b32a9b285c237d62a53182e Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:54:33 +0200 Subject: [PATCH 2/3] Tests: cover the syncer election policy Unit tests for ElectOwner: nearest pick, load balancing, acquire/drop hysteresis, re-election on drift, orphan fallback, dimension/veto exclusion, load soft cap, and Z-up ground-plane distance. --- code/tests/framework_ut.cpp | 4 +- code/tests/modules/delegation_ut.h | 150 +++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 1 deletion(-) create mode 100644 code/tests/modules/delegation_ut.h diff --git a/code/tests/framework_ut.cpp b/code/tests/framework_ut.cpp index db4cb0d29..c9fafd536 100644 --- a/code/tests/framework_ut.cpp +++ b/code/tests/framework_ut.cpp @@ -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" @@ -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" @@ -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); diff --git a/code/tests/modules/delegation_ut.h b/code/tests/modules/delegation_ut.h new file mode 100644 index 000000000..7f4acdd87 --- /dev/null +++ b/code/tests/modules/delegation_ut.h @@ -0,0 +1,150 @@ +/* + * 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 "networking/replication/delegation_policy.h" + +#include +#include + +// Unit coverage for the pure syncer-election policy (ElectOwner). No networking is involved, so the +// hysteresis, load-balancing, proximity and veto rules are exercised in isolation. +MODULE(delegation, { + using namespace Framework::Networking::Replication; + + const MafiaNet::PeerGuid kNone = MafiaNet::UNASSIGNED_PEER_GUID; + const auto guid = [](uint64_t v) { + return static_cast(v); + }; + const auto raw = [](MafiaNet::PeerGuid g) { + return static_cast(g); + }; + const auto candidate = [](MafiaNet::PeerGuid g, float x, float z, int load, bool eligible) { + DelegationCandidate c; + c.guid = g; + c.position = glm::vec3(x, 0.0f, z); + c.ownedLoad = load; + c.eligible = eligible; + return c; + }; + + // Default policy: acquire 80, drop 130. Entity sits at the origin. Distances are on the XZ plane + // (groundXY=false) unless a test overrides it. + DelegationParams params; + const glm::vec3 origin(0.0f, 0.0f, 0.0f); + + IT("elects the nearest eligible candidate when the entity is unowned", { + std::vector c = { + candidate(guid(1), 60.0f, 0.0f, 0, true), + candidate(guid(2), 30.0f, 0.0f, 0, true), + }; + const auto next = ElectOwner(origin, kNone, c, params, false); + UEQUALS(raw(next), raw(guid(2))); + }); + + IT("prefers the least-loaded candidate over a nearer but busier one", { + std::vector c = { + candidate(guid(1), 20.0f, 0.0f, 3, true), // closest but heavily loaded + candidate(guid(2), 70.0f, 0.0f, 0, true), // farther but idle + }; + const auto next = ElectOwner(origin, kNone, c, params, false); + UEQUALS(raw(next), raw(guid(2))); + }); + + IT("keeps the current owner inside dropRange even past acquireRange (hysteresis)", { + // Owner at 100: beyond acquire (80) but within drop (130), so it must not be re-elected away + // even though a fresh candidate sits closer. + std::vector c = { + candidate(guid(1), 100.0f, 0.0f, 0, true), // current owner, in the hysteresis band + candidate(guid(2), 40.0f, 0.0f, 0, true), // closer newcomer + }; + const auto next = ElectOwner(origin, guid(1), c, params, false); + UEQUALS(raw(next), raw(guid(1))); + }); + + IT("re-elects when the current owner drifts past dropRange", { + std::vector c = { + candidate(guid(1), 140.0f, 0.0f, 0, true), // owner, now out of drop range + candidate(guid(2), 50.0f, 0.0f, 0, true), // eligible taker within acquire + }; + const auto next = ElectOwner(origin, guid(1), c, params, false); + UEQUALS(raw(next), raw(guid(2))); + }); + + IT("returns UNASSIGNED when no candidate is within acquireRange", { + std::vector c = { + candidate(guid(1), 90.0f, 0.0f, 0, true), + candidate(guid(2), 200.0f, 0.0f, 0, true), + }; + const auto next = ElectOwner(origin, kNone, c, params, false); + UEQUALS(raw(next), raw(kNone)); + }); + + IT("orphans the entity when the drifted owner has no taker in range", { + // Owner past drop and the only other candidate is out of acquire: falls back to the server. + std::vector c = { + candidate(guid(1), 140.0f, 0.0f, 0, true), + candidate(guid(2), 120.0f, 0.0f, 0, true), + }; + const auto next = ElectOwner(origin, guid(1), c, params, false); + UEQUALS(raw(next), raw(kNone)); + }); + + IT("ignores ineligible candidates (dimension / game veto)", { + std::vector c = { + candidate(guid(1), 20.0f, 0.0f, 0, false), // closest but vetoed + candidate(guid(2), 70.0f, 0.0f, 0, true), + }; + const auto next = ElectOwner(origin, kNone, c, params, false); + UEQUALS(raw(next), raw(guid(2))); + }); + + IT("does not keep a current owner that has become ineligible", { + // Owner still within drop range but vetoed/left the dimension: must hand off, not stick. + std::vector c = { + candidate(guid(1), 50.0f, 0.0f, 0, false), + candidate(guid(2), 60.0f, 0.0f, 0, true), + }; + const auto next = ElectOwner(origin, guid(1), c, params, false); + UEQUALS(raw(next), raw(guid(2))); + }); + + IT("skips candidates at the load soft cap when a lighter one exists", { + params.loadSoftCap = 2; + std::vector c = { + candidate(guid(1), 20.0f, 0.0f, 2, true), // closest but at the cap + candidate(guid(2), 70.0f, 0.0f, 1, true), // under the cap + }; + const auto next = ElectOwner(origin, kNone, c, params, false); + UEQUALS(raw(next), raw(guid(2))); + params.loadSoftCap = 0; + }); + + IT("keeps the current owner even when it is over the soft cap", { + // The cap gates new grants, never revokes what a client already simulates. + params.loadSoftCap = 1; + std::vector c = { + candidate(guid(1), 100.0f, 0.0f, 5, true), // owner in hysteresis band, over cap + candidate(guid(2), 40.0f, 0.0f, 0, true), + }; + const auto next = ElectOwner(origin, guid(1), c, params, false); + UEQUALS(raw(next), raw(guid(1))); + params.loadSoftCap = 0; + }); + + IT("measures distance on the XY plane when groundXY is set (Z-up)", { + // A large Z separation must not count as distance on a Z-up game: the candidate is at XY + // distance 0, so it is in range despite z=1000. + std::vector c = { + {guid(1), glm::vec3(0.0f, 0.0f, 1000.0f), 0, true}, + }; + const auto next = ElectOwner(origin, kNone, c, params, true); + UEQUALS(raw(next), raw(guid(1))); + }); +}); From 85455feeaa2d43d0ea83dae088c742577af344f3 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:00:46 +0200 Subject: [PATCH 3/3] Replication: trim delegation comments to house style Cut the narration to terse invariants and drop the third-party mod reference from the source. --- .../framework/src/networking/network_peer.cpp | 4 +- .../replication/delegation_policy.h | 35 +++++----------- .../networking/replication/network_entity.cpp | 9 +---- .../networking/replication/network_entity.h | 40 ++++++------------- .../replication/replication_manager.cpp | 18 +++------ .../replication/replication_manager.h | 14 +++---- code/tests/modules/delegation_ut.h | 15 ++----- 7 files changed, 41 insertions(+), 94 deletions(-) diff --git a/code/framework/src/networking/network_peer.cpp b/code/framework/src/networking/network_peer.cpp index 51486140d..925b1f646 100644 --- a/code/framework/src/networking/network_peer.cpp +++ b/code/framework/src/networking/network_peer.cpp @@ -66,8 +66,8 @@ namespace Framework::Networking { return; } - // Rebuild the spatial index before ReplicaManager3 computes per-connection relevance, then run - // syncer election off the fresh positions (both server-only no-ops on a client). + // 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()); diff --git a/code/framework/src/networking/replication/delegation_policy.h b/code/framework/src/networking/replication/delegation_policy.h index 8be770af9..336512dcb 100644 --- a/code/framework/src/networking/replication/delegation_policy.h +++ b/code/framework/src/networking/replication/delegation_policy.h @@ -16,39 +16,26 @@ #include namespace Framework::Networking::Replication { - // Tunables for proximity syncer election. Distances are world units, measured on the ground plane - // (see ElectOwner's groundXY). The acquire/drop split is the anti-thrash hysteresis: a candidate - // must come within acquireRange to be newly elected, but the current owner is kept until it drifts - // past the wider dropRange — so an owner hovering at the boundary is not dropped-and-reacquired - // every pass. + // 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; - // Soft cap on how many delegated entities one client is newly given (0 = unlimited). The load - // balancer already prefers the least-loaded candidate; this refuses to pile more onto a client - // already at the cap when a lighter one exists, but never revokes what it holds. - int loadSoftCap = 0; + int loadSoftCap = 0; // max delegated entities newly granted per client (0 = off) }; - // One election candidate: a connected client that could simulate the entity, with its viewer - // position, how many delegated entities it already owns (load balancing), and whether the game or - // dimension check vetoes it (applied by the caller before handing the candidate in). struct DelegationCandidate { MafiaNet::PeerGuid guid = MafiaNet::UNASSIGNED_PEER_GUID; glm::vec3 position {0.0f}; int ownedLoad = 0; - bool eligible = true; + bool eligible = true; // caller-applied dimension + game veto }; - // Pure election decision — no networking, so it is unit-testable in isolation and the same rule - // serves any Framework mod. Given the entity position, its current owner, and the candidate set, - // returns the guid that should own it next: - // - keeps the current owner while it stays eligible and within dropRange (hysteresis), - // - otherwise elects the best candidate within acquireRange (fewest owned, nearest as tiebreak), - // - returns UNASSIGNED when none qualifies, so the server keeps the entity (frozen). - // groundXY selects the second ground-plane axis (Y for Z-up games like Mafia 2, else Z) so height - // never bleeds into the distance test. + // 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 &candidates, const DelegationParams ¶ms, bool groundXY) { const auto ground = [groundXY](const glm::vec3 &p) { return groundXY ? p.y : p.z; @@ -59,8 +46,7 @@ namespace Framework::Networking::Replication { return dx * dx + dv * dv; }; - // Hold the current owner while it is still eligible and inside the wider drop radius. Load is - // not re-checked here: an owner keeps what it already simulates regardless of the soft cap. + // 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) { @@ -70,11 +56,10 @@ namespace Framework::Networking::Replication { if (candidate.eligible && dist2(candidate.position) <= dropSq) { return currentOwner; } - break; // found the current owner but it no longer qualifies: fall through to re-elect + break; } } - // Elect afresh: the least-loaded eligible candidate within acquireRange, nearest breaking ties. const float acquireSq = params.acquireRange * params.acquireRange; MafiaNet::PeerGuid best = MafiaNet::UNASSIGNED_PEER_GUID; int bestLoad = 0; diff --git a/code/framework/src/networking/replication/network_entity.cpp b/code/framework/src/networking/replication/network_entity.cpp index 1a664240d..0ee6cb88b 100644 --- a/code/framework/src/networking/replication/network_entity.cpp +++ b/code/framework/src/networking/replication/network_entity.cpp @@ -41,9 +41,6 @@ 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 (SetOwnerFromServer no-ops on the server); clients adopt what the server - // sends and fire the ownership-change hook on a flip. SetOwnerFromServer(incomingOwner); } @@ -53,8 +50,7 @@ namespace Framework::Networking::Replication { } const bool was = IsOwner(); ownerGUID = newOwner; - // During construction the pose/state fields aren't read yet; DeserializeConstruction fires the - // gained-ownership callback once at the end instead, so a seeding handler sees the real pose. + // Construction defers the callback (see DeserializeConstruction) until the pose is read. if (_constructing) { return; } @@ -118,8 +114,7 @@ namespace Framework::Networking::Replication { SerializeFields(seed); _constructing = false; OnConstructed(); - // Announce ownership now that the pose/state are populated (SetOwnerFromServer deferred it): - // covers an entity that arrives already owned by us, e.g. reconnect resuming a delegation. + // Announce ownership if we arrive already owning it, now that the pose is populated. if (!IsServerPeer() && IsOwner()) { OnOwnershipChanged(true); } diff --git a/code/framework/src/networking/replication/network_entity.h b/code/framework/src/networking/replication/network_entity.h index 7067b8e86..6f19e1474 100644 --- a/code/framework/src/networking/replication/network_entity.h +++ b/code/framework/src/networking/replication/network_entity.h @@ -137,24 +137,17 @@ namespace Framework::Networking::Replication { }; Streaming streaming; - // What becomes of a delegatable entity the server can no longer hand to any client (owner - // dropped, or nobody in range): keep it, replicated frozen from the server, or destroy it. + // Fate of a delegatable entity when no client can take it. enum class OrphanMode : uint8_t { - Freeze, // return to the server (UNASSIGNED) and keep replicating its last pose (default) - Destroy, // remove it when no client can simulate it + Freeze, // return to the server (UNASSIGNED), keep replicating its last pose + Destroy, // remove it }; - // --- Server-only delegation metadata (never replicated) --- - // A delegatable entity is server-owned at rest but its physics/behaviour is handed to a - // nearby client (the elected "syncer") that simulates it and streams state up, the server - // relaying to everyone else. Election lives in ReplicationManager::RunDelegation; this struct - // only marks an entity as a candidate and carries its policy. Opt-in: plain entities - // (players, server-driven singletons) leave delegatable false and are untouched by election. + // 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 specific client, bypassing proximity election (scripted override, - // à la MTASA's persistent syncer). UNASSIGNED means elect normally. + 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; @@ -186,16 +179,13 @@ namespace Framework::Networking::Replication { // Called on the owning client after SerializeForcedState has applied the forced fields. virtual void OnStateForced() {} - // Fired on a client when authority over this entity crosses our peer: nowOwner=true when we - // gained it (spin up the local simulation — the replicated position/velocity/rotation already - // hold the latest pose, so seed from them), false when we lost it (tear the local sim down). - // Never fires on the server. The seam a delegated entity uses to start/stop simulating. + // 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: game veto on electing `candidate` as this entity's syncer. Proximity and dimension - // are already applied by RunDelegation; override to add game rules (role, line-of-sight, ...). + // Server: veto electing `candidate` as syncer (proximity/dimension already applied). virtual bool CanDelegateTo(MafiaNet::PeerGuid candidate) const { (void)candidate; return true; @@ -211,10 +201,8 @@ namespace Framework::Networking::Replication { // MafiaNet::UNASSIGNED_PEER_GUID to return ownership to the server. void SetOwner(MafiaNet::PeerGuid guid); - // Client-side: adopt an owner value pushed by the server (over the wire or the SetOwner RPC), - // firing OnOwnershipChanged when our authority flips. No-op on the server, which is - // authoritative and never adopts. The single sink for client owner changes so the transition - // callback cannot be bypassed. See AdoptIncomingOwner for the construction/delta path. + // 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 @@ -269,9 +257,7 @@ namespace Framework::Networking::Replication { // applied. bool ApplyIncomingEpoch(uint8_t incomingEpoch); - // True only while DeserializeConstruction runs: suppresses the OnOwnershipChanged callback so - // an entity that arrives already owned by us announces ownership once, at the end of - // construction, after its pose/state fields are populated — not mid-read with a stale pose. + // 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. diff --git a/code/framework/src/networking/replication/replication_manager.cpp b/code/framework/src/networking/replication/replication_manager.cpp index cdd5b9782..fa101fe5d 100644 --- a/code/framework/src/networking/replication/replication_manager.cpp +++ b/code/framework/src/networking/replication/replication_manager.cpp @@ -66,8 +66,7 @@ namespace Framework::Networking::Replication { }); owner->RegisterRPC([this](const SetOwnerRPC &payload, MafiaNet::Packet *) { if (auto *entity = GetEntityByNetworkID(payload.networkId)) { - // Adopt the epoch before the owner flip: a gained owner starts sending from inside - // OnOwnershipChanged, and those updates must already carry the current epoch. + // Epoch before the flip: a gained owner sends from inside OnOwnershipChanged. entity->stateEpoch = payload.stateEpoch; entity->SetOwnerFromServer(payload.ownerGUID); } @@ -217,14 +216,12 @@ namespace Framework::Networking::Replication { if (!_isServer) { return; } - // Cadence gate: election is coarse (a syncer holds for many ticks), so run it on its own - // interval rather than every tick. uint64 subtraction, so a zeroed clock still passes once. if (nowMs - _lastDelegationMs < _delegationParams.electionIntervalMs) { return; } _lastDelegationMs = nowMs; - // How many delegated entities each client currently owns, for load balancing. + // Delegated entities each client owns, for load balancing. std::unordered_map load; ForEachEntity([&load](NetworkEntity *entity) { if (entity->delegation.delegatable && entity->ownerGUID != MafiaNet::UNASSIGNED_PEER_GUID) { @@ -232,9 +229,7 @@ namespace Framework::Networking::Replication { } }); - // Orphans whose OrphanMode is Destroy are collected and removed after the sweep: DestroyEntity - // mutates the replica set ForEachEntity walks by index, so deleting mid-iteration would skip - // entities or read past the end. + // Destroy deferred past the sweep: DestroyEntity mutates the index ForEachEntity walks. std::vector toDestroy; std::vector candidates; ForEachEntity([&](NetworkEntity *entity) { @@ -242,7 +237,7 @@ namespace Framework::Networking::Replication { return; } - // A pinned syncer bypasses proximity election entirely (scripted/persistent override). + // A pinned syncer bypasses election. if (entity->delegation.pinnedOwner != MafiaNet::UNASSIGNED_PEER_GUID) { if (entity->ownerGUID != entity->delegation.pinnedOwner) { SetOwner(entity, entity->delegation.pinnedOwner); @@ -272,16 +267,13 @@ namespace Framework::Networking::Replication { return; } - // Owner lost with no taker in range: Destroy mode removes it (ambient/relevancy entities), - // else it falls to the server frozen. This fires on a move-away orphan; a disconnect - // orphan is set to UNASSIGNED by OnClosedConnection first, so it settles frozen. if (next == MafiaNet::UNASSIGNED_PEER_GUID && entity->delegation.orphanMode == NetworkEntity::OrphanMode::Destroy) { toDestroy.push_back(entity); return; } SetOwner(entity, next); - // Keep the load map roughly current within the pass so later entities balance against it. + // 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); diff --git a/code/framework/src/networking/replication/replication_manager.h b/code/framework/src/networking/replication/replication_manager.h index 54e6877a3..224b20ee9 100644 --- a/code/framework/src/networking/replication/replication_manager.h +++ b/code/framework/src/networking/replication/replication_manager.h @@ -119,11 +119,9 @@ namespace Framework::Networking::Replication { } // --- Syncer delegation --- - // Server only, driven from NetworkPeer::Update after RebuildInterest (so it sees this tick's - // positions). Runs the proximity election over delegatable entities, cadence-gated by - // DelegationParams::electionIntervalMs, and calls SetOwner when an entity's syncer should - // change. Cheap no-op when no entity opts in (delegatable stays false). `nowMs` is a - // monotonic millisecond clock (MafiaNet::GetTime). + // 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 ¶ms) { _delegationParams = params; @@ -168,10 +166,8 @@ namespace Framework::Networking::Replication { bool _clientRPCsRegistered = false; InterestGrid _interest; DelegationParams _delegationParams; - // Last election pass time (MafiaNet::GetTime ms); gates the cadence in RunDelegation. - uint64_t _lastDelegationMs = 0; - // Ground plane for election distances, mirrored from SetInterestGroundPlaneXY. - bool _groundXY = false; + uint64_t _lastDelegationMs = 0; // last election pass; gates the cadence + bool _groundXY = false; // election distance plane, mirrored from the interest grid std::unordered_map _viewers; fu2::function _onClientDisconnect; fu2::function _onEntityCreated; diff --git a/code/tests/modules/delegation_ut.h b/code/tests/modules/delegation_ut.h index 7f4acdd87..3e1c3df49 100644 --- a/code/tests/modules/delegation_ut.h +++ b/code/tests/modules/delegation_ut.h @@ -13,8 +13,7 @@ #include #include -// Unit coverage for the pure syncer-election policy (ElectOwner). No networking is involved, so the -// hysteresis, load-balancing, proximity and veto rules are exercised in isolation. +// Coverage for the pure syncer-election policy (ElectOwner). MODULE(delegation, { using namespace Framework::Networking::Replication; @@ -34,8 +33,7 @@ MODULE(delegation, { return c; }; - // Default policy: acquire 80, drop 130. Entity sits at the origin. Distances are on the XZ plane - // (groundXY=false) unless a test overrides it. + // Defaults: acquire 80, drop 130; entity at the origin, XZ plane unless a test overrides. DelegationParams params; const glm::vec3 origin(0.0f, 0.0f, 0.0f); @@ -58,10 +56,8 @@ MODULE(delegation, { }); IT("keeps the current owner inside dropRange even past acquireRange (hysteresis)", { - // Owner at 100: beyond acquire (80) but within drop (130), so it must not be re-elected away - // even though a fresh candidate sits closer. std::vector c = { - candidate(guid(1), 100.0f, 0.0f, 0, true), // current owner, in the hysteresis band + candidate(guid(1), 100.0f, 0.0f, 0, true), // owner, in the hysteresis band (80..130) candidate(guid(2), 40.0f, 0.0f, 0, true), // closer newcomer }; const auto next = ElectOwner(origin, guid(1), c, params, false); @@ -106,7 +102,6 @@ MODULE(delegation, { }); IT("does not keep a current owner that has become ineligible", { - // Owner still within drop range but vetoed/left the dimension: must hand off, not stick. std::vector c = { candidate(guid(1), 50.0f, 0.0f, 0, false), candidate(guid(2), 60.0f, 0.0f, 0, true), @@ -127,7 +122,6 @@ MODULE(delegation, { }); IT("keeps the current owner even when it is over the soft cap", { - // The cap gates new grants, never revokes what a client already simulates. params.loadSoftCap = 1; std::vector c = { candidate(guid(1), 100.0f, 0.0f, 5, true), // owner in hysteresis band, over cap @@ -139,8 +133,7 @@ MODULE(delegation, { }); IT("measures distance on the XY plane when groundXY is set (Z-up)", { - // A large Z separation must not count as distance on a Z-up game: the candidate is at XY - // distance 0, so it is in range despite z=1000. + // XY distance 0 despite z=1000, so in range. std::vector c = { {guid(1), glm::vec3(0.0f, 0.0f, 1000.0f), 0, true}, };