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,