I believe I’ve found an edge case for Push Model Replication that causes Actors to be in the incorrect Network Role when swapping possession. This is similar to UE-66313, but only when Push Model is enabled and Actors with full Push Model support are involved.
A fix was made in 5.4 for this issue here, but the bForceCompareProperties flag is not respected for objects that have full Push Model support. This happens in CompareParentProperties()in “RepLayout.cpp” for the branch considering the case where ERepLayoutFlags::FullPushProperties is true for the object.
The issue requires multiple network connections to repro. It does not reproduce for the first client connection–only for other connections. The steps are as follows:
Launch a PIE session with at least 2 clients and the ability to change possession.
The Actors involved should have full Push Model support, and Push Model should be enabled.
On the second client, swap possession to a different pawn.
Observe the previously-possessed pawn has the role ROLE_AutonomousProxy instead of the expected ROLE_SimulatedProxy.
The reason this only happens on the second client is subtle. The outline of the problem is roughly as follows:
On a possession swap, AActor::SetAutonomousProxy() is used to change the network role when the Actor is unpossessed. This calls AActor::ForcePropertyCompare(), which should ensure all properties are checked–regardless of the dirty state of properties for that frame, essentially disabling shared shadow state.
On the first connection during replication, the previously-possessed Actor has all properties compared. Prior to the swap, FScopedRoleDowngrade would have downgrade the role to the first connection (since the Actor is owned by the second connection). In ~FScopedRoleDowngrade(), the role would be restored after replication and the RemoteRole property would be marked dirty. However, now that the Actor has changed its role to ROLE_SimulatedProxy, no downgrade happens and RemoteRole is not marked dirty at the end of replication to the first connection.
Once replication to the first connection finishes, the dirty state is cleared in CompareParentProperties()using SharedParams.PushModelState->ResetDirtyStates(). Now the Actor has no dirty properties.
Next, replication starts on the second connection. Since the Actor has full Push Model support, it takes the branch in CompareParentProperties() where EnumHasAnyFlags(SharedParams.Flags, ERepLayoutFlags::FullPushProperties) is true.
[BUG] This is where the trouble is. This branch only iterates over dirty properties by calling SharedParams.PushModelState->GetDirtyProperties(), which bypasses IsPropertyDirty(). The flag bForceCompareProperties is not respected. This means CompareRoleProperty() never gets called to check if the saved remote role has changed from the Actors new role. See SavedRemoteRole in FSendingRepState.
Now, the previously-possessed Actor has the wrong network role.
My proposed fix is to update CompareParentProperties() in “RepLayout.cpp” to have the following:
// Old
if (UNLIKELY(SharedParams.bForceFail))
{
...
}
// New
if (UNLIKELY(SharedParams.bForceFail || SharedParams.bForceCompareProperties))
{
...
}
This feels like an opportunity to simplify IsPropertyDirty() in “RepLayout.cpp” to remove the check for bForceCompareProperties. The only code path that looks like it may be circumvented would be the validation path with WITH_PUSH_VALIDATION_SUPPORT.
Hi,
Thank you for reporting this! With other tasks taking priority, we don’t currently have any estimate as to when full push-model support for the engine’s base pawn classes may be implemented, so for others looking to implement fully push-based pawns in their projects, it is good to know what kinds of issues to look out for.
That being said, I haven’t been able to reproduce this issue in my own test project. I am using the latest engine version, but I haven’t found any changes since CL 26916951 that may affect how bForceCompareProperties is handled for fully push model objects. Inspecting the logs, it does seem that the role properties get compared as expected in both the fully push-based and default case:
If you have logs or a repro project of your own you’d be willing to share, I’d be happy to take a look at it.
In the meanwhile, we do have an internal task for implementing push-model support for APawn/ACharacter, and I’ve made a note of this issue on that tracker.
Thanks,
Alex
我们碰到类似的问题
在 Dedicated Server + Replication Graph + Push Model 环境下,玩家在两个可控制载具/坐骑之间切换:
从坐骑 A 切换到坐骑 B,一切正常。
再从坐骑 B 切回坐骑 A,客户端虽然完成了 Possess,但 A 无法移动。
出问题时,客户端 A 的 LocalRole 没有恢复为 ROLE_AutonomousProxy,因此不能正常发送移动输入。
这里出问题的 Role 是坐骑/Pawn Actor 身上的,不是 PlayerController 身上的。Controller 的 Possess/UnPossess 是触发所有权变化的原因,但真正没有正确同步的是 Pawn 的 Role/RemoteRole。
稳定复现步骤
使用 Dedicated Server 启动客户端。
玩家控制坐骑 A,此时 A 对拥有者连接应当是 ROLE_AutonomousProxy。
从 A 切换并 Possess 坐骑 B。
确认 B 可以正常移动。
再从 B 切换并 Possess 坐骑 A。
A 无法移动。
检查客户端 A,可以看到它没有正确恢复为 ROLE_AutonomousProxy。
该问题具有明显的方向性:A → B 正常,B → A 才暴露。
日志和代码证据
我们在以下位置增加了临时日志:
FScopedRoleDowngrade
FObjectReplicator::ReplicateProperties
FRepLayout::UpdateChangelistMgr
FRepLayout::CompareProperties
Replication Graph 的强制收集路径
PlayerController::SetPawn
日志证明:
Replication Graph 确实调用了 ForcePropertyCompare()。
对应 UActorChannel::bForceCompareProperties 已经是 true。
DataReplication.cpp 也确实把该值传进了:
FRepLayout::UpdateChangelistMgr(…, OwningChannel->bForceCompareProperties);
坐骑所有权切换时,服务端用于该连接的 RemoteRole 确实发生了 AutonomousProxy ↔ SimulatedProxy 变化。
但是在 Full Push Model 路径中,未标记 dirty 的 RemoteRole 没有进入属性比较。
因此连接对应的 FSendingRepState::SavedRemoteRole 没有跟随实际值更新。
再次切回 A 时,服务端使用了过期的 Shadow/Saved 状态,认为客户端已经拥有正确的 Role,没有重新发送;但客户端 A 实际仍是 SimulatedProxy,最终导致无法移动。
所以这不是对业务代码的猜测,而是从:
ForcePropertyCompare
→ bForceCompareProperties = true
→ UpdateChangelistMgr
→ CompareProperties
→ FullPushSupport
整条调用链确认的。
根本原因
旧版本中,bForceCompare 只在 FRepLayout::UpdateChangelistMgr() 中用于禁止复用同一帧已有的比较结果,例如:
if (!bForceCompare && GShareShadowState && …)
但它没有继续传入 CompareProperties()。
在启用 Full Push Model 时,代码进入下面的优化分支:
else if (EnumHasAnyFlags(
SharedParams.Flags,
ERepLayoutFlags::FullPushSupport))
{
for (TConstSetBitIterator<> It =
SharedParams.PushModelState->GetDirtyProperties();
It;
++It)
{
…
}
}
这个分支只遍历 Push Model dirty bit。
问题是 RemoteRole 会在 FScopedRoleDowngrade 中根据不同连接临时改变,而该变化不是普通 Gameplay 属性赋值,也不一定会设置 Push Model dirty bit。
最终结果就是:
ForcePropertyCompare() 被调用
↓
bForceCompareProperties = true
↓
UpdateChangelistMgr 确实执行
↓
进入 FullPushSupport 分支
↓
仍然只遍历 dirty properties
↓
RemoteRole 未标脏,因此被跳过
也就是说,旧版本的 ForcePropertyCompare() 对 Full Push Model 属性并不是真正的“强制比较”。
修复方案
我们的最终方案是在引擎层修复 ForceCompare 的语义,而不是只针对 Role/RemoteRole 修改缓存,也没有在项目业务层添加特殊处理。
核心修改如下。
首先,把 bForceCompare 从 UpdateChangelistMgr() 继续传入 CompareProperties():
Result = CompareProperties(
RepState,
&InChangelistMgr.RepChangelistState,
(const uint8*)InObject,
RepFlags,
bForceCompare);
在比较参数中保存这个状态:
struct FComparePropertiesSharedParams
{
…
const bool bForceCompareProperties = false;
};
强制比较时,把属性视为需要检查:
static bool IsPropertyDirty(…)
{
return SharedParams.bForceCompareProperties ||
!(*SharedParams.PushModelProperties)[ParentIndex] ||
SharedParams.PushModelState->IsPropertyDirty(ParentIndex) ||
…;
}
同时,强制比较时不能进入“仅遍历 dirty properties”的 Full Push Model 快速路径:
else if (
EnumHasAnyFlags(
SharedParams.Flags,
ERepLayoutFlags::FullPushSupport) &&
!bRecentlyCollectedGarbage &&
!SharedParams.bForceCompareProperties)
{
// Only iterate dirty properties.
}
这样在 ForcePropertyCompare() 被调用时,即使 Push Model 属性没有被标记 dirty,也会读取实际值并与 Shadow/Saved 状态比较。
为什么没有采用只重置 Role 缓存的方案
我们最初考虑过:
SendingRepState->SavedRemoteRole = ROLE_MAX;
SendingRepState->SavedRole = ROLE_MAX;
这个方案可以刺激 Role/RemoteRole 重新比较,但它只修复两个特殊属性,不能解决其他未标脏 Push Model 属性同样被 ForcePropertyCompare() 跳过的问题。
真正的问题不是 SavedRemoteRole 本身,而是:
ForcePropertyCompare() 在 Full Push Model 路径中没有强制比较属性。
因此最终选择修复引擎的通用 ForceCompare 语义。
和官方修复的关系
UE 5.4 中 Epic 的相关提交是:
45f314c0f7a5e369b5131226d6dd48675a7fc3fc
提交说明中明确提到:
Calling ForceCompare() will now force push model properties
to be evaluated even if they are not flagged dirty.
我们的根因和这个官方修改针对的是同一类引擎缺陷:ForceCompare 在 Push Model 属性未标脏时没有真正执行属性比较。
修复结果
应用引擎修复后,重复执行:
A → B → A
坐骑 A 能正确恢复为 ROLE_AutonomousProxy,移动恢复正常