feat(runtime): seal dormant SetPosition evaluations

This commit is contained in:
Erik 2026-08-01 11:31:58 +02:00
parent 22651c823d
commit 99f867f053
16 changed files with 2298 additions and 62 deletions

View file

@ -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.

View file

@ -179,6 +179,17 @@ public sealed class PropertyBundle
/// </summary>
public sealed class ClientObject
{
private uint? _houseOwnerId;
private uint? _monarchId;
private HouseRestrictionRecord? _restrictions;
/// <summary>
/// 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.
/// </summary>
internal event Action<ClientObject>? 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 <c>WeenieHeaderFlag.Owner</c>, 0x02000000). Zero or a
/// match against the mover's own id admits regardless of the guest list.
/// </summary>
public uint? HouseOwnerId { get; set; }
public uint? HouseOwnerId
{
get => _houseOwnerId;
set
{
if (_houseOwnerId == value) return;
_houseOwnerId = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._monarch_iid</c> (wire <c>WeenieHeaderFlag.Monarch</c>, 0x40) — this
@ -281,7 +301,16 @@ public sealed class ClientObject
/// objects; a player's own value is what <c>RestrictionDB::IsAllowedIn</c>
/// compares against a house's <see cref="HouseRestrictionRecord.AllegianceMonarchId"/>.
/// </summary>
public uint? MonarchId { get; set; }
public uint? MonarchId
{
get => _monarchId;
set
{
if (_monarchId == value) return;
_monarchId = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
/// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._db</c> (<c>RestrictionDB*</c>) — 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.
/// </summary>
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();
/// <summary>

View file

@ -116,11 +116,71 @@ public sealed class ClientObjectTable
private readonly ConcurrentDictionary<uint, Container> _containers = new();
private readonly Dictionary<uint, List<uint>> _containerIndex = new();
private readonly Dictionary<uint, List<uint>> _equipmentIndex = new();
private readonly HashSet<ClientObject> _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<uint, (ClientObjectPlacement placement, int outstanding)> _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;
}
/// <summary>
/// Monotonic authority for live object-table qualities used by physics,
/// including house ownership/restrictions and the mover's monarch.
/// </summary>
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();
}
}
/// <summary>Fires when an object is first added to the session.</summary>
public event Action<ClientObject>? 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<uint>? 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
/// </summary>
public void Clear()
{
foreach (ClientObject item in _restrictionObservedObjects.ToArray())
UnbindRestrictionAuthority(item);
_objects.Clear();
_containers.Clear();
_containerIndex.Clear();

View file

@ -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
/// <c>IsAllowedIn</c> only consults key membership for entry; the permission
/// value is preserved for wire fidelity but not consulted here.</param>
public sealed record HouseRestrictionRecord(
bool OpenToPublic,
uint AllegianceMonarchId,
IReadOnlyDictionary<uint, uint> Guests)
public sealed record HouseRestrictionRecord
{
public HouseRestrictionRecord(
bool OpenToPublic,
uint AllegianceMonarchId,
IReadOnlyDictionary<uint, uint> Guests)
{
ArgumentNullException.ThrowIfNull(Guests);
this.OpenToPublic = OpenToPublic;
this.AllegianceMonarchId = AllegianceMonarchId;
this.Guests = Guests.ToFrozenDictionary();
}
public bool OpenToPublic { get; }
public uint AllegianceMonarchId { get; }
/// <summary>
/// Immutable snapshot of the wire permission table. The parser's mutable
/// dictionary must never remain an untracked mutation path into live
/// collision-entry authority.
/// </summary>
public IReadOnlyDictionary<uint, uint> Guests { get; }
/// <summary>
/// Verbatim port of retail <c>RestrictionDB::IsAllowedIn</c>
/// (named-retail pc:444493-444516, 0x005ae8f0):

View file

@ -25,6 +25,14 @@ public sealed class CellArray : ICollection<uint>, IReadOnlyCollection<uint>
private readonly List<uint> _order = new();
private readonly HashSet<uint> _seen = new();
/// <summary>
/// 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.
/// </summary>
internal CellArray? UnionTarget { get; set; }
public int Count => _order.Count;
public bool IsReadOnly => false;
@ -34,6 +42,11 @@ public sealed class CellArray : ICollection<uint>, IReadOnlyCollection<uint>
/// <summary>Append <paramref name="id"/> iff not already present (retail add_cell dedup).</summary>
public void Add(uint id)
{
if (UnionTarget is { } target
&& !ReferenceEquals(target, this))
{
target.Add(id);
}
if (_seen.Add(id))
_order.Add(id);
}

View file

@ -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
/// </para>
/// </summary>
public static uint FindVisibleChildCell(
PhysicsDataCache cache, uint startCellId, Vector3 worldPoint, bool useStabList)
PhysicsDataCache cache,
uint startCellId,
Vector3 worldPoint,
bool useStabList,
ICollection<uint>? 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<uint> candidates,
uint cellId)
{
if (candidates is CellArray { UnionTarget: { } queryFootprint })
queryFootprint.Add(cellId);
}
private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres)
{
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0;

View file

@ -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.
/// </summary>
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<PhysicsSetPositionCollisionReport, bool>? handleCollisions)
Func<PhysicsSetPositionCollisionReport, bool>? 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<PhysicsSetPositionCollisionReport, bool>? handleCollisions)
Func<PhysicsSetPositionCollisionReport, bool>? 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;

