diff --git a/docs/research/2026-08-01-runtime-local-player-physics-publication.md b/docs/research/2026-08-01-runtime-local-player-physics-publication.md
index d49be713..6146dc93 100644
--- a/docs/research/2026-08-01-runtime-local-player-physics-publication.md
+++ b/docs/research/2026-08-01-runtime-local-player-physics-publication.md
@@ -2,14 +2,16 @@
## Scope
-This is placement Slice 4B2 checkpoint 4. It adds the dormant,
+This is placement Slice 4B2 checkpoints 4-5. It adds the dormant,
presentation-independent transaction which prepares and assigns ownership of
-one local-player `PhysicsBody` and `PlayerMovementController`. No App or
+one local-player `PhysicsBody` and `PlayerMovementController`, then retains an
+exact post-ownership activation lease which can evaluate retail SetPosition
+without publishing it. No App or
Headless production route invokes this transaction, so graphical and no-window
game behavior is unchanged and AP-1/AD-1 remain open.
-The checkpoint deliberately stops before canonical SetPosition activation.
-It does not consume the prepared placement operation, enter the body into the
+The checkpoints deliberately stop before canonical SetPosition activation.
+They do not consume the prepared placement operation, enter the body into the
physics engine, publish FullCell/world/host/shadow/workset state, or project a
presentation entity. Those effects belong to the next transaction and must all
use the same Runtime-owned dormant body.
@@ -88,6 +90,70 @@ Repeated stores of the same body/controller do not advance their epochs; real
bind, replacement, and unbind edges do. This makes ABA-shaped reference changes
observable even if a later value happens to equal an earlier reference.
+## Dormant SetPosition evaluation lease
+
+Ownership commit now returns one private activation token captured only after
+the canonical body and controller stores. It binds the exact entity key,
+authored placement token and sealed command, local identity GUID/revision,
+session lifetime, and the post-store physics-body, object-clock, and controller
+ownership epochs. The owner retains the same record, body, controller, and
+command behind that token; no caller can substitute an equivalent-looking
+body or rebuild the mover.
+
+`EvaluateActivation` revalidates that complete lease and calls Core
+`PhysicsEngine.SetPosition` synchronously with an immutable request. Core's
+transaction is pure: it returns committed, deferred-cell, or rejected
+placement data without writing the canonical body, FullCell, clock, spatial
+worksets, shadows, collision-report owners, host, operation stage, or Place
+projection. A missing cell therefore leaves the exact body dormant and the
+authored operation retryable. A valid result likewise remains only an
+immutable evaluation receipt; this checkpoint has no activation/commit API.
+
+Each evaluation carries an append-only, stable-order union of every cell read
+by the complete Core transaction: the AdjustPosition seed and adjusted cell,
+visible-child probes (including rejected lateral siblings), rejected
+portal/building containment probes, transition/compass retries, and every
+normal or scatter attempt. Rejected probes enter only the authority union and
+never the final successful shadow/CrossCell footprint. Scatter keeps that union
+in retained scratch and materializes its
+immutable receipt exactly once after the final attempt, avoiding quadratic
+copy/allocation growth at the 64-attempt retail ceiling. The final
+`CrossCellIds` remains the successful placement's authored
+shadow footprint; failed scatter probes cannot leak into that commit payload.
+Runtime seals every distinct queried landblock against the exact collision
+generation, the global collision-world authority, and the dynamic-shadow
+mutation revision. An active replacement admission rejects evaluation even
+before it commits, while begin/cancel, a re-entrant generation commit, or any
+owner insert/remove/move/state/suspend/reflood mutation invalidates an older
+receipt.
+
+Entry restrictions also consult the live `ClientObjectTable` for the resolved
+house object, owner and complete restriction record, plus the mover's monarch.
+The receipt therefore seals the exact object-table reference, the engine's
+monotonic binding epoch, and the table's synchronous mutation revision. Object
+creation/removal, owner-property, guest-list, or mover-monarch updates invalidate
+the receipt; a null/fresh replacement and an equal-revision A-B-A binding cycle
+cannot resurrect it. Retained `ClientObject` owner, monarch, and restriction
+setters synchronously advance every exact owning table even when callers mutate
+the object directly rather than re-submit it through `AddOrUpdate`. Replacement,
+removal, and clear detach that observer exactly, and every
+`HouseRestrictionRecord` freezes a defensive snapshot of its input guest map so
+no caller-owned dictionary or mutable downcast can alter entry authority behind
+the revision.
+
+`IsEvaluationCurrent` accepts only the newest receipt for the exact activation
+lease and rejects it after a position/vector/state/object-description/Create
+authority change, identity revision, body/controller replacement, session or
+incarnation change, or any sealed collision/shadow authority change.
+Re-evaluation supersedes the older receipt without mutating world state.
+Re-entrant reset or delete-plus-GUID-reuse during Core evaluation immediately
+retires the invalid lease instead of leaving an orphaned dormant graph. An
+existing activation lease also blocks candidate preparation even if an
+external owner has already cleared the body/controller references; explicit
+discard is required before a new candidate can be prepared. Reset and disposal
+retire the lease, body, and dormant controller and include the pending
+activation in the ownership convergence ledger.
+
## Gates
- Candidate privacy and live-operation rejection.
@@ -104,23 +170,53 @@ observable even if a later value happens to equal an earlier reference.
placement cancellation.
- Delete plus same-GUID reincarnation.
- Candidate replacement, reset, disposal, and ownership convergence.
+- Pure committed/deferred/rejected SetPosition evaluation with bit-exact
+ body-state snapshots and no canonical, collision-report, projection,
+ clock, FullCell, host, shadow, workset, or operation-stage mutation.
+- Complete stable-order queried-cell capture across AdjustPosition,
+ visible-child lookup, normal/scatter retries, map-edge/deferred, rejected,
+ committed, and defensive NoCell outcomes. Scatter deliberately retains
+ retail's RNG consumption; only its authority footprint and commit payload
+ are deterministic for a fixed draw sequence.
+- Newest-receipt selection, active-admission rejection, collision-generation
+ replacement, re-entrant begin/cancel and commit invalidation, plus dynamic
+ shadow insert/move/state/suspend/remove invalidation.
+- Exact object-table reference/revision/binding authority, including
+ post-evaluation and re-entrant house-object, owner, guest-list, and mover-
+ monarch mutations plus null/fresh/equal-revision ABA replacement. Direct
+ retained-object setters, replacement/removal/clear observer lifetime, shared
+ multi-table ownership, and frozen guest-map input are covered explicitly.
+- Re-entrant reset and delete/GUID-reuse convergence plus activation-lease
+ overwrite prevention after an external body/controller clear.
+- Post-ownership position, vector, object-description, Create, identity,
+ body, and controller authority replacement.
- Terminal stale-controller rejection after replacement, reset, and disposal.
- Body/controller epochs advance only on actual ownership changes.
-Focused publication, controller, movement, and SetPosition tests pass 167/167.
-The complete Runtime project passes 627/627 under invariant culture. The App
-Runtime-ownership guard passes 4/4, the complete Release solution builds with
-zero errors, and the complete Release solution test gate passes 10,374 tests
-with 4 intentional skips. Under the machine's Swedish current culture, the
-three previously known formatting assertions still fail (`0,5` versus `0.5`
-and localized sky text); they are unrelated to this checkpoint.
+The checkpoint-5 focused publication suite passes 70/70 and the focused Core
+SetPosition suite passes 59/59. The complete Runtime project passes 666/666;
+the complete Core project passes 4,230 tests / 1 skip; and the App Runtime
+physics/movement ownership guard passes 6/6. The complete Release solution
+builds with zero errors (three pre-existing App-test nullability warnings are
+outside this checkpoint), and its invariant-culture test gate passes 10,419
+tests / 4 intentional skips. The installed prepared-package gate
+uses the exact local `acdream.pak`. Under the machine's Swedish current culture,
+the same three previously known formatting assertions remain unrelated (`0,5`
+versus `0.5` and localized sky text), so the canonical gate runs under
+invariant culture.
## Next checkpoint
-Add the canonical Runtime SetPosition activation transaction. It must evaluate
-and commit the already-owned dormant body, consume the exact prepared placement,
-and atomically establish physics-engine/workset/shadow/FullCell/world/host
-ownership before presentation receives an acknowledgement, then invoke the sole
-`ActivateRuntimePublication` transition. The activation must
-roll back or leave the operation retryable on every pre-commit failure and must
-not construct a second body or controller.
+Add the canonical Runtime SetPosition activation transaction. Before its
+callback-free tail, it must prepare a presentation-independent Runtime
+`PhysicsHost`/`PositionManager`/`MoveToManager` graph, the existing authoritative
+authored shadow payload (without rebuilding AP-22 shapes), and an exact staged
+collision-report batch. Then it may commit the already-owned dormant body,
+contact/water/walkable/response state, object clock, FullCell, exact shadow,
+spatial/ordinary workset, host, and SetPosition operation versions; seal the
+ordered Place receipt; and invoke the sole `ActivateRuntimePublication`
+transition as the final tickable edge. Collision reports and the Place observer
+publish only afterward and must tolerate delete/reset re-entry without replaying
+physics. Every pre-commit failure leaves the dormant lease and authored
+operation retryable or rejects them; no rollback mutation and no second body or
+controller are allowed.
diff --git a/src/AcDream.Core/Items/ClientObject.cs b/src/AcDream.Core/Items/ClientObject.cs
index f89e2e17..c7ee7060 100644
--- a/src/AcDream.Core/Items/ClientObject.cs
+++ b/src/AcDream.Core/Items/ClientObject.cs
@@ -179,6 +179,17 @@ public sealed class PropertyBundle
///
public sealed class ClientObject
{
+ private uint? _houseOwnerId;
+ private uint? _monarchId;
+ private HouseRestrictionRecord? _restrictions;
+
+ ///
+ /// Synchronous table-internal notification for the qualities consulted by
+ /// retail's house-entry restriction gate. Multiple tables may retain the
+ /// same object during isolated evaluation fixtures; each receives the edge.
+ ///
+ internal event Action? RestrictionAuthorityChanged;
+
public uint ObjectId { get; init; }
public uint WeenieClassId { get; set; } // "blueprint"
public string Name { get; set; } = "";
@@ -273,7 +284,16 @@ public sealed class ClientObject
/// object (wire WeenieHeaderFlag.Owner, 0x02000000). Zero or a
/// match against the mover's own id admits regardless of the guest list.
///
- public uint? HouseOwnerId { get; set; }
+ public uint? HouseOwnerId
+ {
+ get => _houseOwnerId;
+ set
+ {
+ if (_houseOwnerId == value) return;
+ _houseOwnerId = value;
+ RestrictionAuthorityChanged?.Invoke(this);
+ }
+ }
///
/// AP-129 (Campaign P Slice P4 review fix): retail PublicWeenieDesc
/// ._monarch_iid (wire WeenieHeaderFlag.Monarch, 0x40) — this
@@ -281,7 +301,16 @@ public sealed class ClientObject
/// objects; a player's own value is what RestrictionDB::IsAllowedIn
/// compares against a house's .
///
- public uint? MonarchId { get; set; }
+ public uint? MonarchId
+ {
+ get => _monarchId;
+ set
+ {
+ if (_monarchId == value) return;
+ _monarchId = value;
+ RestrictionAuthorityChanged?.Invoke(this);
+ }
+ }
///
/// AP-129 (Campaign P Slice P4 review fix): retail PublicWeenieDesc
/// ._db (RestrictionDB*) — the house's own guest/ban list.
@@ -291,7 +320,16 @@ public sealed class ClientObject
/// object — acdream cannot distinguish the two, and both resolve to
/// the same retail-faithful "allow" default.
///
- public HouseRestrictionRecord? Restrictions { get; set; }
+ public HouseRestrictionRecord? Restrictions
+ {
+ get => _restrictions;
+ set
+ {
+ if (ReferenceEquals(_restrictions, value)) return;
+ _restrictions = value;
+ RestrictionAuthorityChanged?.Invoke(this);
+ }
+ }
public PropertyBundle Properties { get; } = new();
///
diff --git a/src/AcDream.Core/Items/ClientObjectTable.cs b/src/AcDream.Core/Items/ClientObjectTable.cs
index 6b83085b..97058b2a 100644
--- a/src/AcDream.Core/Items/ClientObjectTable.cs
+++ b/src/AcDream.Core/Items/ClientObjectTable.cs
@@ -116,11 +116,71 @@ public sealed class ClientObjectTable
private readonly ConcurrentDictionary _containers = new();
private readonly Dictionary> _containerIndex = new();
private readonly Dictionary> _equipmentIndex = new();
+ private readonly HashSet _restrictionObservedObjects =
+ new(ReferenceEqualityComparer.Instance);
// B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
private readonly Dictionary _pendingMoves = new();
+ private ulong _mutationRevision;
+
+ public ClientObjectTable()
+ {
+ // Keep one conservative authority over every object mutation that can
+ // affect physics entry restrictions. These handlers are registered
+ // before any consumer can subscribe, so re-entrant observers see the
+ // advanced revision before they can evaluate or publish a receipt.
+ ObjectAdded += _ => AdvanceMutationRevision();
+ ObjectMoved += _ => AdvanceMutationRevision();
+ ObjectRemoved += _ => AdvanceMutationRevision();
+ ObjectUpdated += _ => AdvanceMutationRevision();
+ Cleared += AdvanceMutationRevision;
+ }
+
+ ///
+ /// Monotonic authority for live object-table qualities used by physics,
+ /// including house ownership/restrictions and the mover's monarch.
+ ///
+ internal ulong MutationRevision => _mutationRevision;
+
+ private void AdvanceMutationRevision() =>
+ _mutationRevision = checked(_mutationRevision + 1UL);
+
+ private void RetainObject(ClientObject item)
+ {
+ if (_objects.TryGetValue(item.ObjectId, out ClientObject? prior)
+ && !ReferenceEquals(prior, item))
+ {
+ UnbindRestrictionAuthority(prior);
+ }
+
+ _objects[item.ObjectId] = item;
+ BindRestrictionAuthority(item);
+ }
+
+ private void BindRestrictionAuthority(ClientObject item)
+ {
+ if (!_restrictionObservedObjects.Add(item)) return;
+ item.RestrictionAuthorityChanged += OnRestrictionAuthorityChanged;
+ }
+
+ private void UnbindRestrictionAuthority(ClientObject item)
+ {
+ if (!_restrictionObservedObjects.Remove(item)) return;
+ item.RestrictionAuthorityChanged -= OnRestrictionAuthorityChanged;
+ }
+
+ private void OnRestrictionAuthorityChanged(ClientObject item)
+ {
+ // Unbinding is synchronous, but keep the exact-reference check as a
+ // defensive lifetime gate against a stale/replaced object callback.
+ if (_objects.TryGetValue(item.ObjectId, out ClientObject? retained)
+ && ReferenceEquals(retained, item))
+ {
+ AdvanceMutationRevision();
+ }
+ }
/// Fires when an object is first added to the session.
public event Action? ObjectAdded;
@@ -270,7 +330,7 @@ public sealed class ClientObjectTable
ClientObjectPlacement previous = prior is null
? default
: ClientObjectPlacement.From(prior);
- _objects[item.ObjectId] = item;
+ RetainObject(item);
UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item));
if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item);
@@ -615,6 +675,7 @@ public sealed class ClientObjectTable
bool notifyObjectRemoved)
{
if (!_objects.TryRemove(itemId, out var item)) return false;
+ UnbindRestrictionAuthority(item);
List? changedContainers = RemoveFromOtherContainerIndexes(
itemId,
exceptContainerId: 0u);
@@ -690,7 +751,7 @@ public sealed class ClientObjectTable
if (!existed || item is null)
{
item = new ClientObject { ObjectId = guid };
- _objects[guid] = item;
+ RetainObject(item);
}
foreach (var kv in incoming.Ints) item.Properties.Ints[kv.Key] = kv.Value;
foreach (var kv in incoming.Int64s) item.Properties.Int64s[kv.Key] = kv.Value;
@@ -800,7 +861,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) // keep: satisfies nullable flow analysis
{
obj = new ClientObject { ObjectId = d.Guid };
- _objects[d.Guid] = obj;
+ RetainObject(obj);
}
uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@@ -876,7 +937,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) // keep: satisfies nullable flow analysis
{
obj = new ClientObject { ObjectId = guid };
- _objects[guid] = obj;
+ RetainObject(obj);
}
uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@@ -955,7 +1016,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = entry.Guid };
- _objects[entry.Guid] = obj;
+ RetainObject(obj);
}
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@@ -1249,7 +1310,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = entry.Guid };
- _objects[entry.Guid] = obj;
+ RetainObject(obj);
}
obj.ContainerTypeHint = entry.ContainerType;
if (!existed) added.Add(obj);
@@ -1361,7 +1422,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null)
{
obj = new ClientObject { ObjectId = entry.Guid };
- _objects[entry.Guid] = obj;
+ RetainObject(obj);
}
ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@@ -1426,6 +1487,8 @@ public sealed class ClientObjectTable
///
public void Clear()
{
+ foreach (ClientObject item in _restrictionObservedObjects.ToArray())
+ UnbindRestrictionAuthority(item);
_objects.Clear();
_containers.Clear();
_containerIndex.Clear();
diff --git a/src/AcDream.Core/Items/HouseRestrictions.cs b/src/AcDream.Core/Items/HouseRestrictions.cs
index 2a68fe02..84ec6321 100644
--- a/src/AcDream.Core/Items/HouseRestrictions.cs
+++ b/src/AcDream.Core/Items/HouseRestrictions.cs
@@ -1,3 +1,4 @@
+using System.Collections.Frozen;
using System.Collections.Generic;
namespace AcDream.Core.Items;
@@ -22,11 +23,29 @@ namespace AcDream.Core.Items;
/// (0 = dwelling access only, 1 = storage access also). Retail's
/// IsAllowedIn only consults key membership for entry; the permission
/// value is preserved for wire fidelity but not consulted here.
-public sealed record HouseRestrictionRecord(
- bool OpenToPublic,
- uint AllegianceMonarchId,
- IReadOnlyDictionary Guests)
+public sealed record HouseRestrictionRecord
{
+ public HouseRestrictionRecord(
+ bool OpenToPublic,
+ uint AllegianceMonarchId,
+ IReadOnlyDictionary Guests)
+ {
+ ArgumentNullException.ThrowIfNull(Guests);
+ this.OpenToPublic = OpenToPublic;
+ this.AllegianceMonarchId = AllegianceMonarchId;
+ this.Guests = Guests.ToFrozenDictionary();
+ }
+
+ public bool OpenToPublic { get; }
+ public uint AllegianceMonarchId { get; }
+
+ ///
+ /// Immutable snapshot of the wire permission table. The parser's mutable
+ /// dictionary must never remain an untracked mutation path into live
+ /// collision-entry authority.
+ ///
+ public IReadOnlyDictionary Guests { get; }
+
///
/// Verbatim port of retail RestrictionDB::IsAllowedIn
/// (named-retail pc:444493-444516, 0x005ae8f0):
diff --git a/src/AcDream.Core/Physics/CellArray.cs b/src/AcDream.Core/Physics/CellArray.cs
index 50a6e094..359e40fb 100644
--- a/src/AcDream.Core/Physics/CellArray.cs
+++ b/src/AcDream.Core/Physics/CellArray.cs
@@ -25,6 +25,14 @@ public sealed class CellArray : ICollection, IReadOnlyCollection
private readonly List _order = new();
private readonly HashSet _seen = new();
+ ///
+ /// Optional append-only union target used by one retained SetPosition
+ /// transaction. Clearing this CELLARRAY must not clear the target: retail
+ /// can rebuild the working array repeatedly while every probed cell still
+ /// contributes to the transaction's collision-world authority footprint.
+ ///
+ internal CellArray? UnionTarget { get; set; }
+
public int Count => _order.Count;
public bool IsReadOnly => false;
@@ -34,6 +42,11 @@ public sealed class CellArray : ICollection, IReadOnlyCollection
/// Append iff not already present (retail add_cell dedup).
public void Add(uint id)
{
+ if (UnionTarget is { } target
+ && !ReferenceEquals(target, this))
+ {
+ target.Add(id);
+ }
if (_seen.Add(id))
_order.Add(id);
}
diff --git a/src/AcDream.Core/Physics/CellTransit.cs b/src/AcDream.Core/Physics/CellTransit.cs
index 71ded556..74042142 100644
--- a/src/AcDream.Core/Physics/CellTransit.cs
+++ b/src/AcDream.Core/Physics/CellTransit.cs
@@ -169,6 +169,7 @@ public static class CellTransit
// Retail CEnvCell::find_transit_cells first asks the loaded
// neighbour cell whether the sphere intersects its CellBSP.
// The portal-plane side test is only the unloaded-cell load hint.
+ RecordUnionOnlyProbe(candidates, otherId);
var otherCell = cache.GetCellStruct(otherId);
if (otherCell is not null &&
CollisionTraversal.HasCellContainment(cache, otherCell))
@@ -446,6 +447,7 @@ public static class CellTransit
if (portal.OtherPortalId < 0)
continue;
+ RecordUnionOnlyProbe(candidates, portal.OtherCellId);
var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell is null ||
!CollisionTraversal.HasCellContainment(cache, otherCell))
@@ -679,8 +681,13 @@ public static class CellTransit
///
///
public static uint FindVisibleChildCell(
- PhysicsDataCache cache, uint startCellId, Vector3 worldPoint, bool useStabList)
+ PhysicsDataCache cache,
+ uint startCellId,
+ Vector3 worldPoint,
+ bool useStabList,
+ ICollection? probedCells = null)
{
+ probedCells?.Add(startCellId);
var start = cache.GetCellStruct(startCellId);
if (start is null) return 0u;
@@ -691,12 +698,17 @@ public static class CellTransit
{
// arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465)
foreach (uint id in start.VisibleCellIds)
+ {
+ probedCells?.Add(id);
if (PointInCell(cache, cache.GetCellStruct(id), worldPoint)) return id;
+ }
}
else
{
// arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434)
foreach (var portal in start.Portals)
+ {
+ probedCells?.Add(portal.OtherCellId);
if (PointInCell(
cache,
cache.GetCellStruct(portal.OtherCellId),
@@ -704,6 +716,7 @@ public static class CellTransit
{
return portal.OtherCellId;
}
+ }
}
return 0u;
@@ -1068,7 +1081,11 @@ public static class CellTransit
sphereRadius))
{
uint recovered = FindVisibleChildCell(
- cache, currentCellId, worldSphereCenter, useStabList: true);
+ cache,
+ currentCellId,
+ worldSphereCenter,
+ useStabList: true,
+ (candidates as CellArray)?.UnionTarget);
if (recovered != 0u && recovered != currentCellId)
return recovered;
}
@@ -1078,6 +1095,14 @@ public static class CellTransit
return currentCellId;
}
+ private static void RecordUnionOnlyProbe(
+ ICollection candidates,
+ uint cellId)
+ {
+ if (candidates is CellArray { UnionTarget: { } queryFootprint })
+ queryFootprint.Add(cellId);
+ }
+
private static int EffectiveSphereCount(IReadOnlyList worldSpheres, int numSpheres)
{
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 597bed30..7af81615 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -224,7 +224,22 @@ public sealed class PhysicsEngine
/// when the restriction weenie can't be resolved — so production MUST
/// wire this to the live table for the fix to actually admit anyone.
///
- public ClientObjectTable? Objects { get; set; }
+ private ClientObjectTable? _objects;
+ private ulong _objectsBindingRevision;
+
+ public ClientObjectTable? Objects
+ {
+ get => _objects;
+ set
+ {
+ if (ReferenceEquals(_objects, value))
+ return;
+ _objects = value;
+ _objectsBindingRevision = checked(_objectsBindingRevision + 1UL);
+ }
+ }
+
+ internal ulong ObjectsBindingRevision => _objectsBindingRevision;
internal sealed record LandblockPhysics(
TerrainSurface Terrain,
@@ -1553,8 +1568,10 @@ public sealed class PhysicsEngine
private AdjustedSetPosition AdjustSetPosition(
uint seedCellId,
Vector3 cellLocalPosition,
- Vector3 firstWorldSphereCenter)
+ Vector3 firstWorldSphereCenter,
+ CellArray queryFootprint)
{
+ queryFootprint.Add(seedCellId);
uint low = seedCellId & 0xFFFFu;
bool lowInRange = low is (>= 1u and <= 0x40u)
or (>= 0x0100u and <= 0xFFFDu)
@@ -1582,7 +1599,8 @@ public sealed class PhysicsEngine
cache,
seedCellId,
firstWorldSphereCenter,
- useStabList: true);
+ useStabList: true,
+ queryFootprint);
if (child != 0u)
{
return new AdjustedSetPosition(
@@ -1606,6 +1624,7 @@ public sealed class PhysicsEngine
bool adjusted = LandDefs.AdjustToOutside(
ref adjustedCell,
ref adjustedLocal);
+ queryFootprint.Add(adjustedCell);
bool resident = adjusted
&& IsLandblockTerrainResident(adjustedCell);
return new AdjustedSetPosition(
@@ -1635,33 +1654,54 @@ public sealed class PhysicsEngine
}
Transition transition = RentTransition();
+ CellArray queryFootprint =
+ transition.SpherePath.SetPositionQueryFootprint;
try
{
+ queryFootprint.Clear();
+ transition.SpherePath.CellCandidates.UnionTarget = queryFootprint;
InitializeSetPositionTransition(transition, request);
bool randomOnly = request.Flags.HasFlag(
PhysicsSetPositionFlags.RandomScatter);
+ PhysicsSetPositionResult result;
if (randomOnly)
{
- return SetScatterPositionInternal(
+ result = SetScatterPositionInternal(
transition,
request,
- handleCollisions);
+ handleCollisions,
+ queryFootprint);
+ }
+ else
+ {
+ result = SetPositionInternal(
+ transition,
+ request,
+ handleCollisions,
+ queryFootprint);
+ if (result.Error != PhysicsSetPositionError.Ok
+ && request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter))
+ {
+ result = SetScatterPositionInternal(
+ transition,
+ request,
+ handleCollisions,
+ queryFootprint);
+ }
}
- PhysicsSetPositionResult result =
- SetPositionInternal(transition, request, handleCollisions);
- if (result.Error != PhysicsSetPositionError.Ok
- && request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter))
+ // Scatter retains one append-only query union across every inner
+ // attempt. Materialize it exactly once at the public transaction
+ // boundary; copying it per attempt is quadratic at retail's
+ // maximum retry count.
+ return result with
{
- return SetScatterPositionInternal(
- transition,
- request,
- handleCollisions);
- }
- return result;
+ QueriedCellIds = queryFootprint.OrderedIds.ToImmutableArray(),
+ };
}
finally
{
+ transition.SpherePath.CellCandidates.UnionTarget = null;
ReturnTransition(transition);
}
}
@@ -1686,7 +1726,8 @@ public sealed class PhysicsEngine
private PhysicsSetPositionResult SetScatterPositionInternal(
Transition transition,
in PhysicsSetPositionRequest request,
- Func? handleCollisions)
+ Func? handleCollisions,
+ CellArray queryFootprint)
{
PhysicsSetPositionResult result = ErrorResult(
request,
@@ -1706,7 +1747,8 @@ public sealed class PhysicsEngine
result = SetPositionInternal(
transition,
scattered,
- handleCollisions);
+ handleCollisions,
+ queryFootprint);
if (result.Error == PhysicsSetPositionError.Ok)
break;
}
@@ -1716,7 +1758,8 @@ public sealed class PhysicsEngine
private PhysicsSetPositionResult SetPositionInternal(
Transition transition,
in PhysicsSetPositionRequest request,
- Func? handleCollisions)
+ Func? handleCollisions,
+ CellArray queryFootprint)
{
transition.SpherePath.CellCandidates.Clear();
transition.SpherePath.ClearWalkable();
@@ -1731,7 +1774,8 @@ public sealed class PhysicsEngine
AdjustedSetPosition adjusted = AdjustSetPosition(
request.CellId,
request.CellLocalPosition,
- firstWorldCenter);
+ firstWorldCenter,
+ queryFootprint);
if (!adjusted.Resident)
{
return new PhysicsSetPositionResult(
@@ -1849,7 +1893,9 @@ public sealed class PhysicsEngine
}
if (spherePath.CurCellId == 0u)
{
- return ErrorResult(request, PhysicsSetPositionError.NoCell);
+ return ErrorResult(
+ request,
+ PhysicsSetPositionError.NoCell);
}
bool inContact = collision.ContactPlaneValid;
diff --git a/src/AcDream.Core/Physics/PhysicsSetPosition.cs b/src/AcDream.Core/Physics/PhysicsSetPosition.cs
index 1ac3de2c..32e8c250 100644
--- a/src/AcDream.Core/Physics/PhysicsSetPosition.cs
+++ b/src/AcDream.Core/Physics/PhysicsSetPosition.cs
@@ -145,7 +145,8 @@ internal readonly record struct PhysicsSetPositionResult(
bool CellChanged = false,
PhysicsShadowCommitAction ShadowAction = PhysicsShadowCommitAction.None,
ImmutableArray CrossCellIds = default,
- ImmutableArray CollidedObjectIds = default)
+ ImmutableArray CollidedObjectIds = default,
+ ImmutableArray QueriedCellIds = default)
{
internal bool IsSuccessful => Error == PhysicsSetPositionError.Ok;
internal bool IsCommitted =>
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index 845f98b2..c16d100a 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -78,6 +78,7 @@ public sealed class ShadowObjectRegistry
_collisionWorld.Current.ShadowOwnerFreeSlots;
private readonly HashSet _prefixScratch = new();
private readonly List _removedPrefixScratch = new();
+ private ulong _mutationRevision;
internal event Action? OwnerMutated;
internal event Action? OwnerPrefixMembershipChanged;
@@ -101,6 +102,7 @@ public sealed class ShadowObjectRegistry
"A populated shadow registry cannot change collision roots.");
}
_collisionWorld = collisionWorld;
+ AdvanceMutationRevision();
}
internal sealed record RegistrationRecord(
@@ -123,8 +125,20 @@ public sealed class ShadowObjectRegistry
? version
: 0UL;
+ ///
+ /// Monotonic authority for every logical mutation of the active shadow
+ /// collision world. SetPosition evaluation receipts seal this value so a
+ /// later owner insert, removal, move, state change, suspension, reflood,
+ /// or cell-row replacement cannot be committed against a different world.
+ ///
+ internal ulong MutationRevision => _mutationRevision;
+
+ private void AdvanceMutationRevision() =>
+ _mutationRevision = checked(_mutationRevision + 1UL);
+
private void BumpOwnerVersion(uint entityId)
{
+ AdvanceMutationRevision();
ulong version = checked(GetOwnerVersion(entityId) + 1UL);
_ownerVersions[entityId] = version;
RefreshOwnerPrefixIndex(entityId);
@@ -1308,6 +1322,7 @@ public sealed class ShadowObjectRegistry
DeregisterCore(entityId, publishMutation: false);
RemoveOwnerPrefixMembership(entityId);
_ownerVersions.Remove(entityId);
+ AdvanceMutationRevision();
return;
}
if (!_entityToCells.TryGetValue(entityId, out List? cells))
@@ -1877,6 +1892,13 @@ public sealed class ShadowObjectRegistry
///
public void Clear()
{
+ bool mutated = _cells.Count != 0
+ || _entityToCells.Count != 0
+ || _entityReg.Count != 0
+ || _suspendedEntities.Count != 0
+ || _suspendedEntityCells.Count != 0;
+ if (mutated)
+ AdvanceMutationRevision();
_cells.Clear();
_entityToCells.Clear();
_suspendedEntities.Clear();
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 4fcac576..aace5aab 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -719,6 +719,7 @@ public sealed class SpherePath
private Vector3[]? _walkableVertexStorage;
private Vector3[]? _lastWalkableVertexStorage;
internal readonly CellArray CellCandidates = new();
+ internal readonly CellArray SetPositionQueryFootprint = new();
internal readonly CellOrderScratchArena OrderedCellScratch = new();
internal Vector3[]? RetainedWalkableVertexStorage => _walkableVertexStorage;
@@ -938,6 +939,8 @@ public sealed class SpherePath
if (_lastWalkableVertexStorage is not null)
System.Array.Clear(_lastWalkableVertexStorage);
CellCandidates.Clear();
+ CellCandidates.UnionTarget = null;
+ SetPositionQueryFootprint.Clear();
OrderedCellScratch.ResetForReuse();
}
diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
index 3089a972..6c6c2f24 100644
--- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
+++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs
@@ -704,6 +704,9 @@ public sealed class PlayerMovementController
internal bool IsSealedPublicationCandidate => _publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.CandidateSealed;
+ internal bool IsRuntimeOwnedDormant => _publicationLifecycle
+ is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant;
+
internal void SealPublicationCandidate()
{
if (_publicationLifecycle
diff --git a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
index 823cfcb6..17510ac7 100644
--- a/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
+++ b/src/AcDream.Runtime/Gameplay/RuntimeLocalPlayerPhysicsPublicationState.cs
@@ -14,6 +14,15 @@ internal enum RuntimeLocalPlayerPhysicsPublicationStatus
Discarded,
}
+internal enum RuntimeLocalPlayerPhysicsActivationStatus
+{
+ Evaluated,
+ DeferredCell,
+ RejectedPlacement,
+ RejectedAuthority,
+ RejectedToken,
+}
+
internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken(
RuntimeEntityKey Entity,
RuntimeEntityPlacementToken Placement,
@@ -31,6 +40,33 @@ internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken(
&& Entity == Placement.Entity;
}
+internal readonly record struct RuntimeLocalPlayerPhysicsActivationToken(
+ RuntimeEntityKey Entity,
+ RuntimeEntityPlacementToken Placement,
+ ulong ActivationId,
+ uint LocalPlayerServerGuid,
+ long LocalPlayerIdentityRevision,
+ ulong PhysicsOwnershipEpoch,
+ ulong ObjectClockEpoch,
+ ulong ControllerOwnershipEpoch,
+ ulong SessionGenerationAuthority)
+{
+ internal bool IsValid => ActivationId != 0UL
+ && LocalPlayerServerGuid != 0u
+ && Placement.IsValid
+ && Entity == Placement.Entity;
+}
+
+internal readonly record struct RuntimeLocalPlayerPhysicsActivationReceipt(
+ RuntimeLocalPlayerPhysicsActivationToken Token,
+ ulong EvaluationId,
+ RuntimeDormantSetPositionEvaluation Placement)
+{
+ internal bool IsValid => Token.IsValid
+ && EvaluationId != 0UL
+ && Placement.Placement == Token.Placement;
+}
+
internal readonly record struct RuntimeLocalPlayerPhysicsCandidateSnapshot(
Vector3 Position,
Quaternion Orientation,
@@ -45,10 +81,13 @@ public readonly record struct
bool IsBound,
bool IsDisposed,
int CandidateCount,
+ int PendingActivationCount,
ulong LastPublicationId)
{
internal bool IsConverged => !IsBound
- || (IsDisposed && CandidateCount == 0);
+ || (IsDisposed
+ && CandidateCount == 0
+ && PendingActivationCount == 0);
}
///
@@ -74,12 +113,28 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
internal required PhysicsBody Body { get; init; }
}
+ private sealed class Activation
+ {
+ internal required RuntimeLocalPlayerPhysicsActivationToken Token
+ { get; init; }
+ internal required RuntimeEntityRecord Record { get; init; }
+ internal required RuntimeSetPositionCommand PlacementCommand
+ { get; init; }
+ internal required PlayerMovementController Controller { get; init; }
+ internal required PhysicsBody Body { get; init; }
+ internal RuntimeLocalPlayerPhysicsActivationReceipt Receipt
+ { get; set; }
+ }
+
private readonly RuntimeEntityDirectory _entities;
private readonly RuntimePhysicsState _physics;
private readonly RuntimeLocalPlayerMovementState _movement;
private readonly RuntimeLocalPlayerIdentityState _identity;
private Candidate? _candidate;
+ private Activation? _activation;
private ulong _nextPublicationId;
+ private ulong _nextActivationId;
+ private ulong _nextEvaluationId;
private bool _disposed;
internal RuntimeLocalPlayerPhysicsPublicationState(
@@ -161,9 +216,15 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
}
internal RuntimeLocalPlayerPhysicsPublicationStatus Commit(
- in RuntimeLocalPlayerPhysicsPublicationToken token)
+ in RuntimeLocalPlayerPhysicsPublicationToken token) =>
+ Commit(token, out _);
+
+ internal RuntimeLocalPlayerPhysicsPublicationStatus Commit(
+ in RuntimeLocalPlayerPhysicsPublicationToken token,
+ out RuntimeLocalPlayerPhysicsActivationToken activationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
+ activationToken = default;
if (!token.IsValid
|| _candidate is not { } candidate
|| candidate.Token != token)
@@ -184,10 +245,104 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
candidate.Record.ObjectClock);
candidate.Record.SetPhysicsBody(candidate.Body);
_movement.CommitRuntimeOwnedController(candidate.Controller);
+ activationToken = new RuntimeLocalPlayerPhysicsActivationToken(
+ candidate.Token.Entity,
+ candidate.Token.Placement,
+ checked(++_nextActivationId),
+ candidate.Token.LocalPlayerServerGuid,
+ candidate.Token.LocalPlayerIdentityRevision,
+ candidate.Record.PhysicsOwnershipEpoch,
+ candidate.Record.ObjectClockEpoch,
+ _movement.ControllerOwnershipEpoch,
+ _entities.SessionLifetimeVersion);
+ _activation = new Activation
+ {
+ Token = activationToken,
+ Record = candidate.Record,
+ PlacementCommand = candidate.PlacementCommand,
+ Controller = candidate.Controller,
+ Body = candidate.Body,
+ };
_candidate = null;
return RuntimeLocalPlayerPhysicsPublicationStatus.Committed;
}
+ internal RuntimeLocalPlayerPhysicsActivationStatus EvaluateActivation(
+ in RuntimeLocalPlayerPhysicsActivationToken token,
+ out RuntimeLocalPlayerPhysicsActivationReceipt receipt)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ receipt = default;
+ if (!token.IsValid
+ || _activation is not { } activation
+ || activation.Token != token)
+ {
+ return RuntimeLocalPlayerPhysicsActivationStatus.RejectedToken;
+ }
+ if (!IsActivationCurrent(activation))
+ {
+ DiscardActivation();
+ return RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority;
+ }
+ if (!_physics.SetPosition.TryEvaluateDormantLocalActivation(
+ activation.Record,
+ activation.Body,
+ token.Placement,
+ activation.PlacementCommand,
+ out RuntimeDormantSetPositionEvaluation placement))
+ {
+ if (ReferenceEquals(_activation, activation)
+ && !IsActivationCurrent(activation))
+ {
+ DiscardActivation();
+ }
+ return RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority;
+ }
+
+ receipt = new RuntimeLocalPlayerPhysicsActivationReceipt(
+ token,
+ checked(++_nextEvaluationId),
+ placement);
+ activation.Receipt = receipt;
+ if (placement.Result.IsDeferred)
+ return RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell;
+ return placement.Result.IsCommitted
+ ? RuntimeLocalPlayerPhysicsActivationStatus.Evaluated
+ : RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement;
+ }
+
+ internal bool IsEvaluationCurrent(
+ in RuntimeLocalPlayerPhysicsActivationReceipt receipt)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (!receipt.IsValid
+ || _activation is not { } activation
+ || activation.Token != receipt.Token
+ || activation.Receipt != receipt)
+ {
+ return false;
+ }
+ return IsActivationCurrent(activation)
+ && _physics.SetPosition.IsDormantLocalEvaluationCurrent(
+ activation.Record,
+ activation.Body,
+ receipt.Placement);
+ }
+
+ internal bool DiscardActivation(
+ in RuntimeLocalPlayerPhysicsActivationToken token)
+ {
+ ObjectDisposedException.ThrowIf(_disposed, this);
+ if (!token.IsValid
+ || _activation is not { } activation
+ || activation.Token != token)
+ {
+ return false;
+ }
+ DiscardActivation();
+ return true;
+ }
+
internal RuntimeLocalPlayerPhysicsPublicationStatus Discard(
in RuntimeLocalPlayerPhysicsPublicationToken token)
{
@@ -231,12 +386,14 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
IsBound: true,
_disposed,
_candidate is null ? 0 : 1,
+ _activation is null ? 0 : 1,
_nextPublicationId);
internal void ResetSession()
{
ObjectDisposedException.ThrowIf(_disposed, this);
DiscardCurrent();
+ DiscardActivation();
}
public void Dispose()
@@ -244,6 +401,7 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
if (_disposed)
return;
DiscardCurrent();
+ DiscardActivation();
_disposed = true;
}
@@ -251,7 +409,8 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
RuntimeEntityRecord record,
in RuntimeEntityPlacementToken placement,
in RuntimeSetPositionCommand command) =>
- record.Key is { } key
+ _activation is null
+ && record.Key is { } key
&& key == placement.Entity
&& _entities.IsCurrent(record)
&& !record.DeleteAcceptedForTeardown
@@ -276,7 +435,8 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
command);
private bool IsCurrent(Candidate candidate) =>
- candidate.Controller.IsSealedPublicationCandidate
+ _activation is null
+ && candidate.Controller.IsSealedPublicationCandidate
&& candidate.Controller.OwnsPhysicsBody(candidate.Body)
&& _entities.SessionLifetimeVersion
== candidate.Token.SessionGenerationAuthority
@@ -313,4 +473,55 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
_candidate = null;
candidate?.Controller.DiscardRuntimeCandidate();
}
+
+ private bool IsActivationCurrent(Activation activation) =>
+ activation.Controller.IsRuntimeOwnedDormant
+ && activation.Controller.OwnsPhysicsBody(activation.Body)
+ && _entities.SessionLifetimeVersion
+ == activation.Token.SessionGenerationAuthority
+ && _entities.IsCurrent(activation.Record)
+ && activation.Record.Key == activation.Token.Entity
+ && !_identity.IsDisposed
+ && _identity.ServerGuid == activation.Token.LocalPlayerServerGuid
+ && _identity.ServerGuid == activation.Record.ServerGuid
+ && _identity.Revision == activation.Token.LocalPlayerIdentityRevision
+ && activation.Record.PhysicsOwnershipEpoch
+ == activation.Token.PhysicsOwnershipEpoch
+ && activation.Record.ObjectClockEpoch
+ == activation.Token.ObjectClockEpoch
+ && _movement.CanCommitRuntimeOwnedController(
+ activation.Token.ControllerOwnershipEpoch,
+ activation.Controller)
+ && ReferenceEquals(activation.Record.PhysicsBody, activation.Body)
+ && activation.Record.PhysicsHost is null
+ && activation.Record.RemoteMotion is null
+ && activation.Record.Projectile is null
+ && !activation.Record.PhysicsBodyAcquisitionInProgress
+ && !activation.Record.RemoteMotionBindingInProgress
+ && !activation.Record.ProjectileBindingInProgress
+ && !activation.Record.RequiresRemotePlacementRuntime
+ && !activation.Record.DeleteAcceptedForTeardown
+ && _physics.SetPosition.IsExactPreparedPlacementCurrent(
+ activation.Record,
+ activation.Token.Placement,
+ activation.PlacementCommand);
+
+ private void DiscardActivation()
+ {
+ Activation? activation = _activation;
+ _activation = null;
+ if (activation is not null
+ && _entities.IsCurrent(activation.Record)
+ && ReferenceEquals(
+ activation.Record.PhysicsBody,
+ activation.Body))
+ {
+ _entities.SetPhysicsBody(activation.Record, null);
+ }
+ if (activation is not null
+ && ReferenceEquals(_movement.Controller, activation.Controller))
+ {
+ _movement.Controller = null;
+ }
+ }
}
diff --git a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
index e67465c5..285566dd 100644
--- a/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
+++ b/src/AcDream.Runtime/Physics/RuntimePhysicsState.cs
@@ -1,3 +1,5 @@
+using System.Collections.Immutable;
+using AcDream.Core.Items;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
@@ -1042,6 +1044,7 @@ public sealed class RuntimePhysicsState : IDisposable
private bool _suppressCollisionOwnerJournal;
private long _nextCollisionPreparationSequence;
private long _latestCollisionPreparationStartSequence;
+ private ulong _collisionWorldAuthority = 1UL;
private readonly List>
_collisionGenerationCommittedObservers = new();
private bool _disposed;
@@ -1898,6 +1901,7 @@ public sealed class RuntimePhysicsState : IDisposable
EnsureNotDisposed();
EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId);
+ AdvanceCollisionWorldAuthority();
if (_collisionAdmissions.Remove(
canonical,
out RuntimeCollisionAdmission? superseded))
@@ -2013,6 +2017,7 @@ public sealed class RuntimePhysicsState : IDisposable
admission.Generation);
_collisionGenerations[admission.LandblockId] = checked(
admission.Generation + 1UL);
+ AdvanceCollisionWorldAuthority();
}
TrimCollisionOwnerJournal();
}
@@ -2226,6 +2231,7 @@ public sealed class RuntimePhysicsState : IDisposable
try
{
Engine.CommitLandblockReplacement(replacement);
+ AdvanceCollisionWorldAuthority();
}
finally
{
@@ -2496,6 +2502,7 @@ public sealed class RuntimePhysicsState : IDisposable
private void InvalidateCollisionAdmission(uint landblockId)
{
+ AdvanceCollisionWorldAuthority();
ulong currentGeneration = _collisionGenerations.TryGetValue(
landblockId,
out ulong current)
@@ -2540,6 +2547,130 @@ public sealed class RuntimePhysicsState : IDisposable
: 1UL;
}
+ ///
+ /// Exact collision-prefix generation authority used by private
+ /// SetPosition evaluations. Beginning a replacement generation advances
+ /// this authority immediately, so a receipt cannot survive a prepared
+ /// world replacement and later observe different collision rows.
+ ///
+ internal ulong CollisionGenerationAuthority(uint exactCellId)
+ {
+ uint landblockId = CanonicalLandblock(exactCellId);
+ return landblockId != 0u
+ && _collisionGenerations.TryGetValue(
+ landblockId,
+ out ulong generation)
+ ? generation
+ : 0UL;
+ }
+
+ internal ulong CollisionWorldAuthority => _collisionWorldAuthority;
+
+ internal ulong ShadowWorldAuthority =>
+ Engine.ShadowObjects.MutationRevision;
+
+ internal ClientObjectTable? ObjectTable => Engine.Objects;
+
+ internal ulong ObjectTableBindingAuthority =>
+ Engine.ObjectsBindingRevision;
+
+ internal ulong ObjectTableAuthority =>
+ ObjectTable?.MutationRevision ?? 0UL;
+
+ private void AdvanceCollisionWorldAuthority() =>
+ _collisionWorldAuthority = checked(_collisionWorldAuthority + 1UL);
+
+ internal bool TrySealCollisionEvaluationAuthority(
+ in PhysicsSetPositionResult result,
+ ulong expectedCollisionWorldAuthority,
+ ulong expectedShadowWorldAuthority,
+ ClientObjectTable? expectedObjectTable,
+ ulong expectedObjectTableBindingAuthority,
+ ulong expectedObjectTableAuthority,
+ out RuntimeCollisionEvaluationAuthority authority)
+ {
+ authority = default;
+ if (_collisionWorldAuthority != expectedCollisionWorldAuthority
+ || ShadowWorldAuthority != expectedShadowWorldAuthority
+ || !ReferenceEquals(ObjectTable, expectedObjectTable)
+ || ObjectTableBindingAuthority
+ != expectedObjectTableBindingAuthority
+ || ObjectTableAuthority != expectedObjectTableAuthority)
+ {
+ return false;
+ }
+
+ var prefixes = new HashSet();
+ var authorities = ImmutableArray.CreateBuilder<
+ RuntimeCollisionGenerationAuthority>();
+ void Add(uint cellId)
+ {
+ uint landblock = CanonicalLandblock(cellId);
+ if (landblock == 0u || !prefixes.Add(landblock))
+ return;
+ authorities.Add(new RuntimeCollisionGenerationAuthority(
+ landblock,
+ CollisionGenerationAuthority(landblock)));
+ }
+
+ Add(result.CellId);
+ if (!result.QueriedCellIds.IsDefaultOrEmpty)
+ {
+ foreach (uint cellId in result.QueriedCellIds)
+ Add(cellId);
+ }
+ foreach (uint prefix in prefixes)
+ {
+ if (_collisionAdmissions.ContainsKey(prefix))
+ return false;
+ }
+
+ if (_collisionWorldAuthority != expectedCollisionWorldAuthority
+ || ShadowWorldAuthority != expectedShadowWorldAuthority
+ || !ReferenceEquals(ObjectTable, expectedObjectTable)
+ || ObjectTableBindingAuthority
+ != expectedObjectTableBindingAuthority
+ || ObjectTableAuthority != expectedObjectTableAuthority)
+ {
+ return false;
+ }
+ authority = new RuntimeCollisionEvaluationAuthority(
+ expectedCollisionWorldAuthority,
+ expectedShadowWorldAuthority,
+ expectedObjectTable,
+ expectedObjectTableBindingAuthority,
+ expectedObjectTableAuthority,
+ authorities.ToImmutable());
+ return true;
+ }
+
+ internal bool IsCollisionEvaluationAuthorityCurrent(
+ in RuntimeCollisionEvaluationAuthority authority)
+ {
+ if (!authority.IsValid
+ || _collisionWorldAuthority != authority.CollisionWorldAuthority
+ || ShadowWorldAuthority != authority.ShadowWorldAuthority
+ || !ReferenceEquals(ObjectTable, authority.ObjectTable)
+ || ObjectTableBindingAuthority
+ != authority.ObjectTableBindingAuthority
+ || ObjectTableAuthority != authority.ObjectTableAuthority)
+ {
+ return false;
+ }
+ foreach (RuntimeCollisionGenerationAuthority generation
+ in authority.Generations)
+ {
+ if (generation.LandblockId == 0u
+ || _collisionAdmissions.ContainsKey(generation.LandblockId)
+ || CollisionGenerationAuthority(generation.LandblockId)
+ != generation.Generation)
+ {
+ return false;
+ }
+ }
+ return true;
+ }
+
internal bool HandleSetPositionCollisions(
RuntimeEntityRecord record,
ulong positionAuthorityVersion,
diff --git a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
index a58bd2a1..b20e2070 100644
--- a/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
+++ b/src/AcDream.Runtime/Physics/RuntimeSetPositionState.cs
@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Numerics;
+using AcDream.Core.Items;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
using AcDream.Runtime.Entities;
@@ -82,6 +83,32 @@ internal readonly record struct RuntimeSetPositionCommand(
float ShadowWorldOffsetY = 0f,
RuntimePortalPlacementAuthority Portal = default);
+internal readonly record struct RuntimeCollisionGenerationAuthority(
+ uint LandblockId,
+ ulong Generation);
+
+internal readonly record struct RuntimeCollisionEvaluationAuthority(
+ ulong CollisionWorldAuthority,
+ ulong ShadowWorldAuthority,
+ ClientObjectTable? ObjectTable,
+ ulong ObjectTableBindingAuthority,
+ ulong ObjectTableAuthority,
+ ImmutableArray Generations)
+{
+ internal bool IsValid => CollisionWorldAuthority != 0UL
+ && !Generations.IsDefault;
+}
+
+internal readonly record struct RuntimeDormantSetPositionEvaluation(
+ RuntimeEntityPlacementToken Placement,
+ RuntimeSetPositionCommand Command,
+ PhysicsSetPositionResult Result,
+ RuntimeCollisionEvaluationAuthority CollisionAuthority)
+{
+ internal bool IsValid => Placement.IsValid
+ && CollisionAuthority.IsValid;
+}
+
public readonly record struct RuntimePlacementProjectionToken(
ulong Sequence,
ulong Revision,
@@ -597,6 +624,98 @@ internal sealed class RuntimeSetPositionState : IDisposable
&& IsPreparationAuthorityCurrent(operation, authority);
}
+ ///
+ /// Evaluates the exact authored local-player placement without mutating
+ /// the canonical dormant body or any Runtime ownership index. Core's
+ /// SetPosition transaction is pure; collision reporting is deliberately
+ /// omitted until the later atomic activation commit.
+ ///
+ internal bool TryEvaluateDormantLocalActivation(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeEntityPlacementToken token,
+ in RuntimeSetPositionCommand command,
+ out RuntimeDormantSetPositionEvaluation evaluation)
+ {
+ EnsureNotDisposed();
+ ArgumentNullException.ThrowIfNull(record);
+ ArgumentNullException.ThrowIfNull(body);
+ evaluation = default;
+ if (!IsExactDormantLocalActivationCurrent(
+ record,
+ body,
+ token,
+ command,
+ out Operation? operation))
+ {
+ return false;
+ }
+
+ PhysicsSetPositionRequest canonicalRequest = command.Physics with
+ {
+ MoverPhysicsState = record.FinalPhysicsState,
+ MovingEntityId = token.Entity.LocalEntityId,
+ CurrentCellId = null,
+ };
+ if (!IsStructurallyValid(canonicalRequest))
+ return false;
+
+ var canonicalCommand = command with { Physics = canonicalRequest };
+ ulong collisionWorldAuthority = _physics.CollisionWorldAuthority;
+ ulong shadowWorldAuthority = _physics.ShadowWorldAuthority;
+ ClientObjectTable? objectTable = _physics.ObjectTable;
+ ulong objectTableBindingAuthority =
+ _physics.ObjectTableBindingAuthority;
+ ulong objectTableAuthority = objectTable?.MutationRevision ?? 0UL;
+ PhysicsSetPositionResult result = _physics.Engine.SetPosition(
+ canonicalRequest,
+ handleCollisions: null);
+ if (!IsExactDormantLocalActivationCurrent(
+ record,
+ body,
+ token,
+ command,
+ out Operation? current)
+ || !ReferenceEquals(current, operation))
+ {
+ return false;
+ }
+
+ if (!_physics.TrySealCollisionEvaluationAuthority(
+ result,
+ collisionWorldAuthority,
+ shadowWorldAuthority,
+ objectTable,
+ objectTableBindingAuthority,
+ objectTableAuthority,
+ out RuntimeCollisionEvaluationAuthority collisionAuthority))
+ {
+ return false;
+ }
+
+ evaluation = new RuntimeDormantSetPositionEvaluation(
+ token,
+ canonicalCommand,
+ result,
+ collisionAuthority);
+ return true;
+ }
+
+ internal bool IsDormantLocalEvaluationCurrent(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeDormantSetPositionEvaluation evaluation) =>
+ evaluation.IsValid
+ && IsExactDormantLocalActivationCurrent(
+ record,
+ body,
+ evaluation.Placement,
+ evaluation.Command,
+ out _,
+ allowCanonicalCommand: true)
+ && _physics.IsCollisionEvaluationAuthorityCurrent(
+ evaluation.CollisionAuthority);
+
internal RuntimeSetPositionOutcome SubmitPreparedPlacement(
in RuntimeEntityPlacementToken token,
in RuntimeSetPositionCommand command) =>
@@ -1881,6 +2000,69 @@ internal sealed class RuntimeSetPositionState : IDisposable
&& operation.Record.PlacementCommitVersion
== operation.PlacementCommitVersion;
+ private bool IsExactDormantLocalActivationCurrent(
+ RuntimeEntityRecord record,
+ PhysicsBody body,
+ in RuntimeEntityPlacementToken token,
+ in RuntimeSetPositionCommand command,
+ out Operation? operation,
+ bool allowCanonicalCommand = false)
+ {
+ operation = null;
+ if (!token.IsValid
+ || token.Entity != record.Key
+ || token.PreparationKind
+ is not RuntimeEntityPlacementPreparationKind.AuthoredMover
+ || command.Kind is not (RuntimeSetPositionOperationKind.InitialLogin
+ or RuntimeSetPositionOperationKind.LocalAuthoritative)
+ || command.Portal != default
+ && !command.Portal.IsValid
+ || !_operations.TryGetValue(token.Entity, out operation)
+ || operation.Token != token
+ || operation.Stage
+ is not RuntimeEntityPlacementStage.AwaitingPreparation
+ || !ReferenceEquals(operation.Record, record)
+ || !IsCurrent(operation)
+ || !ReferenceEquals(record.PhysicsBody, body)
+ || body.InWorld
+ || (body.TransientState & TransientStateFlags.Active) != 0
+ || record.PhysicsHost is not null
+ || record.RemoteMotion is not null
+ || record.Projectile is not null
+ || record.PhysicsBodyAcquisitionInProgress
+ || record.RemoteMotionBindingInProgress
+ || record.ProjectileBindingInProgress
+ || record.RequiresRemotePlacementRuntime
+ || record.DeleteAcceptedForTeardown
+ || _physics.IsSpatialRoot(record)
+ || !_moverPreparationAuthorities.TryGetValue(
+ token.Entity,
+ out MoverPreparationAuthority authority)
+ || authority.OperationId != token.OperationId
+ || !authority.Prepared
+ || !IsPreparationAuthorityCurrent(operation, authority))
+ {
+ operation = null;
+ return false;
+ }
+
+ if (authority.PreparedCommand == command)
+ return true;
+ if (!allowCanonicalCommand)
+ return false;
+
+ RuntimeSetPositionCommand authored = authority.PreparedCommand;
+ return authored with
+ {
+ Physics = authored.Physics with
+ {
+ MoverPhysicsState = record.FinalPhysicsState,
+ MovingEntityId = token.Entity.LocalEntityId,
+ CurrentCellId = null,
+ },
+ } == command;
+ }
+
private bool IsVelocityCurrent(Operation operation) =>
operation.SourceVelocityAuthorityVersion == 0UL
|| operation.Record.VelocityAuthorityVersion
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs
index 6b88f5ef..7b5f3672 100644
--- a/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs
@@ -1,6 +1,8 @@
using System.Collections.Immutable;
using System.Numerics;
using AcDream.Core.Physics;
+using DatReaderWriter.Enums;
+using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics;
@@ -32,7 +34,7 @@ public sealed class PhysicsSetPositionTests
var local = new Vector3(193f, 12f, 7f);
uint expectedCell = Cell;
Vector3 expectedLocal = local;
- Assert.True(LandDefs.AdjustToOutside(
+ Assert.True(AcDream.Core.Physics.LandDefs.AdjustToOutside(
ref expectedCell,
ref expectedLocal));
@@ -46,6 +48,9 @@ public sealed class PhysicsSetPositionTests
Assert.Equal(expectedCell, result.CellId);
Assert.Equal(expectedLocal, result.CellLocalPosition);
Assert.Equal(new Vector3(193f, 12f, 7f), result.Position);
+ Assert.Equal(
+ new[] { Cell, expectedCell }.Distinct(),
+ result.QueriedCellIds);
}
[Fact]
@@ -82,6 +87,194 @@ public sealed class PhysicsSetPositionTests
Assert.Equal(0u, result.CellId);
Assert.Equal(local, result.CellLocalPosition);
Assert.Equal(local, result.Position);
+ Assert.Equal(new[] { southWestCell, 0u }, result.QueriedCellIds);
+ }
+
+ [Fact]
+ public void QueryFootprintIncludesAdjustPositionVisibleChildProbes()
+ {
+ const uint start = Landblock | 0x0101u;
+ const uint sibling = Landblock | 0x0102u;
+ var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() };
+ engine.DataCache.RegisterCellStructForTest(
+ start,
+ ContainmentCell(
+ new Plane(new Vector3(0f, -1f, 0f), 3f),
+ [sibling]));
+ engine.DataCache.RegisterCellStructForTest(
+ sibling,
+ ContainmentCell(
+ new Plane(new Vector3(0f, 1f, 0f), -7f),
+ []));
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ start,
+ new Vector3(0f, 8f, 1f),
+ new Vector3(0f, 8f, 1f)) with
+ {
+ MoverPhysicsState = PhysicsStateFlags.Missile,
+ });
+
+ Assert.Contains(start, result.QueriedCellIds);
+ Assert.Contains(sibling, result.QueriedCellIds);
+ Assert.True(
+ result.QueriedCellIds.IndexOf(start)
+ < result.QueriedCellIds.IndexOf(sibling));
+ }
+
+ [Fact]
+ public void LateralVisibleChildRecoveryRecordsRejectedAndWinningSiblingsUnionOnly()
+ {
+ const uint start = Landblock | 0x0101u;
+ const uint rejected = Landblock | 0x0102u;
+ const uint winner = Landblock | 0x0103u;
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(
+ start,
+ ContainmentCell(
+ new Plane(new Vector3(0f, -1f, 0f), 3f),
+ [rejected, winner]));
+ cache.RegisterCellStructForTest(
+ rejected,
+ ContainmentCell(
+ new Plane(new Vector3(0f, 1f, 0f), -20f),
+ []));
+ cache.RegisterCellStructForTest(
+ winner,
+ ContainmentCell(
+ new Plane(new Vector3(0f, 1f, 0f), -7f),
+ []));
+ var candidates = new CellArray();
+ var queryFootprint = new CellArray();
+ candidates.UnionTarget = queryFootprint;
+ var spheres = new[]
+ {
+ new Sphere
+ {
+ Origin = new Vector3(0f, 8f, 1f),
+ Radius = 0.48f,
+ },
+ };
+
+ uint containing = CellTransit.FindCellSet(
+ cache,
+ spheres,
+ spheres.Length,
+ start,
+ candidates);
+
+ Assert.Equal(winner, containing);
+ Assert.Equal(new[] { start }, candidates.OrderedIds);
+ Assert.Equal(
+ new[] { start, rejected, winner },
+ queryFootprint.OrderedIds);
+ }
+
+ [Fact]
+ public void RejectedPortalContainmentProbeIsRecordedUnionOnly()
+ {
+ const uint start = Landblock | 0x0101u;
+ const uint rejected = Landblock | 0x0102u;
+ const ushort portalPolygonId = 10;
+ var portalPolygon = new ResolvedPolygon
+ {
+ Id = portalPolygonId,
+ Vertices =
+ [
+ new Vector3(10f, -1f, 0f),
+ new Vector3(10f, 1f, 0f),
+ new Vector3(10f, 1f, 2f),
+ ],
+ Plane = new Plane(Vector3.UnitX, -10f),
+ NumPoints = 3,
+ SidesType = CullMode.None,
+ };
+ var startCell = new CellPhysics
+ {
+ BSP = new PhysicsBSPTree
+ {
+ Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf },
+ },
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode { Type = BSPNodeType.Leaf },
+ },
+ Portals =
+ [
+ new PortalInfo(
+ (ushort)(rejected & 0xFFFFu),
+ portalPolygonId,
+ 0),
+ ],
+ PortalPolygons = new Dictionary
+ {
+ [portalPolygonId] = portalPolygon,
+ },
+ };
+ var cache = new PhysicsDataCache();
+ cache.RegisterCellStructForTest(start, startCell);
+ cache.RegisterCellStructForTest(
+ rejected,
+ ContainmentCell(
+ new Plane(Vector3.UnitX, -100f),
+ []));
+ var candidates = new CellArray();
+ var queryFootprint = new CellArray();
+ candidates.UnionTarget = queryFootprint;
+ var spheres = new[]
+ {
+ new Sphere
+ {
+ Origin = Vector3.Zero,
+ Radius = 0.48f,
+ },
+ };
+
+ uint containing = CellTransit.FindCellSet(
+ cache,
+ spheres,
+ spheres.Length,
+ start,
+ candidates);
+
+ Assert.Equal(start, containing);
+ Assert.Equal(new[] { start }, candidates.OrderedIds);
+ Assert.Equal(new[] { start, rejected }, queryFootprint.OrderedIds);
+ }
+
+ [Fact]
+ public void RejectedBuildingContainmentProbeIsRecordedUnionOnly()
+ {
+ const uint rejected = Landblock | 0x0102u;
+ var building = new BuildingPhysics
+ {
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Portals =
+ [
+ new BldPortalInfo(
+ rejected,
+ otherPortalId: 0,
+ flags: 0),
+ ],
+ };
+ var candidates = new CellArray();
+ var queryFootprint = new CellArray();
+ candidates.UnionTarget = queryFootprint;
+
+ CellTransit.CheckBuildingTransit(
+ new PhysicsDataCache(),
+ building,
+ Vector3.Zero,
+ sphereRadius: 0.48f,
+ candidates);
+
+ Assert.Empty(candidates);
+ Assert.Equal(new[] { rejected }, queryFootprint.OrderedIds);
}
[Fact]
@@ -867,6 +1060,91 @@ public sealed class PhysicsSetPositionTests
Assert.Equal(new Vector3(11f, 11f, 10f), result.Position);
}
+ [Fact]
+ public void NormalThenScatterUnionsEveryLandblockProbeInStableOrder()
+ {
+ PhysicsEngine engine = FlatEngine();
+ const uint eastLandblock = 0xAAB40000u;
+ AddFlatLandblock(engine, eastLandblock, worldOffsetX: 192f);
+ int pass = 0;
+ engine.TransitionCellCollisionTestHook =
+ (_, phase, _, observed) => phase
+ is TransitionCellCollisionPhase.Environment
+ && pass++ == 0
+ ? TransitionState.Collided
+ : observed;
+ var random = new Queue([1d, 0.5d]);
+ engine.SetPositionRandomUnit = random.Dequeue;
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(180f, 12f, 7f),
+ new Vector3(180f, 12f, 7f)) with
+ {
+ MoverPhysicsState = PhysicsStateFlags.Missile,
+ Flags = PhysicsSetPositionFlags.Placement
+ | PhysicsSetPositionFlags.Scatter,
+ ScatterRadiusX = 20f,
+ ScatterAttempts = 1u,
+ });
+
+ Assert.True(result.IsCommitted);
+ int source = IndexOfLandblock(result.QueriedCellIds, Landblock);
+ int east = IndexOfLandblock(
+ result.QueriedCellIds,
+ eastLandblock);
+ Assert.True(source >= 0);
+ Assert.True(east > source);
+ }
+
+ [Fact]
+ public void MultiScatterUnionsFailedAndSuccessfulAttemptLandblocks()
+ {
+ PhysicsEngine engine = FlatEngine();
+ const uint westLandblock = 0xA8B40000u;
+ const uint eastLandblock = 0xAAB40000u;
+ AddFlatLandblock(engine, westLandblock, worldOffsetX: -192f);
+ AddFlatLandblock(engine, eastLandblock, worldOffsetX: 192f);
+ int pass = 0;
+ engine.TransitionCellCollisionTestHook =
+ (_, phase, _, observed) => phase
+ is TransitionCellCollisionPhase.Environment
+ && pass++ == 0
+ ? TransitionState.Collided
+ : observed;
+ var random = new Queue([0d, 0.5d, 1d, 0.5d]);
+ engine.SetPositionRandomUnit = random.Dequeue;
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(10f, 12f, 7f),
+ new Vector3(10f, 12f, 7f)) with
+ {
+ MoverPhysicsState = PhysicsStateFlags.Missile,
+ Flags = PhysicsSetPositionFlags.RandomScatter,
+ ScatterRadiusX = 200f,
+ ScatterAttempts = 2u,
+ });
+
+ Assert.True(result.IsCommitted);
+ int west = IndexOfLandblock(
+ result.QueriedCellIds,
+ westLandblock);
+ int east = IndexOfLandblock(
+ result.QueriedCellIds,
+ eastLandblock);
+ Assert.True(west >= 0);
+ Assert.True(east > west);
+ Assert.DoesNotContain(
+ result.CrossCellIds,
+ cell => (cell & 0xFFFF0000u) == westLandblock);
+ Assert.Contains(
+ result.CrossCellIds,
+ cell => (cell & 0xFFFF0000u) == eastLandblock);
+ }
+
[Fact]
public void Scatter_ReusesOneTransitionAndFailedInnerProbeCannotLeakIntoSuccess()
{
@@ -1038,4 +1316,43 @@ public sealed class PhysicsSetPositionTests
worldOffsetX,
worldOffsetY);
}
+
+ private static CellPhysics ContainmentCell(
+ Plane plane,
+ uint[] visibleCells) => new()
+ {
+ BSP = new PhysicsBSPTree
+ {
+ Root = new PhysicsBSPNode { Type = BSPNodeType.Leaf },
+ },
+ WorldTransform = Matrix4x4.Identity,
+ InverseWorldTransform = Matrix4x4.Identity,
+ Resolved = new Dictionary(),
+ CellBSP = new CellBSPTree
+ {
+ Root = new CellBSPNode
+ {
+ SplittingPlane = plane,
+ PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
+ },
+ },
+ Portals = [new PortalInfo(0xFFFF, 0, 0)],
+ PortalPolygons = new Dictionary(),
+ VisibleCellIds = new HashSet(visibleCells),
+ };
+
+ private static int IndexOfLandblock(
+ ImmutableArray cells,
+ uint landblock)
+ {
+ for (int index = 0; index < cells.Length; index++)
+ {
+ if ((cells[index] & 0xFFFF0000u)
+ == (landblock & 0xFFFF0000u))
+ {
+ return index;
+ }
+ }
+ return -1;
+ }
}
diff --git a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
index 6ab1e808..c9ed432f 100644
--- a/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
+++ b/tests/AcDream.Runtime.Tests/Gameplay/RuntimeLocalPlayerPhysicsPublicationStateTests.cs
@@ -1,5 +1,6 @@
using System.Collections.Immutable;
using System.Numerics;
+using AcDream.Core.Items;
using AcDream.Core.Net;
using AcDream.Core.Net.Messages;
using AcDream.Core.Physics;
@@ -114,6 +115,761 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
fixture.Owner.Commit(token));
}
+ [Fact]
+ public void DeferredActivationEvaluationLeavesExactOwnedGraphDormantAndRetryable()
+ {
+ using var fixture = new Fixture();
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ PhysicsBody body = fixture.Record.PhysicsBody!;
+ Vector3 position = body.Position;
+ Quaternion orientation = body.Orientation;
+ EvaluationPuritySnapshot before = CaptureEvaluationPurity(fixture);
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
+ fixture.Owner.EvaluateActivation(token, out var receipt));
+ AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture));
+
+ Assert.True(receipt.IsValid);
+ Assert.True(fixture.Owner.IsEvaluationCurrent(receipt));
+ Assert.False(body.InWorld);
+ Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active));
+ Assert.Equal(position, body.Position);
+ Assert.Equal(orientation, body.Orientation);
+ Assert.Equal(Cell, fixture.Record.FullCellId);
+ Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership()
+ .RetainedShadowRegistrationCount);
+ Assert.Equal(1, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+ AssertNotLive(fixture.Movement.Controller!);
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.DeferredCell,
+ fixture.Owner.EvaluateActivation(token, out var retry));
+ AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture));
+ Assert.True(retry.EvaluationId > receipt.EvaluationId);
+ }
+
+ [Fact]
+ public void CommittedEvaluationReceiptRemainsPureAndBindsExactDormantAuthority()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ PhysicsBody body = fixture.Record.PhysicsBody!;
+ ulong clockEpoch = fixture.Record.ObjectClockEpoch;
+ ulong placementCommit = fixture.Record.PlacementCommitVersion;
+ uint fullCell = fixture.Record.FullCellId;
+ Vector3 bodyPosition = body.Position;
+ Quaternion bodyOrientation = body.Orientation;
+ Assert.Equal(fixture.Record.Key, token.Entity);
+ Assert.Equal(fixture.Record.PhysicsOwnershipEpoch,
+ token.PhysicsOwnershipEpoch);
+ Assert.Equal(fixture.Record.ObjectClockEpoch, token.ObjectClockEpoch);
+ Assert.Equal(fixture.Movement.ControllerOwnershipEpoch,
+ token.ControllerOwnershipEpoch);
+ EvaluationPuritySnapshot before = CaptureEvaluationPurity(fixture);
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var receipt));
+ AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture));
+ Assert.True(receipt.Placement.Result.IsCommitted);
+ Assert.True(fixture.Owner.IsEvaluationCurrent(receipt));
+ Assert.False(body.InWorld);
+ Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active));
+ Assert.Equal(bodyPosition, body.Position);
+ Assert.Equal(bodyOrientation, body.Orientation);
+ Assert.Equal(fullCell, fixture.Record.FullCellId);
+ Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.Equal(clockEpoch, fixture.Record.ObjectClockEpoch);
+ Assert.Equal(placementCommit, fixture.Record.PlacementCommitVersion);
+ Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership()
+ .RetainedShadowRegistrationCount);
+ Assert.Equal(1, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+ Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection(
+ out _));
+ AssertNotLive(fixture.Movement.Controller!);
+ }
+
+ [Fact]
+ public void RejectedPlacementReceiptIsPureAndRetryableUnderSameLease()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ PhysicsBody body = fixture.Record.PhysicsBody!;
+ Vector3 position = body.Position;
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ static (_, _, _, _) => TransitionState.Collided;
+ EvaluationPuritySnapshot before = CaptureEvaluationPurity(fixture);
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedPlacement,
+ fixture.Owner.EvaluateActivation(token, out var rejected));
+ AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture));
+ Assert.True(rejected.IsValid);
+ Assert.True(fixture.Owner.IsEvaluationCurrent(rejected));
+ Assert.False(body.InWorld);
+ Assert.Equal(position, body.Position);
+ Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null;
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var retry));
+ AssertEvaluationPurity(before, CaptureEvaluationPurity(fixture));
+ Assert.True(fixture.Owner.IsEvaluationCurrent(retry));
+ Assert.False(fixture.Owner.IsEvaluationCurrent(rejected));
+ }
+
+ [Fact]
+ public void StaleActivationReceiptCannotPublishAndNextEvaluationRetiresDormantOwners()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var receipt));
+ fixture.Lifetime.Entities.SetFinalPhysicsState(
+ fixture.Record,
+ fixture.Record.FinalPhysicsState | PhysicsStateFlags.Frozen);
+
+ Assert.False(fixture.Owner.IsEvaluationCurrent(receipt));
+ PhysicsBody staleBody = fixture.Record.PhysicsBody!;
+ Assert.False(staleBody.InWorld);
+ Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection(
+ out _));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority,
+ fixture.Owner.EvaluateActivation(token, out _));
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Equal(Cell, fixture.Record.FullCellId);
+ Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.Equal(0, fixture.Lifetime.Physics.CaptureOwnership()
+ .RetainedShadowRegistrationCount);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+ }
+
+ [Fact]
+ public void CollisionAdmissionRejectsEvaluationUntilReplacementCommits()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var receipt));
+ PhysicsBody body = fixture.Record.PhysicsBody!;
+
+ RuntimeCollisionAdmission admission = fixture.Lifetime.Physics
+ .BeginCollisionAdmission(Cell & 0xFFFF0000u);
+
+ Assert.False(fixture.Owner.IsEvaluationCurrent(receipt));
+ Assert.False(body.InWorld);
+ Assert.False(body.TransientState.HasFlag(TransientStateFlags.Active));
+ Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority,
+ fixture.Owner.EvaluateActivation(token, out _));
+ Assert.Equal(1, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+
+ using PreparedLandblockCollisionGeneration prepared = fixture
+ .Lifetime.Physics.PrepareCollisionGeneration(admission);
+ fixture.Lifetime.Physics.StageCollisionAssets(
+ admission,
+ prepared,
+ CollisionAssets(Cell & 0xFFFF0000u));
+ Assert.True(CommitPrepared(
+ fixture.Lifetime.Physics,
+ admission,
+ prepared).Committed);
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var replacement));
+ Assert.True(fixture.Owner.IsEvaluationCurrent(replacement));
+ }
+
+ [Fact]
+ public void ReentrantCollisionWorldMutationRejectsOtherwiseCurrentEvaluation()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ bool mutated = false;
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ (_, phase, _, observed) =>
+ {
+ if (!mutated
+ && phase is TransitionCellCollisionPhase.Environment)
+ {
+ mutated = true;
+ RuntimeCollisionAdmission admission = fixture.Lifetime
+ .Physics.BeginCollisionAdmission(0x01010000u);
+ fixture.Lifetime.Physics.CancelCollisionGeneration(
+ admission);
+ }
+ return observed;
+ };
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority,
+ fixture.Owner.EvaluateActivation(token, out _));
+ Assert.True(mutated);
+ Assert.Equal(1, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null;
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var replacement));
+ Assert.True(fixture.Owner.IsEvaluationCurrent(replacement));
+ }
+
+ [Fact]
+ public void ReentrantCollisionGenerationCommitRejectsOtherwiseCurrentEvaluation()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+
+ RuntimeCollisionAdmission admission = fixture.Lifetime.Physics
+ .BeginCollisionAdmission(0x01010000u);
+ using PreparedLandblockCollisionGeneration prepared = fixture
+ .Lifetime.Physics.PrepareCollisionGeneration(admission);
+ fixture.Lifetime.Physics.StageCollisionAssets(
+ admission,
+ prepared,
+ CollisionAssets(0x01010000u));
+ bool committed = false;
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ (_, phase, _, observed) =>
+ {
+ if (!committed
+ && phase is TransitionCellCollisionPhase.Environment)
+ {
+ committed = CommitPrepared(
+ fixture.Lifetime.Physics,
+ admission,
+ prepared).Committed;
+ }
+ return observed;
+ };
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority,
+ fixture.Owner.EvaluateActivation(token, out _));
+ Assert.True(committed);
+ Assert.Equal(1, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null;
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var replacement));
+ Assert.True(fixture.Owner.IsEvaluationCurrent(replacement));
+ }
+
+ [Theory]
+ [InlineData(ActivationInvalidation.Position)]
+ [InlineData(ActivationInvalidation.Vector)]
+ [InlineData(ActivationInvalidation.ObjectDescription)]
+ [InlineData(ActivationInvalidation.Create)]
+ [InlineData(ActivationInvalidation.Identity)]
+ [InlineData(ActivationInvalidation.Body)]
+ [InlineData(ActivationInvalidation.Controller)]
+ [InlineData(ActivationInvalidation.Clock)]
+ [InlineData(ActivationInvalidation.Spatial)]
+ [InlineData(ActivationInvalidation.Host)]
+ [InlineData(ActivationInvalidation.Remote)]
+ [InlineData(ActivationInvalidation.Projectile)]
+ [InlineData(ActivationInvalidation.PlacementCancellation)]
+ public void EveryPostOwnershipAuthorityReplacementInvalidatesEvaluation(
+ ActivationInvalidation invalidation)
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var receipt));
+
+ switch (invalidation)
+ {
+ case ActivationInvalidation.Position:
+ Assert.True(fixture.Lifetime.Physics.SetPosition
+ .BeginAuthoredPlacement(
+ fixture.Record,
+ fixture.Record.PositionAuthorityVersion,
+ RuntimeSetPositionOperationKind.LocalAuthoritative)
+ .IsValid);
+ break;
+ case ActivationInvalidation.Vector:
+ fixture.Lifetime.Entities.AdvanceVectorAuthority(fixture.Record);
+ break;
+ case ActivationInvalidation.ObjectDescription:
+ fixture.Lifetime.Entities.AdvanceObjDescAuthority(fixture.Record);
+ break;
+ case ActivationInvalidation.Create:
+ fixture.Lifetime.Entities.AdvanceCreateAuthority(fixture.Record);
+ break;
+ case ActivationInvalidation.Identity:
+ fixture.Identity.ServerGuid++;
+ break;
+ case ActivationInvalidation.Body:
+ fixture.Lifetime.Entities.SetPhysicsBody(
+ fixture.Record,
+ new PhysicsBody());
+ break;
+ case ActivationInvalidation.Controller:
+ fixture.Movement.Controller = new PlayerMovementController(
+ new PhysicsEngine());
+ break;
+ case ActivationInvalidation.Clock:
+ fixture.Lifetime.Entities.SuspendObjectClock(fixture.Record);
+ break;
+ case ActivationInvalidation.Spatial:
+ fixture.Lifetime.Entities.SetFullCell(
+ fixture.Record,
+ Cell + 1u,
+ (Cell & 0xFFFF0000u) | 0xFFFFu);
+ break;
+ case ActivationInvalidation.Host:
+ fixture.Lifetime.Entities.SetPhysicsHost(
+ fixture.Record,
+ CreatePhysicsHost(fixture.Record.ServerGuid));
+ break;
+ case ActivationInvalidation.Remote:
+ fixture.Lifetime.Entities.SetRemoteMotion(
+ fixture.Record,
+ new RemoteMotion());
+ break;
+ case ActivationInvalidation.Projectile:
+ fixture.Lifetime.Entities.SetProjectile(
+ fixture.Record,
+ new RuntimeProjectile(
+ new PhysicsBody(),
+ new ProjectileCollisionSphere(
+ Vector3.Zero,
+ 0.1f)));
+ break;
+ case ActivationInvalidation.PlacementCancellation:
+ Assert.True(fixture.Lifetime.Physics.SetPosition.Cancel(
+ fixture.Record,
+ publishWithdrawal: false));
+ break;
+ default:
+ throw new ArgumentOutOfRangeException(nameof(invalidation));
+ }
+
+ Assert.False(fixture.Owner.IsEvaluationCurrent(receipt));
+ Assert.False(fixture.Lifetime.Physics.IsSpatialRoot(fixture.Record));
+ Assert.False(fixture.Lifetime.Physics.SetPosition.TryPeekProjection(
+ out _));
+ }
+
+ [Fact]
+ public void ShadowInsertMoveStateSuspendAndRemoveInvalidateReceipts()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var emptyWorld));
+ Assert.True(fixture.Owner.IsEvaluationCurrent(emptyWorld));
+ uint queriedCell = emptyWorld.Placement.Result.QueriedCellIds[0];
+ const uint ownerId = 0x7000F001u;
+ Vector3 far = new(100f, 100f, 0f);
+
+ fixture.Lifetime.Physics.Engine.ShadowObjects.Register(
+ ownerId,
+ SetupId,
+ far,
+ Quaternion.Identity,
+ 0.25f,
+ 0f,
+ 0f,
+ queriedCell & 0xFFFF0000u,
+ ShadowCollisionType.Cylinder,
+ cylHeight: 1f,
+ seedCellId: queriedCell,
+ isStatic: false);
+ Assert.False(fixture.Owner.IsEvaluationCurrent(emptyWorld));
+
+ RuntimeLocalPlayerPhysicsActivationReceipt current = Reevaluate();
+ fixture.Lifetime.Physics.Engine.ShadowObjects.UpdatePosition(
+ ownerId,
+ far + Vector3.One,
+ Quaternion.Identity,
+ 0f,
+ 0f,
+ queriedCell & 0xFFFF0000u,
+ queriedCell);
+ Assert.False(fixture.Owner.IsEvaluationCurrent(current));
+
+ current = Reevaluate();
+ fixture.Lifetime.Physics.Engine.ShadowObjects.UpdatePhysicsState(
+ ownerId,
+ (uint)PhysicsStateFlags.Ethereal);
+ Assert.False(fixture.Owner.IsEvaluationCurrent(current));
+
+ current = Reevaluate();
+ Assert.True(fixture.Lifetime.Physics.Engine.ShadowObjects.Suspend(
+ ownerId));
+ Assert.False(fixture.Owner.IsEvaluationCurrent(current));
+
+ current = Reevaluate();
+ fixture.Lifetime.Physics.Engine.ShadowObjects.Deregister(ownerId);
+ Assert.False(fixture.Owner.IsEvaluationCurrent(current));
+
+ RuntimeLocalPlayerPhysicsActivationReceipt Reevaluate()
+ {
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var next));
+ Assert.True(fixture.Owner.IsEvaluationCurrent(next));
+ return next;
+ }
+ }
+
+ [Theory]
+ [InlineData(RestrictionObjectMutation.RemoveHouseObject)]
+ [InlineData(RestrictionObjectMutation.HouseOwnerProperty)]
+ [InlineData(RestrictionObjectMutation.HouseGuestList)]
+ [InlineData(RestrictionObjectMutation.MoverMonarch)]
+ public void RestrictionObjectMutationInvalidatesEvaluatedReceipt(
+ RestrictionObjectMutation mutation)
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ SeedRestrictionObjects(fixture);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var receipt));
+
+ ApplyRestrictionObjectMutation(fixture, mutation);
+
+ Assert.False(fixture.Owner.IsEvaluationCurrent(receipt));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var replacement));
+ Assert.True(fixture.Owner.IsEvaluationCurrent(replacement));
+ }
+
+ [Theory]
+ [InlineData(RestrictionObjectMutation.RemoveHouseObject)]
+ [InlineData(RestrictionObjectMutation.HouseOwnerProperty)]
+ [InlineData(RestrictionObjectMutation.HouseGuestList)]
+ [InlineData(RestrictionObjectMutation.MoverMonarch)]
+ public void ReentrantRestrictionObjectMutationAbortsEvaluation(
+ RestrictionObjectMutation mutation)
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ SeedRestrictionObjects(fixture);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ bool mutated = false;
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ (_, phase, _, observed) =>
+ {
+ if (!mutated
+ && phase is TransitionCellCollisionPhase.Environment)
+ {
+ mutated = true;
+ ApplyRestrictionObjectMutation(fixture, mutation);
+ }
+ return observed;
+ };
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority,
+ fixture.Owner.EvaluateActivation(token, out _));
+ Assert.True(mutated);
+ Assert.Equal(1, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook = null;
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var replacement));
+ Assert.True(fixture.Owner.IsEvaluationCurrent(replacement));
+ }
+
+ [Fact]
+ public void ObjectTableNullFreshAndEqualRevisionAbaInvalidateReceipt()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ SeedRestrictionObjects(fixture);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var originalReceipt));
+ ClientObjectTable original = fixture.Lifetime.Objects;
+
+ fixture.Lifetime.Physics.Engine.Objects = null;
+ Assert.False(fixture.Owner.IsEvaluationCurrent(originalReceipt));
+
+ // Returning to the same reference/revision is an ABA-shaped binding
+ // replacement. The engine binding authority keeps the old receipt
+ // stale even though object identity and mutation revision match again.
+ fixture.Lifetime.Physics.Engine.Objects = original;
+ Assert.False(fixture.Owner.IsEvaluationCurrent(originalReceipt));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var reboundReceipt));
+
+ var fresh = new ClientObjectTable();
+ fresh.AddOrUpdate(new ClientObject { ObjectId = 0x70003F10u });
+ fresh.AddOrUpdate(new ClientObject { ObjectId = 0x70003F11u });
+ Assert.Equal(original.MutationRevision, fresh.MutationRevision);
+ fixture.Lifetime.Physics.Engine.Objects = fresh;
+
+ Assert.False(fixture.Owner.IsEvaluationCurrent(reboundReceipt));
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.Evaluated,
+ fixture.Owner.EvaluateActivation(token, out var freshReceipt));
+ Assert.True(fixture.Owner.IsEvaluationCurrent(freshReceipt));
+ }
+
+ [Fact]
+ public void HouseRestrictionGuestsAreAnImmutableInputSnapshot()
+ {
+ var sourceGuests = new Dictionary();
+ var restrictions = new HouseRestrictionRecord(
+ OpenToPublic: false,
+ AllegianceMonarchId: 0u,
+ Guests: sourceGuests);
+ var table = new ClientObjectTable();
+ var house = new ClientObject
+ {
+ ObjectId = RestrictionObjectId,
+ Restrictions = restrictions,
+ };
+ table.AddOrUpdate(house);
+ ulong retainedRevision = table.MutationRevision;
+
+ sourceGuests[0x70003F20u] = 1u;
+
+ Assert.False(restrictions.IsAllowedIn(0x70003F20u, 0u));
+ if (restrictions.Guests is IDictionary dictionaryView)
+ {
+ Assert.Throws(
+ () => dictionaryView[0x70003F20u] = 1u);
+ }
+ Assert.False(restrictions.IsAllowedIn(0x70003F20u, 0u));
+ Assert.Equal(retainedRevision, table.MutationRevision);
+ }
+
+ [Fact]
+ public void RemovalAndClearUnbindDirectRestrictionMutationAuthority()
+ {
+ var table = new ClientObjectTable();
+ var removed = new ClientObject { ObjectId = RestrictionObjectId };
+ table.AddOrUpdate(removed);
+ Assert.True(table.Remove(removed.ObjectId));
+ ulong removedRevision = table.MutationRevision;
+
+ removed.HouseOwnerId = 0x70003F21u;
+ Assert.Equal(removedRevision, table.MutationRevision);
+
+ var cleared = new ClientObject { ObjectId = 0x70003F22u };
+ table.AddOrUpdate(cleared);
+ table.Clear();
+ ulong clearedRevision = table.MutationRevision;
+
+ cleared.MonarchId = 0x70003F23u;
+ Assert.Equal(clearedRevision, table.MutationRevision);
+ }
+
+ [Fact]
+ public void AddOrUpdateReplacementTransfersDirectMutationAuthority()
+ {
+ var table = new ClientObjectTable();
+ var displaced = new ClientObject { ObjectId = RestrictionObjectId };
+ var replacement = new ClientObject { ObjectId = RestrictionObjectId };
+ table.AddOrUpdate(displaced);
+ table.AddOrUpdate(replacement);
+ ulong replacementRevision = table.MutationRevision;
+
+ displaced.MonarchId = 0x70003F24u;
+ Assert.Equal(replacementRevision, table.MutationRevision);
+
+ replacement.MonarchId = 0x70003F25u;
+ Assert.True(table.MutationRevision > replacementRevision);
+ }
+
+ [Fact]
+ public void SharedRetainedObjectNotifiesEveryOwningTable()
+ {
+ var first = new ClientObjectTable();
+ var second = new ClientObjectTable();
+ var shared = new ClientObject { ObjectId = RestrictionObjectId };
+ first.AddOrUpdate(shared);
+ second.AddOrUpdate(shared);
+ ulong firstRevision = first.MutationRevision;
+ ulong secondRevision = second.MutationRevision;
+
+ shared.HouseOwnerId = 0x70003F26u;
+
+ Assert.True(first.MutationRevision > firstRevision);
+ Assert.True(second.MutationRevision > secondRevision);
+ }
+
+ [Theory]
+ [InlineData(ReentrantActivationInvalidation.Reset)]
+ [InlineData(ReentrantActivationInvalidation.DeleteAndGuidReuse)]
+ public void ReentrantInvalidationDuringCoreEvaluationRetiresLease(
+ ReentrantActivationInvalidation invalidation)
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ RuntimeEntityRecord? replacement = null;
+ bool invalidated = false;
+ fixture.Lifetime.Physics.Engine.TransitionCellCollisionTestHook =
+ (_, phase, _, observed) =>
+ {
+ if (invalidated
+ || phase is not TransitionCellCollisionPhase.Environment)
+ {
+ return observed;
+ }
+ invalidated = true;
+ if (invalidation is ReentrantActivationInvalidation.Reset)
+ {
+ fixture.Movement.ResetSession();
+ }
+ else
+ {
+ uint guid = fixture.Record.ServerGuid;
+ Assert.True(fixture.Lifetime.TryAcceptDelete(
+ new DeleteObject.Parsed(
+ guid,
+ fixture.Record.Incarnation),
+ isLocalPlayer: false,
+ removeRetainedObject: false,
+ out RuntimeEntityDeleteAcceptance acceptance));
+ fixture.Lifetime.CompleteAcceptedDelete(acceptance);
+ Assert.Null(fixture.Lifetime.RetireCanonicalOnly(
+ fixture.Record));
+ replacement = fixture.Lifetime.RegisterEntity(
+ Spawn(guid, incarnation: 2)).Canonical!;
+ }
+ return observed;
+ };
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsActivationStatus.RejectedAuthority,
+ fixture.Owner.EvaluateActivation(token, out _));
+
+ Assert.True(invalidated);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Null(replacement?.PhysicsBody);
+ }
+
+ [Fact]
+ public void ExistingActivationLeaseCannotBeOverwrittenAfterExternalClear()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ fixture.Lifetime.Entities.SetPhysicsBody(fixture.Record, null);
+ fixture.Movement.Controller = null;
+
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.RejectedAuthority,
+ fixture.Owner.Prepare(
+ fixture.Record,
+ fixture.Placement,
+ fixture.Command,
+ PlayerMovementConstructionOptions.Fallback,
+ out _));
+ Assert.Equal(1, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+
+ Assert.True(fixture.Owner.DiscardActivation(token));
+ Assert.Equal(0, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+ Assert.Equal(RuntimeLocalPlayerPhysicsPublicationStatus.Prepared,
+ fixture.Owner.Prepare(
+ fixture.Record,
+ fixture.Placement,
+ fixture.Command,
+ PlayerMovementConstructionOptions.Fallback,
+ out _));
+ }
+
+ [Fact]
+ public void ResetRetiresPendingActivationAndConvergesItsOwnershipLedger()
+ {
+ using var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out _));
+
+ fixture.Movement.ResetSession();
+
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ Assert.Equal(0, fixture.Owner.CaptureOwnership()
+ .PendingActivationCount);
+ }
+
+ [Fact]
+ public void ExplicitDiscardAndDisposeRetirePendingActivationExactlyOnce()
+ {
+ var fixture = new Fixture(residentWorld: true);
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(
+ fixture.Prepare(),
+ out RuntimeLocalPlayerPhysicsActivationToken token));
+ Assert.True(fixture.Owner.DiscardActivation(token));
+ Assert.False(fixture.Owner.DiscardActivation(token));
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+
+ fixture.RepreparePlacement();
+ Assert.Equal(
+ RuntimeLocalPlayerPhysicsPublicationStatus.Committed,
+ fixture.Owner.Commit(fixture.Prepare(), out _));
+ fixture.Owner.Dispose();
+ Assert.True(fixture.Owner.CaptureOwnership().IsConverged);
+ Assert.Null(fixture.Record.PhysicsBody);
+ Assert.Null(fixture.Movement.Controller);
+ fixture.Dispose();
+ }
+
[Theory]
[InlineData(PublicationInvalidation.SetPositionReplacement)]
[InlineData(PublicationInvalidation.Vector)]
@@ -439,13 +1195,192 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
fixture.Movement.ControllerOwnershipEpoch);
}
+ private static EvaluationPuritySnapshot CaptureEvaluationPurity(
+ Fixture fixture)
+ {
+ PhysicsBody body = fixture.Record.PhysicsBody!;
+ bool hasAwaiting = fixture.Lifetime.Physics.SetPosition
+ .TryGetAwaitingPreparationToken(
+ fixture.Record,
+ out RuntimeEntityPlacementToken awaiting);
+ return new EvaluationPuritySnapshot(
+ fixture.Lifetime.Physics.CaptureOwnership(),
+ fixture.Lifetime.Physics.SetPosition.CaptureOwnership(),
+ fixture.Lifetime.Physics.CollisionReports.CaptureOwnership(),
+ fixture.Lifetime.Physics.CollisionWorldAuthority,
+ fixture.Lifetime.Physics.ShadowWorldAuthority,
+ fixture.Lifetime.Physics.ObjectTable,
+ fixture.Lifetime.Physics.ObjectTableBindingAuthority,
+ fixture.Lifetime.Physics.ObjectTableAuthority,
+ hasAwaiting,
+ awaiting,
+ fixture.Lifetime.Physics.SetPosition
+ .IsExactPreparedPlacementCurrent(
+ fixture.Record,
+ fixture.Placement,
+ fixture.Command),
+ fixture.Record.FullCellId,
+ fixture.Record.SpatialAuthorityVersion,
+ fixture.Record.PlacementCommitVersion,
+ fixture.Record.ObjectClockEpoch,
+ fixture.Record.ObjectClock.PendingSeconds,
+ fixture.Record.ObjectClock.IsActive,
+ CaptureBody(body),
+ CaptureVectorBits(body.WalkableVertices));
+ }
+
+ private static void AssertEvaluationPurity(
+ in EvaluationPuritySnapshot expected,
+ in EvaluationPuritySnapshot actual)
+ {
+ Assert.Equal(expected.PhysicsOwnership, actual.PhysicsOwnership);
+ Assert.Equal(expected.SetPositionOwnership, actual.SetPositionOwnership);
+ Assert.Equal(expected.CollisionOwnership, actual.CollisionOwnership);
+ Assert.Equal(expected.CollisionWorldAuthority,
+ actual.CollisionWorldAuthority);
+ Assert.Equal(expected.ShadowWorldAuthority,
+ actual.ShadowWorldAuthority);
+ Assert.Same(expected.ObjectTable, actual.ObjectTable);
+ Assert.Equal(expected.ObjectTableBindingAuthority,
+ actual.ObjectTableBindingAuthority);
+ Assert.Equal(expected.ObjectTableAuthority,
+ actual.ObjectTableAuthority);
+ Assert.Equal(expected.HasAwaitingPlacement,
+ actual.HasAwaitingPlacement);
+ Assert.Equal(expected.AwaitingPlacement, actual.AwaitingPlacement);
+ Assert.Equal(expected.ExactPlacementCurrent,
+ actual.ExactPlacementCurrent);
+ Assert.Equal(expected.FullCellId, actual.FullCellId);
+ Assert.Equal(expected.SpatialAuthorityVersion,
+ actual.SpatialAuthorityVersion);
+ Assert.Equal(expected.PlacementCommitVersion,
+ actual.PlacementCommitVersion);
+ Assert.Equal(expected.ObjectClockEpoch, actual.ObjectClockEpoch);
+ Assert.Equal(expected.ObjectClockPending,
+ actual.ObjectClockPending);
+ Assert.Equal(expected.ObjectClockActive, actual.ObjectClockActive);
+ Assert.Equal(expected.Body, actual.Body);
+ Assert.True(expected.WalkableVertexBits.AsSpan().SequenceEqual(
+ actual.WalkableVertexBits.AsSpan()));
+ }
+
+ private static BodyPuritySnapshot CaptureBody(PhysicsBody body) => new(
+ body.Position,
+ body.CellPosition,
+ body.InWorld,
+ body.Orientation,
+ body.Velocity,
+ body.CachedVelocity,
+ body.FramesStationaryFall,
+ body.Acceleration,
+ body.Omega,
+ body.GroundNormal,
+ body.SlidingNormal,
+ body.ContactPlaneValid,
+ body.ContactPlane,
+ body.ContactPlaneCellId,
+ body.ContactPlaneIsWater,
+ body.WalkablePolygonValid,
+ body.WalkablePlane,
+ body.WalkableUp,
+ body.Elasticity,
+ body.Friction,
+ body.State,
+ body.TransientState,
+ body.LastUpdateTime,
+ body.IsFullyConstrained,
+ body.LastMoveWasAutonomous);
+
+ private static ImmutableArray CaptureVectorBits(
+ Vector3[]? vectors)
+ {
+ if (vectors is null)
+ return ImmutableArray.Empty;
+ var result = ImmutableArray.CreateBuilder(vectors.Length * 3);
+ foreach (Vector3 vector in vectors)
+ {
+ result.Add(BitConverter.SingleToUInt32Bits(vector.X));
+ result.Add(BitConverter.SingleToUInt32Bits(vector.Y));
+ result.Add(BitConverter.SingleToUInt32Bits(vector.Z));
+ }
+ return result.ToImmutable();
+ }
+
+ private readonly record struct EvaluationPuritySnapshot(
+ RuntimePhysicsOwnershipSnapshot PhysicsOwnership,
+ RuntimeSetPositionOwnershipSnapshot SetPositionOwnership,
+ RuntimeCollisionReportingOwnershipSnapshot CollisionOwnership,
+ ulong CollisionWorldAuthority,
+ ulong ShadowWorldAuthority,
+ ClientObjectTable? ObjectTable,
+ ulong ObjectTableBindingAuthority,
+ ulong ObjectTableAuthority,
+ bool HasAwaitingPlacement,
+ RuntimeEntityPlacementToken AwaitingPlacement,
+ bool ExactPlacementCurrent,
+ uint FullCellId,
+ ulong SpatialAuthorityVersion,
+ ulong PlacementCommitVersion,
+ ulong ObjectClockEpoch,
+ double ObjectClockPending,
+ bool ObjectClockActive,
+ BodyPuritySnapshot Body,
+ ImmutableArray WalkableVertexBits);
+
+ private readonly record struct BodyPuritySnapshot(
+ Vector3 Position,
+ AcDream.Core.Physics.Position CellPosition,
+ bool InWorld,
+ Quaternion Orientation,
+ Vector3 Velocity,
+ Vector3 CachedVelocity,
+ int FramesStationaryFall,
+ Vector3 Acceleration,
+ Vector3 Omega,
+ Vector3 GroundNormal,
+ Vector3 SlidingNormal,
+ bool ContactPlaneValid,
+ Plane ContactPlane,
+ uint ContactPlaneCellId,
+ bool ContactPlaneIsWater,
+ bool WalkablePolygonValid,
+ Plane WalkablePlane,
+ Vector3 WalkableUp,
+ float Elasticity,
+ float Friction,
+ PhysicsStateFlags State,
+ TransientStateFlags TransientState,
+ double LastUpdateTime,
+ bool IsFullyConstrained,
+ bool LastMoveWasAutonomous);
+
private sealed class Fixture : IDisposable
{
private bool _lifetimeDisposed;
- internal Fixture(bool preparePlacement = true)
+ internal Fixture(
+ bool preparePlacement = true,
+ bool residentWorld = false)
{
- Lifetime = new RuntimeEntityObjectLifetime();
+ if (residentWorld)
+ {
+ var engine = new PhysicsEngine
+ {
+ DataCache = new PhysicsDataCache(),
+ };
+ engine.AddLandblock(
+ Cell & 0xFFFF0000u,
+ new TerrainSurface(new byte[81], new float[256]),
+ Array.Empty(),
+ Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+ Lifetime = new RuntimeEntityObjectLifetime(engine);
+ }
+ else
+ {
+ Lifetime = new RuntimeEntityObjectLifetime();
+ }
Movement = new RuntimeLocalPlayerMovementState();
Identity = new RuntimeLocalPlayerIdentityState();
Owner = new RuntimeLocalPlayerPhysicsPublicationState(
@@ -594,6 +1529,106 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Physics: physics);
}
+ private const uint RestrictionObjectId = 0x70003F01u;
+
+ private static void SeedRestrictionObjects(Fixture fixture)
+ {
+ fixture.Lifetime.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = RestrictionObjectId,
+ HouseOwnerId = 0x70003F02u,
+ Restrictions = new HouseRestrictionRecord(
+ OpenToPublic: false,
+ AllegianceMonarchId: 0x70003F03u,
+ Guests: new Dictionary()),
+ });
+ fixture.Lifetime.Objects.AddOrUpdate(new ClientObject
+ {
+ ObjectId = fixture.Record.ServerGuid,
+ MonarchId = 0x70003F03u,
+ });
+ }
+
+ private static void ApplyRestrictionObjectMutation(
+ Fixture fixture,
+ RestrictionObjectMutation mutation)
+ {
+ ClientObjectTable objects = fixture.Lifetime.Objects;
+ switch (mutation)
+ {
+ case RestrictionObjectMutation.RemoveHouseObject:
+ Assert.True(objects.Remove(RestrictionObjectId));
+ break;
+ case RestrictionObjectMutation.HouseOwnerProperty:
+ {
+ ClientObject house = objects.Get(RestrictionObjectId)!;
+ house.HouseOwnerId = 0x70003F04u;
+ break;
+ }
+ case RestrictionObjectMutation.HouseGuestList:
+ objects.Get(RestrictionObjectId)!.Restrictions =
+ new HouseRestrictionRecord(
+ OpenToPublic: false,
+ AllegianceMonarchId: 0x70003F03u,
+ Guests: new Dictionary
+ {
+ [fixture.Record.ServerGuid] = 1u,
+ });
+ break;
+ case RestrictionObjectMutation.MoverMonarch:
+ {
+ ClientObject mover = objects.Get(fixture.Record.ServerGuid)!;
+ mover.MonarchId = 0x70003F05u;
+ break;
+ }
+ default:
+ throw new ArgumentOutOfRangeException(nameof(mutation));
+ }
+ }
+
+ private static RuntimeCollisionGenerationCommit CommitPrepared(
+ RuntimePhysicsState physics,
+ RuntimeCollisionAdmission admission,
+ PreparedLandblockCollisionGeneration prepared)
+ {
+ while (true)
+ {
+ while (!physics.AdvanceCollisionRetainedOwnerCapture(
+ admission,
+ prepared).Completed)
+ {
+ }
+ foreach (uint ownerId in prepared.RetainedOwnerIds)
+ {
+ physics.RefreshCollisionRetainedOwner(
+ admission,
+ prepared,
+ ownerId);
+ }
+ RuntimeCollisionSealStep seal;
+ do
+ {
+ seal = physics.AdvanceCollisionGenerationSeal(
+ admission,
+ prepared);
+ }
+ while (!seal.Completed && !seal.Restarted);
+ if (seal.Completed)
+ break;
+ }
+ return physics.CommitCollisionGeneration(admission, prepared);
+ }
+
+ private static RuntimeLandblockCollisionAssets CollisionAssets(
+ uint landblockId) => new(
+ landblockId,
+ new TerrainSurface(new byte[81], new float[256]),
+ Array.Empty(),
+ Array.Empty(),
+ 0f,
+ 0f,
+ 0u);
+
public enum PublicationInvalidation
{
SetPositionReplacement,
@@ -629,6 +1664,37 @@ public sealed class RuntimeLocalPlayerPhysicsPublicationStateTests
Dispose,
}
+ public enum ActivationInvalidation
+ {
+ Position,
+ Vector,
+ ObjectDescription,
+ Create,
+ Identity,
+ Body,
+ Controller,
+ Clock,
+ Spatial,
+ Host,
+ Remote,
+ Projectile,
+ PlacementCancellation,
+ }
+
+ public enum ReentrantActivationInvalidation
+ {
+ Reset,
+ DeleteAndGuidReuse,
+ }
+
+ public enum RestrictionObjectMutation
+ {
+ RemoveHouseObject,
+ HouseOwnerProperty,
+ HouseGuestList,
+ MoverMonarch,
+ }
+
private static EntityPhysicsHost CreatePhysicsHost(uint id) => new(
id,
getPosition: static () => default,