Remove caching of CurrentAdjacentCells in Cargo

In 05ed9d9a73 we stopped caching the values with ToArray to resolve a desync. But even caching the enumerable can lead to a desync, so remove the caching entirely.

----

Let's explain how the code that cached values via ToArray could desync.

Usually, the cell given by `self.Location` matches with the cell given by `self.GetTargetablePositions()`. However if the unit is moving and close to the boundary between two cells, it is possible for the targetable position to be an adjacent cell instead.

Combined with the fact hovering over the unit will evaluate `CurrentAdjacentCells` only for the local player and not everybody, the following sequence becomes possible to induce a desync:
- As the APC is moving into the last cell before unloading, the local player hovers over it. `self.Location` is the last cell, but `self.GetTargetablePositions()` gives the *previous* cell (as the unit is close to the boundary between the cells)
- The local player then caches `CurrentAdjacentCells`. The cache key of `self.Location` is the final cell, but the values are calculated for `self.GetTargetablePositions()` of an *adjacent* cell.
- When the order to unload is resolved, the cache key of `CurrentAdjacentCells` is already `self.Location` and so `CurrentAdjacentCells` is *not* updated.
- The units unload into cells based on the *adjacent* cell.

Then, for other players in the game:
- The hover does nothing for these players.
- When the order is resolved, `CurrentAdjacentCells` is out of date and is re-evaluated.
- `self.Location` and `self.GetTargetablePositions()` are both the last cell, because the unit has finished moving.
- So the cache is updated with a key of `self.Location` and values from the *same* cell.
- The units unload into cells based on the *current* cell.

As the units unload into different cells, a desync occurs. Ultimately the cause here is that cache key is insufficient - `self.Location` can have the same value but the output can differ. The function isn't a pure function so memoizing the result via `ToArray()` isn't sound.

Reverting it to cache the enumerable, which is then lazily re-evaluated reduces the scope of possible desyncs but is NOT a full solve. The cached enumerable caches the result of `Actor.GetTargetablePositions()` which isn't a fully lazy sequence. A different result is returned depending on `EnabledTargetablePositions.Any()`. Therefore, if the traits were to enable/disable inbetween, then we can still end up with different results. Memoizing the enumerable isn't sound either!

Currently our only trait is `HitShape` which is enabled based on conditions. A condition that enables/disables it based on movement would be one way to trigger this scenario. Let's say you have a unit where you toggle between two hit shapes when it is moving and when it stops moving. That would allow you to replicate the above scenario once again.

Instead of trying to come up with a sound caching mechanism in the face of a series of complex inputs, we just give up on trying to cache this information at all.
This commit is contained in:
RoosterDragon
2024-08-01 20:56:35 +01:00
committed by Matthias Mailänder
parent 05ed9d9a73
commit 88fb83bc57
3 changed files with 9 additions and 12 deletions

View File

@@ -59,7 +59,7 @@ namespace OpenRA.Mods.Cnc.Traits.Render
if (move.CurrentMovementTypes != MovementType.None || self.World.Map.DistanceAboveTerrain(self.CenterPosition).Length > 0)
return false;
return cargo.CurrentAdjacentCells.Any(c => self.World.Map.Contains(c)
return cargo.CurrentAdjacentCells().Any(c => self.World.Map.Contains(c)
&& info.OpenTerrainTypes.Contains(self.World.Map.GetTerrainInfo(c).Type));
}

View File

@@ -53,7 +53,7 @@ namespace OpenRA.Mods.Common.Activities
{
var pos = passenger.Trait<IPositionable>();
return cargo.CurrentAdjacentCells
return cargo.CurrentAdjacentCells()
.Shuffle(self.World.SharedRandom)
.Select(c => (c, pos.GetAvailableSubCell(c)))
.Cast<(CPos, SubCell SubCell)?>()
@@ -65,7 +65,7 @@ namespace OpenRA.Mods.Common.Activities
var pos = passenger.Trait<IPositionable>();
// Find the cells that are blocked by transient actors
return cargo.CurrentAdjacentCells
return cargo.CurrentAdjacentCells()
.Where(c => pos.CanEnterCell(c, null, BlockedByActor.All) != pos.CanEnterCell(c, null, BlockedByActor.None));
}

View File

@@ -14,7 +14,6 @@ using System.Collections.Generic;
using System.Linq;
using OpenRA.Mods.Common.Activities;
using OpenRA.Mods.Common.Orders;
using OpenRA.Mods.Common.Widgets;
using OpenRA.Primitives;
using OpenRA.Traits;
@@ -107,10 +106,6 @@ namespace OpenRA.Mods.Common.Traits
bool takeOffAfterLoad;
bool initialised;
readonly CachedTransform<CPos, IEnumerable<CPos>> currentAdjacentCells;
public IEnumerable<CPos> CurrentAdjacentCells => currentAdjacentCells.Update(self.Location);
public IEnumerable<Actor> Passengers => cargo;
public int PassengerCount => cargo.Count;
@@ -123,9 +118,6 @@ namespace OpenRA.Mods.Common.Traits
self = init.Self;
checkTerrainType = info.UnloadTerrainTypes.Count > 0;
currentAdjacentCells = new CachedTransform<CPos, IEnumerable<CPos>>(loc =>
Util.AdjacentCells(self.World, Target.FromActor(self)).Where(c => loc != c));
var runtimeCargoInit = init.GetOrDefault<RuntimeCargoInit>(info);
var cargoInit = init.GetOrDefault<CargoInit>(info);
if (runtimeCargoInit != null)
@@ -234,6 +226,11 @@ namespace OpenRA.Mods.Common.Traits
}
}
public IEnumerable<CPos> CurrentAdjacentCells()
{
return Util.AdjacentCells(self.World, Target.FromActor(self)).Where(c => self.Location != c);
}
public bool CanUnload(BlockedByActor check = BlockedByActor.None)
{
if (IsTraitDisabled)
@@ -249,7 +246,7 @@ namespace OpenRA.Mods.Common.Traits
}
return !IsEmpty() && (aircraft == null || aircraft.CanLand(self.Location, blockedByMobile: false))
&& CurrentAdjacentCells != null && CurrentAdjacentCells.Any(c => Passengers.Any(p => !p.IsDead && p.Trait<IPositionable>().CanEnterCell(c, null, check)));
&& CurrentAdjacentCells().Any(c => Passengers.Any(p => !p.IsDead && p.Trait<IPositionable>().CanEnterCell(c, null, check)));
}
public bool CanLoad(Actor a)