View file

@ -145,7 +145,8 @@ internal readonly record struct PhysicsSetPositionResult(
bool CellChanged = false,
PhysicsShadowCommitAction ShadowAction = PhysicsShadowCommitAction.None,
ImmutableArray<uint> CrossCellIds = default,
ImmutableArray<uint> CollidedObjectIds = default)
ImmutableArray<uint> CollidedObjectIds = default,
ImmutableArray<uint> QueriedCellIds = default)
{
internal bool IsSuccessful => Error == PhysicsSetPositionError.Ok;
internal bool IsCommitted =>

View file

@ -78,6 +78,7 @@ public sealed class ShadowObjectRegistry
_collisionWorld.Current.ShadowOwnerFreeSlots;
private readonly HashSet<uint> _prefixScratch = new();
private readonly List<uint> _removedPrefixScratch = new();
private ulong _mutationRevision;
internal event Action<uint, ulong>? OwnerMutated;
internal event Action<uint, uint>? 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;
/// <summary>
/// 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.
/// </summary>
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<uint>? cells))
@ -1877,6 +1892,13 @@ public sealed class ShadowObjectRegistry
/// </summary>
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();

View file

@ -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();
}

View file

@ -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

View file

@ -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);
}
/// <summary>
@ -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;
}
}
}

View file

@ -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<Action<RuntimeCollisionGenerationCommitted>>
_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;
}
/// <summary>
/// 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.
/// </summary>
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<uint>();
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,

View file

@ -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<RuntimeCollisionGenerationAuthority> 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);
}
/// <summary>
/// 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.
/// </summary>
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

View file

@ -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<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree
{
Root = new CellBSPNode { Type = BSPNodeType.Leaf },
},
Portals =
[
new PortalInfo(
(ushort)(rejected & 0xFFFFu),
portalPolygonId,
0),
],
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>
{
[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<double>([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<double>([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<ushort, ResolvedPolygon>(),
CellBSP = new CellBSPTree
{
Root = new CellBSPNode
{
SplittingPlane = plane,
PosNode = new CellBSPNode { Type = BSPNodeType.Leaf },
},
},
Portals = [new PortalInfo(0xFFFF, 0, 0)],
PortalPolygons = new Dictionary<ushort, ResolvedPolygon>(),
VisibleCellIds = new HashSet<uint>(visibleCells),
};
private static int IndexOfLandblock(
ImmutableArray<uint> cells,
uint landblock)
{
for (int index = 0; index < cells.Length; index++)
{
if ((cells[index] & 0xFFFF0000u)
== (landblock & 0xFFFF0000u))
{
return index;
}
}
return -1;
}
}