From 7f45cf2c2df97c6de72791548f8218fbe27d28af Mon Sep 17 00:00:00 2001 From: KHeartz Date: Mon, 12 Oct 2026 18:21:41 -0400 Subject: [PATCH 1/6] Replication: Keep a stale owner update's unforced fields The server dropped a whole owner update stamped before its last ForceState, but the owner sends each change only once, so anything it measured in that window was lost for good (a player's death, among others). The update is now applied and only the forced fields are put back, which is all the epoch has to protect. --- .../src/networking/replication/delegation.h | 2 +- .../networking/replication/network_entity.cpp | 25 +++++--- .../networking/replication/network_entity.h | 10 ++-- .../replication/replication_manager.h | 5 +- code/tests/modules/replication_authority_ut.h | 58 ++++++++++++++++++- 5 files changed, 80 insertions(+), 20 deletions(-) diff --git a/code/framework/src/networking/replication/delegation.h b/code/framework/src/networking/replication/delegation.h index 03528aa16..65066c55a 100644 --- a/code/framework/src/networking/replication/delegation.h +++ b/code/framework/src/networking/replication/delegation.h @@ -97,7 +97,7 @@ namespace Framework::Networking::Replication { // It is deliberately built on the ownership the replication layer already has rather than beside // it: the elected client becomes the entity's owner, so it inherits the whole authority model -- // its updates travel upstream and are relayed, the server's overrides reach it through - // ForceState, stale updates from a revoked simulator are dropped by the state epoch, and the + // ForceState, updates still in flight from a revoked simulator are refused, and the // pose ordering is reset on the handover. A game's own code asks `IsOwner()` and nothing else. // // The entity opts in by overriding NetworkEntity::GetDelegationPolicy. Nothing else is required; diff --git a/code/framework/src/networking/replication/network_entity.cpp b/code/framework/src/networking/replication/network_entity.cpp index cb53b4150..ced7ebb89 100644 --- a/code/framework/src/networking/replication/network_entity.cpp +++ b/code/framework/src/networking/replication/network_entity.cpp @@ -313,17 +313,26 @@ namespace Framework::Networking::Replication { } } - // Channel 1 — state. + // Channel 1 — state. A stale update is still applied: the owner sends each change only once, so + // dropping it would lose it for good. Only the forced fields are put back afterwards. if (deserializeParameters->bitstreamWrittenTo[kStateChannel]) { uint8_t incomingEpoch = stateEpoch; deserializeParameters->serializationBitstream[kStateChannel].Read(incomingEpoch); - if (ApplyIncomingEpoch(incomingEpoch)) { - MafiaNet::VariableDeltaSerializer::DeserializationContext ctx; - _vds.BeginDeserialize(&ctx, &deserializeParameters->serializationBitstream[kStateChannel]); - FieldSerializer fields(&_vds, &ctx, !IsServerPeer()); - SerializeBaseFields(fields); - SerializeFields(fields); - _vds.EndDeserialize(&ctx); + const bool stale = !ApplyIncomingEpoch(incomingEpoch); + MafiaNet::BitStream forced; + if (stale) { + FieldSerializer keep(&forced, true); + SerializeForcedState(keep); + } + MafiaNet::VariableDeltaSerializer::DeserializationContext ctx; + _vds.BeginDeserialize(&ctx, &deserializeParameters->serializationBitstream[kStateChannel]); + FieldSerializer fields(&_vds, &ctx, !IsServerPeer()); + SerializeBaseFields(fields); + SerializeFields(fields); + _vds.EndDeserialize(&ctx); + if (stale) { + FieldSerializer restore(&forced, false); + SerializeForcedState(restore); } } diff --git a/code/framework/src/networking/replication/network_entity.h b/code/framework/src/networking/replication/network_entity.h index 63dda9195..def682c67 100644 --- a/code/framework/src/networking/replication/network_entity.h +++ b/code/framework/src/networking/replication/network_entity.h @@ -187,9 +187,9 @@ namespace Framework::Networking::Replication { // Fences server overrides against in-flight owner updates: bumped by the server on // ForceState, adopted by the owner from the ForceState/SetOwner RPCs and echoed back in its - // updates; the server drops owner state carrying a stale value (sent before the owner saw the - // override) instead of letting it revert the forced state. Equality-checked, so uint8 wrap is - // harmless. Managed by ReplicationManager — game code never touches it. + // updates; an owner update carrying a stale value (sent before the owner saw the override) + // loses its pose and its forced fields, so it cannot revert the forced state. Equality-checked, + // so uint8 wrap is harmless. Managed by ReplicationManager — game code never touches it. uint8_t stateEpoch = 0; // Local-clock send time of the last applied update (MafiaNet shifts it on receipt). Not replicated. @@ -385,8 +385,8 @@ namespace Framework::Networking::Replication { void SerializeBaseFields(FieldSerializer &fields); // Apply an epoch received over the wire: clients adopt it; the server compares it and - // returns false when the update is stale (sent before the last ForceState) and must not be - // applied. + // returns false when the update is stale (sent before the last ForceState), so its pose and + // forced fields must not be applied. bool ApplyIncomingEpoch(uint8_t incomingEpoch); // --- State-bag plumbing, reached only by the bag itself --- diff --git a/code/framework/src/networking/replication/replication_manager.h b/code/framework/src/networking/replication/replication_manager.h index b89be774e..82b0b53ab 100644 --- a/code/framework/src/networking/replication/replication_manager.h +++ b/code/framework/src/networking/replication/replication_manager.h @@ -109,9 +109,8 @@ namespace Framework::Networking::Replication { // Server: push the entity's forced state to its owner — the server's authoritative override // of an owned entity (see NetworkEntity::ForceState / OnStateForced). Bumps the entity's - // state epoch so owner updates sent before the override arrives are dropped instead of - // reverting it. No-op on clients and for unowned entities (which already replicate to - // everyone). + // state epoch so owner updates sent before the override arrives cannot revert it. No-op on + // clients and for unowned entities (which already replicate to everyone). void ForceState(NetworkEntity *entity); // Server: change an entity's owner and notify the new owner directly (see diff --git a/code/tests/modules/replication_authority_ut.h b/code/tests/modules/replication_authority_ut.h index 60ff9490d..403b7f66f 100644 --- a/code/tests/modules/replication_authority_ut.h +++ b/code/tests/modules/replication_authority_ut.h @@ -203,13 +203,28 @@ MODULE(replication_authority, { } }; - // One entity's state channel, serialized the way its peer writes it and delivered to another. - const auto deliverState = [](VerdictEntity &from, VerdictEntity &to, MafiaNet::Connection_RM3 *sourceConnection) { + // An owner-reported value the server can override and push back with ForceState (a game's effect + // mask), beside one only the owner measures. + struct ForcedEntity final: NetworkEntity { + int measured = 0; + int forced = 0; + void SerializeFields(Framework::Networking::Replication::FieldSerializer &fields) override { + fields.Field(measured); + fields.Field(forced); + } + void SerializeForcedState(Framework::Networking::Replication::FieldSerializer &fields) override { + fields.Field(forced); + } + }; + + // One entity's state channel, serialized the way its peer writes it and delivered to another. The + // first send to a system carries every field; later ones only what changed since the last. + const auto deliverState = [](auto &from, auto &to, MafiaNet::Connection_RM3 *sourceConnection, bool firstSend = true) { MafiaNet::SerializeParameters sp; for (int i = 0; i < MafiaNet::RM3_NUM_OUTPUT_BITSTREAM_CHANNELS; ++i) { sp.lastSentBitstream[i] = nullptr; } - sp.whenLastSerialized = 0; + sp.whenLastSerialized = firstSend ? 0 : 1; sp.curTime = 1; from.OnUserReplicaPreSerializeTick(); from.Serialize(&sp); @@ -256,6 +271,43 @@ MODULE(replication_authority, { EQUALS(observer.verdict, 2); }); + IT("applies a stale update's measured fields but keeps the ones the server forced", { + ForcedEntity simulator; + simulator.replicaManager = clientManager; + simulator.measured = 1; + simulator.forced = 1; + + ForcedEntity server; + server.replicaManager = serverManager; + server.ownerGUID = MafiaNet::ToPeerGuid(ownerGuid); + + deliverState(simulator, server, ownerConn); + EQUALS(server.measured, 1); + EQUALS(server.forced, 1); + + // The server overrides. Before the override reaches the owner, it sends a change of its own + // and its reading of the forced value, both under the old epoch. The owner sends neither + // again, so the measured one has to land now. + server.forced = 5; + ++server.stateEpoch; + simulator.measured = 2; + simulator.forced = 9; + deliverState(simulator, server, ownerConn, false); + EQUALS(server.measured, 2); + EQUALS(server.forced, 5); + + // Adopted (as the ForceState RPC does): the owner's updates land whole again. + simulator.stateEpoch = server.stateEpoch; + simulator.forced = 5; + simulator.measured = 3; + deliverState(simulator, server, ownerConn, false); + EQUALS(server.measured, 3); + EQUALS(server.forced, 5); + simulator.forced = 6; + deliverState(simulator, server, ownerConn, false); + EQUALS(server.forced, 6); + }); + serverManager->DeallocConnection(ownerConn); serverManager->DeallocConnection(attackerConn); }); From 5d9f8d24f2bd702df3713b7e929358f66e4ade44 Mon Sep 17 00:00:00 2001 From: KHeartz Date: Tue, 13 Oct 2026 11:45:13 -0400 Subject: [PATCH 2/6] Replication: Keep forced fields exact across a stale update The snapshot of the forced fields went through the wire encoding, so a forced field written with Float16, FloatBits or BitsRange came back rounded after any stale owner update. FieldSerializer::Exact makes those writers keep full precision for a local copy, as they already do on the delta path. --- .../networking/replication/network_entity.cpp | 4 ++-- .../networking/replication/network_entity.h | 20 ++++++++++++++----- code/tests/modules/replication_authority_ut.h | 9 +++++++-- 3 files changed, 24 insertions(+), 9 deletions(-) diff --git a/code/framework/src/networking/replication/network_entity.cpp b/code/framework/src/networking/replication/network_entity.cpp index ced7ebb89..29e774766 100644 --- a/code/framework/src/networking/replication/network_entity.cpp +++ b/code/framework/src/networking/replication/network_entity.cpp @@ -321,7 +321,7 @@ namespace Framework::Networking::Replication { const bool stale = !ApplyIncomingEpoch(incomingEpoch); MafiaNet::BitStream forced; if (stale) { - FieldSerializer keep(&forced, true); + auto keep = FieldSerializer::Exact(&forced, true); SerializeForcedState(keep); } MafiaNet::VariableDeltaSerializer::DeserializationContext ctx; @@ -331,7 +331,7 @@ namespace Framework::Networking::Replication { SerializeFields(fields); _vds.EndDeserialize(&ctx); if (stale) { - FieldSerializer restore(&forced, false); + auto restore = FieldSerializer::Exact(&forced, false); SerializeForcedState(restore); } } diff --git a/code/framework/src/networking/replication/network_entity.h b/code/framework/src/networking/replication/network_entity.h index def682c67..c0e39a841 100644 --- a/code/framework/src/networking/replication/network_entity.h +++ b/code/framework/src/networking/replication/network_entity.h @@ -35,6 +35,14 @@ namespace Framework::Networking::Replication { FieldSerializer(MafiaNet::VariableDeltaSerializer *vds, MafiaNet::VariableDeltaSerializer::DeserializationContext *ctx, bool serverAuthored) : _vds(vds), _deserialize(ctx), _serverAuthored(serverAuthored) {} FieldSerializer(MafiaNet::BitStream *bs, bool write, bool serverAuthored = true) : _plain(bs), _writePlain(write), _serverAuthored(serverAuthored) {} + // A local copy rather than wire bytes: the quantized writers keep full precision, so a write + // followed by a read gives back exactly what was written. + static FieldSerializer Exact(MafiaNet::BitStream *bs, bool write) { + FieldSerializer fields(bs, write); + fields._exact = true; + return fields; + } + bool Writing() const { return _serialize != nullptr || (_plain != nullptr && _writePlain); } @@ -82,12 +90,13 @@ namespace Framework::Networking::Replication { } // Quantized writers — meaningful only on the plain-bitstream (transform-channel) path; on the - // VDS delta path they fall back to full precision so a single Serialize body can serve both. + // VDS delta path and in an Exact copy they fall back to full precision so a single Serialize + // body can serve all of them. void Float16(float &value, float min, float max) { if (!_good) { return; } - if (_plain) { + if (_plain && !_exact) { _good = _plain->SerializeFloat16(_writePlain, value, min, max); } else { @@ -100,7 +109,7 @@ namespace Framework::Networking::Replication { if (!_good) { return; } - if (_plain) { + if (_plain && !_exact) { _good = _plain->SerializeBitsFromIntegerRange(_writePlain, value, min, max); } else { @@ -108,12 +117,12 @@ namespace Framework::Networking::Replication { } } - // Fixed-point float in [min,max] over `bits` bits (plain path only; VDS keeps full precision). + // Fixed-point float in [min,max] over `bits` bits (plain path only; VDS and Exact keep full precision). void FloatBits(float &value, float min, float max, int bits) { if (!_good) { return; } - if (!_plain) { + if (!_plain || _exact) { Field(value); return; } @@ -145,6 +154,7 @@ namespace Framework::Networking::Replication { MafiaNet::BitStream *_plain = nullptr; bool _writePlain = false; bool _serverAuthored = true; + bool _exact = false; bool _good = true; }; diff --git a/code/tests/modules/replication_authority_ut.h b/code/tests/modules/replication_authority_ut.h index 403b7f66f..fd260534d 100644 --- a/code/tests/modules/replication_authority_ut.h +++ b/code/tests/modules/replication_authority_ut.h @@ -204,16 +204,19 @@ MODULE(replication_authority, { }; // An owner-reported value the server can override and push back with ForceState (a game's effect - // mask), beside one only the owner measures. + // mask), beside one only the owner measures. `trim` is forced over a coarse 2-bit wire encoding. struct ForcedEntity final: NetworkEntity { int measured = 0; int forced = 0; + float trim = 0.0f; void SerializeFields(Framework::Networking::Replication::FieldSerializer &fields) override { fields.Field(measured); fields.Field(forced); + fields.Field(trim); } void SerializeForcedState(Framework::Networking::Replication::FieldSerializer &fields) override { fields.Field(forced); + fields.FloatBits(trim, 0.0f, 1.0f, 2); } }; @@ -287,14 +290,16 @@ MODULE(replication_authority, { // The server overrides. Before the override reaches the owner, it sends a change of its own // and its reading of the forced value, both under the old epoch. The owner sends neither - // again, so the measured one has to land now. + // again, so the measured one has to land now; `trim` must come back exact, not as 2 bits. server.forced = 5; + server.trim = 0.5f; ++server.stateEpoch; simulator.measured = 2; simulator.forced = 9; deliverState(simulator, server, ownerConn, false); EQUALS(server.measured, 2); EQUALS(server.forced, 5); + EQUALS(server.trim, 0.5f); // Adopted (as the ForceState RPC does): the owner's updates land whole again. simulator.stateEpoch = server.stateEpoch; From 79db7e3878b723a7d2652b8316f7c10799a589c0 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:32:51 +0200 Subject: [PATCH 3/6] Replication: Read past a stale update's forced fields Snapshotting and restoring the forced fields ran SerializeForcedState in read mode on the server for every stale update, so whatever a game does when it reads a forced state ran there too. Find which state variables SerializeForcedState covers by where they live in memory and read past them instead. The forced fields are never written, so the Exact copy mode is gone. The ForceState RPC body moves into NetworkEntity::WriteForcedState and ReadForcedState, so the tests drive the path the RPC takes. --- .../networking/replication/network_entity.cpp | 50 +++++++++--- .../networking/replication/network_entity.h | 79 ++++++++++++++----- .../replication/replication_manager.cpp | 18 +---- code/tests/modules/replication_authority_ut.h | 28 +++++-- 4 files changed, 123 insertions(+), 52 deletions(-) diff --git a/code/framework/src/networking/replication/network_entity.cpp b/code/framework/src/networking/replication/network_entity.cpp index 29e774766..5a1e29e8e 100644 --- a/code/framework/src/networking/replication/network_entity.cpp +++ b/code/framework/src/networking/replication/network_entity.cpp @@ -160,6 +160,40 @@ namespace Framework::Networking::Replication { fields.Field(rotation); } + void NetworkEntity::WriteForcedState(MafiaNet::BitStream *bs) { + bs->Write(stateEpoch); + // Whether the pose below is a move or an echo, so the owner warps only for the first. + bs->Write(IsPoseServerAuthored()); + FieldSerializer fields(bs, true); + SerializeForcedState(fields); + } + + void NetworkEntity::ReadForcedState(MafiaNet::BitStream *bs) { + // Adopting the epoch is what acknowledges the override: updates we send from here on carry + // it, so the server stops refusing them. + uint8_t epoch = 0; + bs->Read(epoch); + bool poseForced = true; + bs->Read(poseForced); + stateEpoch = epoch; + _poseForced = poseForced; + FieldSerializer fields(bs, false); + SerializeForcedState(fields); + OnStateForced(); + } + + std::vector NetworkEntity::ForcedVariables(bool serverAuthored) { + std::vector spans; + FieldSerializer forced(&spans); + SerializeForcedState(forced); + std::vector variables; + FieldSerializer state(&spans, &variables, serverAuthored); + SerializeBaseFields(state); + SerializeFields(state); + return variables; + } + + void NetworkEntity::MarkStateDirty() { // No manager before Reference(); a bag written that early is carried by the seed anyway. if (auto *manager = Manager()) { @@ -314,26 +348,20 @@ namespace Framework::Networking::Replication { } // Channel 1 — state. A stale update is still applied: the owner sends each change only once, so - // dropping it would lose it for good. Only the forced fields are put back afterwards. + // dropping it would lose it for good. Only its forced fields are read past. if (deserializeParameters->bitstreamWrittenTo[kStateChannel]) { uint8_t incomingEpoch = stateEpoch; deserializeParameters->serializationBitstream[kStateChannel].Read(incomingEpoch); - const bool stale = !ApplyIncomingEpoch(incomingEpoch); - MafiaNet::BitStream forced; - if (stale) { - auto keep = FieldSerializer::Exact(&forced, true); - SerializeForcedState(keep); + std::vector forced; + if (!ApplyIncomingEpoch(incomingEpoch)) { + forced = ForcedVariables(!IsServerPeer()); } MafiaNet::VariableDeltaSerializer::DeserializationContext ctx; _vds.BeginDeserialize(&ctx, &deserializeParameters->serializationBitstream[kStateChannel]); - FieldSerializer fields(&_vds, &ctx, !IsServerPeer()); + FieldSerializer fields(&_vds, &ctx, !IsServerPeer(), &forced); SerializeBaseFields(fields); SerializeFields(fields); _vds.EndDeserialize(&ctx); - if (stale) { - auto restore = FieldSerializer::Exact(&forced, false); - SerializeForcedState(restore); - } } // Already shifted to our local clock by RakPeer; do not subtract GetClockDifferential. diff --git a/code/framework/src/networking/replication/network_entity.h b/code/framework/src/networking/replication/network_entity.h index c0e39a841..fa4c9c5f8 100644 --- a/code/framework/src/networking/replication/network_entity.h +++ b/code/framework/src/networking/replication/network_entity.h @@ -17,7 +17,9 @@ #include #include +#include #include +#include namespace Framework::Networking::Replication { class ReplicationManager; @@ -32,19 +34,24 @@ namespace Framework::Networking::Replication { class FieldSerializer final { public: FieldSerializer(MafiaNet::VariableDeltaSerializer *vds, MafiaNet::VariableDeltaSerializer::SerializationContext *ctx, bool serverAuthored) : _vds(vds), _serialize(ctx), _serverAuthored(serverAuthored) {} - FieldSerializer(MafiaNet::VariableDeltaSerializer *vds, MafiaNet::VariableDeltaSerializer::DeserializationContext *ctx, bool serverAuthored) : _vds(vds), _deserialize(ctx), _serverAuthored(serverAuthored) {} + // `keep` marks, by delta variable index, the fields to read past without assigning. + FieldSerializer(MafiaNet::VariableDeltaSerializer *vds, MafiaNet::VariableDeltaSerializer::DeserializationContext *ctx, bool serverAuthored, const std::vector *keep = nullptr) : _vds(vds), _deserialize(ctx), _serverAuthored(serverAuthored), _keep(keep) {} FieldSerializer(MafiaNet::BitStream *bs, bool write, bool serverAuthored = true) : _plain(bs), _writePlain(write), _serverAuthored(serverAuthored) {} - // A local copy rather than wire bytes: the quantized writers keep full precision, so a write - // followed by a read gives back exactly what was written. - static FieldSerializer Exact(MafiaNet::BitStream *bs, bool write) { - FieldSerializer fields(bs, write); - fields._exact = true; - return fields; - } + // Where one field lives, so one field list can be found inside another without a wire. + struct Span { + const void *data; + std::size_t size; + }; + + // Walks without touching anything: lists every field's span, or, given `within`, records per + // field (in delta variable order) whether it overlaps one of them. Reads as writing so a + // body's read-side code does not run. + explicit FieldSerializer(std::vector *spans) : _spans(spans) {} + FieldSerializer(const std::vector *within, std::vector *overlaps, bool serverAuthored) : _serverAuthored(serverAuthored), _within(within), _overlaps(overlaps) {} bool Writing() const { - return _serialize != nullptr || (_plain != nullptr && _writePlain); + return _serialize != nullptr || (_plain != nullptr && _writePlain) || _spans != nullptr || _overlaps != nullptr; } // Whether the server wrote (or is writing) this stream; see the class comment. @@ -67,13 +74,26 @@ namespace Framework::Networking::Replication { else if (_serialize) { _vds->SerializeVariable(_serialize, value); } - else { + else if (_deserialize) { // DeserializeVariable returns "changed", not read success. bool changed = false; _good = _deserialize->bitStream->Read(changed); if (_good && changed) { - _good = _deserialize->bitStream->Read(value); + if (_keep != nullptr && _index < _keep->size() && (*_keep)[_index]) { + T discarded {}; + _good = _deserialize->bitStream->Read(discarded); + } + else { + _good = _deserialize->bitStream->Read(value); + } } + ++_index; + } + else if (_spans) { + _spans->push_back({&value, sizeof(T)}); + } + else { + _overlaps->push_back(Overlaps(&value, sizeof(T))); } } @@ -90,13 +110,12 @@ namespace Framework::Networking::Replication { } // Quantized writers — meaningful only on the plain-bitstream (transform-channel) path; on the - // VDS delta path and in an Exact copy they fall back to full precision so a single Serialize - // body can serve all of them. + // VDS delta path they fall back to full precision so a single Serialize body can serve both. void Float16(float &value, float min, float max) { if (!_good) { return; } - if (_plain && !_exact) { + if (_plain) { _good = _plain->SerializeFloat16(_writePlain, value, min, max); } else { @@ -109,7 +128,7 @@ namespace Framework::Networking::Replication { if (!_good) { return; } - if (_plain && !_exact) { + if (_plain) { _good = _plain->SerializeBitsFromIntegerRange(_writePlain, value, min, max); } else { @@ -117,12 +136,12 @@ namespace Framework::Networking::Replication { } } - // Fixed-point float in [min,max] over `bits` bits (plain path only; VDS and Exact keep full precision). + // Fixed-point float in [min,max] over `bits` bits (plain path only; VDS keeps full precision). void FloatBits(float &value, float min, float max, int bits) { if (!_good) { return; } - if (!_plain || _exact) { + if (!_plain) { Field(value); return; } @@ -148,14 +167,29 @@ namespace Framework::Networking::Replication { } private: + bool Overlaps(const void *data, std::size_t size) const { + const auto *begin = static_cast(data); + for (const Span &span : *_within) { + const auto *other = static_cast(span.data); + if (begin < other + span.size && other < begin + size) { + return true; + } + } + return false; + } + MafiaNet::VariableDeltaSerializer *_vds = nullptr; MafiaNet::VariableDeltaSerializer::SerializationContext *_serialize = nullptr; MafiaNet::VariableDeltaSerializer::DeserializationContext *_deserialize = nullptr; MafiaNet::BitStream *_plain = nullptr; bool _writePlain = false; bool _serverAuthored = true; - bool _exact = false; bool _good = true; + const std::vector *_keep = nullptr; + std::size_t _index = 0; + std::vector *_spans = nullptr; + const std::vector *_within = nullptr; + std::vector *_overlaps = nullptr; }; // A replicated game object. Game entities derive from this and override SerializeFields (per-tick @@ -286,6 +320,11 @@ namespace Framework::Networking::Replication { return _poseForced; } + // The ForceState RPC body: the server writes it for the owner, which adopts it. Sent by + // ReplicationManager::ForceState; game code calls ForceState instead. + void WriteForcedState(MafiaNet::BitStream *bs); + void ReadForcedState(MafiaNet::BitStream *bs); + // Raised for every key a construction seed delivered, after OnConstructed, so a listener // finds the entity fully built. void NotifySeededState(const std::vector &keys); @@ -399,6 +438,10 @@ namespace Framework::Networking::Replication { // forced fields must not be applied. bool ApplyIncomingEpoch(uint8_t incomingEpoch); + // Which state-channel variables, in delta order, hold a field SerializeForcedState carries. + // `serverAuthored` names the stream being walked, as for FieldSerializer. + std::vector ForcedVariables(bool serverAuthored); + // --- State-bag plumbing, reached only by the bag itself --- // Registers with the manager's dirty list, so a flush walks what changed rather than // sweeping the world. diff --git a/code/framework/src/networking/replication/replication_manager.cpp b/code/framework/src/networking/replication/replication_manager.cpp index cf65b0456..39cca2c90 100644 --- a/code/framework/src/networking/replication/replication_manager.cpp +++ b/code/framework/src/networking/replication/replication_manager.cpp @@ -113,18 +113,8 @@ namespace Framework::Networking::Replication { owner->RegisterRawRPC(kForceStateId, [this](MafiaNet::BitStream *bs, MafiaNet::Packet *) { MafiaNet::NetworkID networkId; bs->ReadCompressed(networkId); - uint8_t epoch = 0; - bs->Read(epoch); - bool poseForced = true; - bs->Read(poseForced); if (auto *entity = GetEntityByNetworkID(networkId)) { - // Adopting the epoch is what acknowledges the override: updates we send from here - // on carry it, so the server stops dropping them. - entity->stateEpoch = epoch; - entity->_poseForced = poseForced; - FieldSerializer fields(bs, false); - entity->SerializeForcedState(fields); - entity->OnStateForced(); + entity->ReadForcedState(bs); } }); owner->RegisterRPC([this](const SetOwnerRPC &payload, MafiaNet::Packet *) { @@ -172,11 +162,7 @@ namespace Framework::Networking::Replication { MafiaNet::NetworkID networkId = entity->GetNetworkID(); // NetworkIDs are small and monotonic, so WriteCompressed strips the leading zero bytes. bs.WriteCompressed(networkId); - bs.Write(entity->stateEpoch); - // Whether the pose below is a move or an echo, so the owner warps only for the first. - bs.Write(entity->IsPoseServerAuthored()); - FieldSerializer fields(&bs, true); - entity->SerializeForcedState(fields); + entity->WriteForcedState(&bs); _owner->SendRawRPC(kForceStateId, bs, MafiaNet::ToGuid(entity->ownerGUID)); } diff --git a/code/tests/modules/replication_authority_ut.h b/code/tests/modules/replication_authority_ut.h index fd260534d..01b955c9d 100644 --- a/code/tests/modules/replication_authority_ut.h +++ b/code/tests/modules/replication_authority_ut.h @@ -205,10 +205,12 @@ MODULE(replication_authority, { // An owner-reported value the server can override and push back with ForceState (a game's effect // mask), beside one only the owner measures. `trim` is forced over a coarse 2-bit wire encoding. + // `forcedReads` counts what a game hangs off reading a forced state, which only an owner may run. struct ForcedEntity final: NetworkEntity { - int measured = 0; - int forced = 0; - float trim = 0.0f; + int measured = 0; + int forced = 0; + float trim = 0.0f; + int forcedReads = 0; void SerializeFields(Framework::Networking::Replication::FieldSerializer &fields) override { fields.Field(measured); fields.Field(forced); @@ -217,9 +219,19 @@ MODULE(replication_authority, { void SerializeForcedState(Framework::Networking::Replication::FieldSerializer &fields) override { fields.Field(forced); fields.FloatBits(trim, 0.0f, 1.0f, 2); + if (!fields.Writing()) { + ++forcedReads; + } } }; + // The ForceState RPC body, from the server's entity to its owner's, once the epoch is bumped. + const auto deliverForcedState = [](NetworkEntity &server, NetworkEntity &owner) { + MafiaNet::BitStream bs; + server.WriteForcedState(&bs); + owner.ReadForcedState(&bs); + }; + // One entity's state channel, serialized the way its peer writes it and delivered to another. The // first send to a system carries every field; later ones only what changed since the last. const auto deliverState = [](auto &from, auto &to, MafiaNet::Connection_RM3 *sourceConnection, bool firstSend = true) { @@ -300,11 +312,13 @@ MODULE(replication_authority, { EQUALS(server.measured, 2); EQUALS(server.forced, 5); EQUALS(server.trim, 0.5f); + EQUALS(server.forcedReads, 0); - // Adopted (as the ForceState RPC does): the owner's updates land whole again. - simulator.stateEpoch = server.stateEpoch; - simulator.forced = 5; - simulator.measured = 3; + // Adopted: the owner's updates land whole again. + deliverForcedState(server, simulator); + EQUALS(simulator.stateEpoch, server.stateEpoch); + EQUALS(simulator.forced, 5); + simulator.measured = 3; deliverState(simulator, server, ownerConn, false); EQUALS(server.measured, 3); EQUALS(server.forced, 5); From 3b44b88e6e6b51d68622efb1c10e562c5a0267e9 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:32:51 +0200 Subject: [PATCH 4/6] Replication: Resend forced fields once the owner adopts them The server reads past what an owner sent of its forced fields under the old epoch, but the owner's delta history records those values as delivered. An owner that went back to such a value after adopting the forced state never sent it again, and the server kept the forced one. Mark the forced fields changed in the owner's history when it adopts a forced state, so its next update carries them. --- .../networking/replication/network_entity.cpp | 17 ++++++++++++ .../networking/replication/network_entity.h | 12 ++++++--- code/tests/modules/replication_authority_ut.h | 26 +++++++++++++++++++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/code/framework/src/networking/replication/network_entity.cpp b/code/framework/src/networking/replication/network_entity.cpp index 5a1e29e8e..307366cab 100644 --- a/code/framework/src/networking/replication/network_entity.cpp +++ b/code/framework/src/networking/replication/network_entity.cpp @@ -179,6 +179,9 @@ namespace Framework::Networking::Replication { _poseForced = poseForced; FieldSerializer fields(bs, false); SerializeForcedState(fields); + // The server read past what we sent of these under the old epoch, but our delta history + // records it as delivered: without this, moving back to that value would never be resent. + _vds.MarkChanged(ForcedVariables(IsServerPeer())); OnStateForced(); } @@ -193,6 +196,20 @@ namespace Framework::Networking::Replication { return variables; } + void DeltaSerializer::MarkChanged(const std::vector &variables) { + // Nothing sent yet: the first send carries every variable anyway. + const unsigned int history = GetRemoteSystemHistoryListIndex(MafiaNet::UNASSIGNED_RAKNET_GUID); + if (history == static_cast(-1)) { + return; + } + std::vector bits((variables.size() + 7) / 8, 0); + for (std::size_t i = 0; i < variables.size(); ++i) { + if (variables[i]) { + bits[i >> 3] |= static_cast(0x80 >> (i & 7)); + } + } + remoteSystemVariableHistoryList[history]->variableListDeltaTracker.FlagDirtyFromBitArray(bits.data()); + } void NetworkEntity::MarkStateDirty() { // No manager before Reference(); a bag written that early is carried by the seed anyway. diff --git a/code/framework/src/networking/replication/network_entity.h b/code/framework/src/networking/replication/network_entity.h index fa4c9c5f8..7c215477b 100644 --- a/code/framework/src/networking/replication/network_entity.h +++ b/code/framework/src/networking/replication/network_entity.h @@ -192,6 +192,12 @@ namespace Framework::Networking::Replication { std::vector *_overlaps = nullptr; }; + // The delta serializer, plus a way to make chosen variables count as changed on the next send. + class DeltaSerializer final : public MafiaNet::VariableDeltaSerializer { + public: + void MarkChanged(const std::vector &variables); + }; + // A replicated game object. Game entities derive from this and override SerializeFields (per-tick // delta state) and/or OnSerializeConstruction (one-shot spawn state). // @@ -232,8 +238,8 @@ namespace Framework::Networking::Replication { // Fences server overrides against in-flight owner updates: bumped by the server on // ForceState, adopted by the owner from the ForceState/SetOwner RPCs and echoed back in its // updates; an owner update carrying a stale value (sent before the owner saw the override) - // loses its pose and its forced fields, so it cannot revert the forced state. Equality-checked, - // so uint8 wrap is harmless. Managed by ReplicationManager — game code never touches it. + // loses its pose and its forced fields, so it cannot revert the forced state; the owner resends + // those fields once it adopts the override. Equality-checked, so uint8 wrap is harmless. Managed by ReplicationManager — game code never touches it. uint8_t stateEpoch = 0; // Local-clock send time of the last applied update (MafiaNet shifts it on receipt). Not replicated. @@ -456,7 +462,7 @@ namespace Framework::Networking::Replication { // Tracks the last value of each serialized variable per connection so updates carry only // what changed (the documented ReplicaManager3 delta path). - MafiaNet::VariableDeltaSerializer _vds; + DeltaSerializer _vds; // Sender: refresh schedule state, in serialize-tick time. bool _transformMoved = false; diff --git a/code/tests/modules/replication_authority_ut.h b/code/tests/modules/replication_authority_ut.h index 01b955c9d..2c861644c 100644 --- a/code/tests/modules/replication_authority_ut.h +++ b/code/tests/modules/replication_authority_ut.h @@ -327,6 +327,32 @@ MODULE(replication_authority, { EQUALS(server.forced, 6); }); + IT("resends a forced field the owner moves back to the value it sent before the override", { + ForcedEntity simulator; + simulator.replicaManager = clientManager; + simulator.forced = 1; + + ForcedEntity server; + server.replicaManager = serverManager; + server.ownerGUID = MafiaNet::ToPeerGuid(ownerGuid); + + deliverState(simulator, server, ownerConn); + + // The owner reports 9 under the old epoch while the server forces 5, so the server keeps 5. + server.forced = 5; + ++server.stateEpoch; + simulator.forced = 9; + deliverState(simulator, server, ownerConn, false); + EQUALS(server.forced, 5); + + // The owner adopts 5, then its game measures 9 again. It last sent 9, but the server never + // took it, so 9 has to go out again. + deliverForcedState(server, simulator); + simulator.forced = 9; + deliverState(simulator, server, ownerConn, false); + EQUALS(server.forced, 9); + }); + serverManager->DeallocConnection(ownerConn); serverManager->DeallocConnection(attackerConn); }); From b4c068199ac5c52d712cebb0aa24ba289254bddf Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:42:04 +0200 Subject: [PATCH 5/6] Vendors: Pin MafiaNet 0.23.1 Resizing a delta-tracked variable no longer clears the next one's dirty flag, which could drop the forced-field resend after a ForceState. Wire-compatible. --- cmake/MafiaNetPin.cmake | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cmake/MafiaNetPin.cmake b/cmake/MafiaNetPin.cmake index f8906c91d..129485886 100644 --- a/cmake/MafiaNetPin.cmake +++ b/cmake/MafiaNetPin.cmake @@ -28,10 +28,10 @@ # tree, so bumping the pin here and reconfiguring incrementally would silently # keep the old release. This file is the single source of truth, and there is no # reason to let -D override the wire format of the protocol. -set(MAFIANET_PIN_VERSION "0.23.0") # A Shutdown() from inside receive dispatch no longer frees the packet or transfer receiver in use; snake_case header names (RAKNET_PROTOCOL_VERSION still 7, wire-compatible) +set(MAFIANET_PIN_VERSION "0.23.1") # Resizing a delta-tracked variable no longer clears the next one's dirty flag or writes past the list (RAKNET_PROTOCOL_VERSION still 7, wire-compatible) -set(MAFIANET_PIN_SHA256_linux-x86_64 "b81fcd58707273b25e6e6ac3ce927f3b51ff6d53742bc042aac22e59a71fb953") -set(MAFIANET_PIN_SHA256_macos-arm64 "0723adfa047ffd8cf89ce5c0b1ddb1ffc088313bbbf8f25b95a321bbd0301a2b") -set(MAFIANET_PIN_SHA256_macos-x86_64 "cbb3ccc389a2a8444bad223db2bb0fe8d7c4818b48b3b40e56f744200c584ff5") -set(MAFIANET_PIN_SHA256_windows-x64 "baefa6694fdd2985940f307256b1410ccbd3a7dbd21f12ebc809c80b5d813e6c") -set(MAFIANET_PIN_SHA256_windows-x86 "75f70eafec2245994aa3130a89727f6241732831ce14f6b38dcd4edb33416c3e") +set(MAFIANET_PIN_SHA256_linux-x86_64 "c4035c5ea91d231b921b111b06e10e730c7d1b51ab633bcfb291fb2e0b037092") +set(MAFIANET_PIN_SHA256_macos-arm64 "666cffa58941299d90e66384d29934b1bff7b4063dde27a5c10049b6f5d4b9c7") +set(MAFIANET_PIN_SHA256_macos-x86_64 "5f518eaba0fd5f305b234798bbea9c5284b6670a2fce16d76dbc25ac7649b156") +set(MAFIANET_PIN_SHA256_windows-x64 "86026f72e81c6e257c096e03ade38fa8317375bf1d0be2aee448641808ebb605") +set(MAFIANET_PIN_SHA256_windows-x86 "db722fbabb0d7844433bcd31d25b22e4ab7c9b01b0509191b32273409335987b") From cbbba1c3e8fdf0c6742a9350d5a47959af6fc872 Mon Sep 17 00:00:00 2001 From: Segfault <5221072+Segfaultd@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:55:42 +0200 Subject: [PATCH 6/6] Replication: Adopt nothing from a truncated forced state ReadForcedState ignored whether the ForceState body read in full, so a short one still adopted its epoch and ran OnStateForced. The epoch tells the server the override was applied, so it stopped protecting forced fields the owner never received. Read the header into temporaries and adopt it only once the forced fields read. --- .../networking/replication/network_entity.cpp | 15 ++++++--- code/tests/modules/replication_authority_ut.h | 33 ++++++++++++++++++- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/code/framework/src/networking/replication/network_entity.cpp b/code/framework/src/networking/replication/network_entity.cpp index 307366cab..120f05436 100644 --- a/code/framework/src/networking/replication/network_entity.cpp +++ b/code/framework/src/networking/replication/network_entity.cpp @@ -171,14 +171,19 @@ namespace Framework::Networking::Replication { void NetworkEntity::ReadForcedState(MafiaNet::BitStream *bs) { // Adopting the epoch is what acknowledges the override: updates we send from here on carry // it, so the server stops refusing them. - uint8_t epoch = 0; - bs->Read(epoch); + // A truncated body adopts nothing: its epoch would acknowledge an override never applied. + uint8_t epoch = 0; bool poseForced = true; - bs->Read(poseForced); - stateEpoch = epoch; - _poseForced = poseForced; + if (!bs->Read(epoch) || !bs->Read(poseForced)) { + return; + } FieldSerializer fields(bs, false); SerializeForcedState(fields); + if (!fields.Good()) { + return; + } + stateEpoch = epoch; + _poseForced = poseForced; // The server read past what we sent of these under the old epoch, but our delta history // records it as delivered: without this, moving back to that value would never be resent. _vds.MarkChanged(ForcedVariables(IsServerPeer())); diff --git a/code/tests/modules/replication_authority_ut.h b/code/tests/modules/replication_authority_ut.h index 2c861644c..f9633bfea 100644 --- a/code/tests/modules/replication_authority_ut.h +++ b/code/tests/modules/replication_authority_ut.h @@ -205,12 +205,17 @@ MODULE(replication_authority, { // An owner-reported value the server can override and push back with ForceState (a game's effect // mask), beside one only the owner measures. `trim` is forced over a coarse 2-bit wire encoding. - // `forcedReads` counts what a game hangs off reading a forced state, which only an owner may run. + // `forcedReads` counts what a game hangs off reading a forced state, which only an owner may run; + // `forcedHooks` counts OnStateForced. struct ForcedEntity final: NetworkEntity { int measured = 0; int forced = 0; float trim = 0.0f; int forcedReads = 0; + int forcedHooks = 0; + void OnStateForced() override { + ++forcedHooks; + } void SerializeFields(Framework::Networking::Replication::FieldSerializer &fields) override { fields.Field(measured); fields.Field(forced); @@ -353,6 +358,32 @@ MODULE(replication_authority, { EQUALS(server.forced, 9); }); + IT("adopts nothing from a truncated forced state", { + ForcedEntity simulator; + simulator.replicaManager = clientManager; + simulator.stateEpoch = 3; + + MafiaNet::BitStream empty; + simulator.ReadForcedState(&empty); + EQUALS(simulator.stateEpoch, 3); + EQUALS(simulator.forcedHooks, 0); + + // A header with no fields after it: the epoch would acknowledge an override never applied. + MafiaNet::BitStream headerOnly; + headerOnly.Write(static_cast(4)); + headerOnly.Write(true); + simulator.ReadForcedState(&headerOnly); + EQUALS(simulator.stateEpoch, 3); + EQUALS(simulator.forcedHooks, 0); + + ForcedEntity server; + server.replicaManager = serverManager; + server.stateEpoch = 4; + deliverForcedState(server, simulator); + EQUALS(simulator.stateEpoch, 4); + EQUALS(simulator.forcedHooks, 1); + }); + serverManager->DeallocConnection(ownerConn); serverManager->DeallocConnection(attackerConn); });