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 ## 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 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 Headless production route invokes this transaction, so graphical and no-window
game behavior is unchanged and AP-1/AD-1 remain open. game behavior is unchanged and AP-1/AD-1 remain open.
The checkpoint deliberately stops before canonical SetPosition activation. The checkpoints deliberately stop before canonical SetPosition activation.
It does not consume the prepared placement operation, enter the body into the 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 physics engine, publish FullCell/world/host/shadow/workset state, or project a
presentation entity. Those effects belong to the next transaction and must all presentation entity. Those effects belong to the next transaction and must all
use the same Runtime-owned dormant body. 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 bind, replacement, and unbind edges do. This makes ABA-shaped reference changes
observable even if a later value happens to equal an earlier reference. 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 ## Gates
- Candidate privacy and live-operation rejection. - Candidate privacy and live-operation rejection.
@ -104,23 +170,53 @@ observable even if a later value happens to equal an earlier reference.
placement cancellation. placement cancellation.
- Delete plus same-GUID reincarnation. - Delete plus same-GUID reincarnation.
- Candidate replacement, reset, disposal, and ownership convergence. - 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. - Terminal stale-controller rejection after replacement, reset, and disposal.
- Body/controller epochs advance only on actual ownership changes. - Body/controller epochs advance only on actual ownership changes.
Focused publication, controller, movement, and SetPosition tests pass 167/167. The checkpoint-5 focused publication suite passes 70/70 and the focused Core
The complete Runtime project passes 627/627 under invariant culture. The App SetPosition suite passes 59/59. The complete Runtime project passes 666/666;
Runtime-ownership guard passes 4/4, the complete Release solution builds with the complete Core project passes 4,230 tests / 1 skip; and the App Runtime
zero errors, and the complete Release solution test gate passes 10,374 tests physics/movement ownership guard passes 6/6. The complete Release solution
with 4 intentional skips. Under the machine's Swedish current culture, the builds with zero errors (three pre-existing App-test nullability warnings are
three previously known formatting assertions still fail (`0,5` versus `0.5` outside this checkpoint), and its invariant-culture test gate passes 10,419
and localized sky text); they are unrelated to this checkpoint. 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 ## Next checkpoint
Add the canonical Runtime SetPosition activation transaction. It must evaluate Add the canonical Runtime SetPosition activation transaction. Before its
and commit the already-owned dormant body, consume the exact prepared placement, callback-free tail, it must prepare a presentation-independent Runtime
and atomically establish physics-engine/workset/shadow/FullCell/world/host `PhysicsHost`/`PositionManager`/`MoveToManager` graph, the existing authoritative
ownership before presentation receives an acknowledgement, then invoke the sole authored shadow payload (without rebuilding AP-22 shapes), and an exact staged
`ActivateRuntimePublication` transition. The activation must collision-report batch. Then it may commit the already-owned dormant body,
roll back or leave the operation retryable on every pre-commit failure and must contact/water/walkable/response state, object clock, FullCell, exact shadow,
not construct a second body or controller. 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> /// </summary>
public sealed class ClientObject 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 ObjectId { get; init; }
public uint WeenieClassId { get; set; } // "blueprint" public uint WeenieClassId { get; set; } // "blueprint"
public string Name { get; set; } = ""; public string Name { get; set; } = "";
@ -273,7 +284,16 @@ public sealed class ClientObject
/// object (wire <c>WeenieHeaderFlag.Owner</c>, 0x02000000). Zero or a /// object (wire <c>WeenieHeaderFlag.Owner</c>, 0x02000000). Zero or a
/// match against the mover's own id admits regardless of the guest list. /// match against the mover's own id admits regardless of the guest list.
/// </summary> /// </summary>
public uint? HouseOwnerId { get; set; } public uint? HouseOwnerId
{
get => _houseOwnerId;
set
{
if (_houseOwnerId == value) return;
_houseOwnerId = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
/// <summary> /// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc /// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._monarch_iid</c> (wire <c>WeenieHeaderFlag.Monarch</c>, 0x40) — this /// ._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> /// objects; a player's own value is what <c>RestrictionDB::IsAllowedIn</c>
/// compares against a house's <see cref="HouseRestrictionRecord.AllegianceMonarchId"/>. /// compares against a house's <see cref="HouseRestrictionRecord.AllegianceMonarchId"/>.
/// </summary> /// </summary>
public uint? MonarchId { get; set; } public uint? MonarchId
{
get => _monarchId;
set
{
if (_monarchId == value) return;
_monarchId = value;
RestrictionAuthorityChanged?.Invoke(this);
}
}
/// <summary> /// <summary>
/// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc /// AP-129 (Campaign P Slice P4 review fix): retail <c>PublicWeenieDesc
/// ._db</c> (<c>RestrictionDB*</c>) — the house's own guest/ban list. /// ._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 /// object — acdream cannot distinguish the two, and both resolve to
/// the same retail-faithful "allow" default. /// the same retail-faithful "allow" default.
/// </summary> /// </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(); public PropertyBundle Properties { get; } = new();
/// <summary> /// <summary>

View file

@ -116,11 +116,71 @@ public sealed class ClientObjectTable
private readonly ConcurrentDictionary<uint, Container> _containers = new(); private readonly ConcurrentDictionary<uint, Container> _containers = new();
private readonly Dictionary<uint, List<uint>> _containerIndex = new(); private readonly Dictionary<uint, List<uint>> _containerIndex = new();
private readonly Dictionary<uint, List<uint>> _equipmentIndex = 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 // B-Drag: pre-move snapshots for optimistic inventory moves. itemId → (container, slot, equip) BEFORE
// the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0), // the optimistic MoveItem; restored by RollbackMove on InventoryServerSaveFailed (0x00A0),
// cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo. // cleared by ConfirmMove on the InventoryPutObjInContainer (0x0022) echo.
private readonly Dictionary<uint, (ClientObjectPlacement placement, int outstanding)> _pendingMoves = new(); 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> /// <summary>Fires when an object is first added to the session.</summary>
public event Action<ClientObject>? ObjectAdded; public event Action<ClientObject>? ObjectAdded;
@ -270,7 +330,7 @@ public sealed class ClientObjectTable
ClientObjectPlacement previous = prior is null ClientObjectPlacement previous = prior is null
? default ? default
: ClientObjectPlacement.From(prior); : ClientObjectPlacement.From(prior);
_objects[item.ObjectId] = item; RetainObject(item);
UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item)); UpdateEquipmentIndex(item.ObjectId, previous, ClientObjectPlacement.From(item));
if (!existed) ObjectAdded?.Invoke(item); if (!existed) ObjectAdded?.Invoke(item);
else ObjectUpdated?.Invoke(item); else ObjectUpdated?.Invoke(item);
@ -615,6 +675,7 @@ public sealed class ClientObjectTable
bool notifyObjectRemoved) bool notifyObjectRemoved)
{ {
if (!_objects.TryRemove(itemId, out var item)) return false; if (!_objects.TryRemove(itemId, out var item)) return false;
UnbindRestrictionAuthority(item);
List<uint>? changedContainers = RemoveFromOtherContainerIndexes( List<uint>? changedContainers = RemoveFromOtherContainerIndexes(
itemId, itemId,
exceptContainerId: 0u); exceptContainerId: 0u);
@ -690,7 +751,7 @@ public sealed class ClientObjectTable
if (!existed || item is null) if (!existed || item is null)
{ {
item = new ClientObject { ObjectId = guid }; 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.Ints) item.Properties.Ints[kv.Key] = kv.Value;
foreach (var kv in incoming.Int64s) item.Properties.Int64s[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 if (!existed || obj is null) // keep: satisfies nullable flow analysis
{ {
obj = new ClientObject { ObjectId = d.Guid }; obj = new ClientObject { ObjectId = d.Guid };
_objects[d.Guid] = obj; RetainObject(obj);
} }
uint oldContainer = obj.ContainerId; uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj); ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@ -876,7 +937,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) // keep: satisfies nullable flow analysis if (!existed || obj is null) // keep: satisfies nullable flow analysis
{ {
obj = new ClientObject { ObjectId = guid }; obj = new ClientObject { ObjectId = guid };
_objects[guid] = obj; RetainObject(obj);
} }
uint oldContainer = obj.ContainerId; uint oldContainer = obj.ContainerId;
ClientObjectPlacement previous = ClientObjectPlacement.From(obj); ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@ -955,7 +1016,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) if (!existed || obj is null)
{ {
obj = new ClientObject { ObjectId = entry.Guid }; obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj; RetainObject(obj);
} }
ClientObjectPlacement previous = ClientObjectPlacement.From(obj); ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@ -1249,7 +1310,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) if (!existed || obj is null)
{ {
obj = new ClientObject { ObjectId = entry.Guid }; obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj; RetainObject(obj);
} }
obj.ContainerTypeHint = entry.ContainerType; obj.ContainerTypeHint = entry.ContainerType;
if (!existed) added.Add(obj); if (!existed) added.Add(obj);
@ -1361,7 +1422,7 @@ public sealed class ClientObjectTable
if (!existed || obj is null) if (!existed || obj is null)
{ {
obj = new ClientObject { ObjectId = entry.Guid }; obj = new ClientObject { ObjectId = entry.Guid };
_objects[entry.Guid] = obj; RetainObject(obj);
} }
ClientObjectPlacement previous = ClientObjectPlacement.From(obj); ClientObjectPlacement previous = ClientObjectPlacement.From(obj);
@ -1426,6 +1487,8 @@ public sealed class ClientObjectTable
/// </summary> /// </summary>
public void Clear() public void Clear()
{ {
foreach (ClientObject item in _restrictionObservedObjects.ToArray())
UnbindRestrictionAuthority(item);
_objects.Clear(); _objects.Clear();
_containers.Clear(); _containers.Clear();
_containerIndex.Clear(); _containerIndex.Clear();

View file

@ -1,3 +1,4 @@
using System.Collections.Frozen;
using System.Collections.Generic; using System.Collections.Generic;
namespace AcDream.Core.Items; namespace AcDream.Core.Items;
@ -22,11 +23,29 @@ namespace AcDream.Core.Items;
/// (0 = dwelling access only, 1 = storage access also). Retail's /// (0 = dwelling access only, 1 = storage access also). Retail's
/// <c>IsAllowedIn</c> only consults key membership for entry; the permission /// <c>IsAllowedIn</c> only consults key membership for entry; the permission
/// value is preserved for wire fidelity but not consulted here.</param> /// value is preserved for wire fidelity but not consulted here.</param>
public sealed record HouseRestrictionRecord( public sealed record HouseRestrictionRecord
bool OpenToPublic,
uint AllegianceMonarchId,
IReadOnlyDictionary<uint, uint> Guests)
{ {
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> /// <summary>
/// Verbatim port of retail <c>RestrictionDB::IsAllowedIn</c> /// Verbatim port of retail <c>RestrictionDB::IsAllowedIn</c>
/// (named-retail pc:444493-444516, 0x005ae8f0): /// (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 List<uint> _order = new();
private readonly HashSet<uint> _seen = 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 int Count => _order.Count;
public bool IsReadOnly => false; 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> /// <summary>Append <paramref name="id"/> iff not already present (retail add_cell dedup).</summary>
public void Add(uint id) public void Add(uint id)
{ {
if (UnionTarget is { } target
&& !ReferenceEquals(target, this))
{
target.Add(id);
}
if (_seen.Add(id)) if (_seen.Add(id))
_order.Add(id); _order.Add(id);
} }

View file

@ -169,6 +169,7 @@ public static class CellTransit
// Retail CEnvCell::find_transit_cells first asks the loaded // Retail CEnvCell::find_transit_cells first asks the loaded
// neighbour cell whether the sphere intersects its CellBSP. // neighbour cell whether the sphere intersects its CellBSP.
// The portal-plane side test is only the unloaded-cell load hint. // The portal-plane side test is only the unloaded-cell load hint.
RecordUnionOnlyProbe(candidates, otherId);
var otherCell = cache.GetCellStruct(otherId); var otherCell = cache.GetCellStruct(otherId);
if (otherCell is not null && if (otherCell is not null &&
CollisionTraversal.HasCellContainment(cache, otherCell)) CollisionTraversal.HasCellContainment(cache, otherCell))
@ -446,6 +447,7 @@ public static class CellTransit
if (portal.OtherPortalId < 0) if (portal.OtherPortalId < 0)
continue; continue;
RecordUnionOnlyProbe(candidates, portal.OtherCellId);
var otherCell = cache.GetCellStruct(portal.OtherCellId); var otherCell = cache.GetCellStruct(portal.OtherCellId);
if (otherCell is null || if (otherCell is null ||
!CollisionTraversal.HasCellContainment(cache, otherCell)) !CollisionTraversal.HasCellContainment(cache, otherCell))
@ -679,8 +681,13 @@ public static class CellTransit
/// </para> /// </para>
/// </summary> /// </summary>
public static uint FindVisibleChildCell( 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); var start = cache.GetCellStruct(startCellId);
if (start is null) return 0u; 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) // arg3 != 0 → iterate stab_list, GetVisible + point_in_cell (:311444-311465)
foreach (uint id in start.VisibleCellIds) foreach (uint id in start.VisibleCellIds)
{
probedCells?.Add(id);
if (PointInCell(cache, cache.GetCellStruct(id), worldPoint)) return id; if (PointInCell(cache, cache.GetCellStruct(id), worldPoint)) return id;
}
} }
else else
{ {
// arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434) // arg3 == 0 → iterate direct portals, GetOtherCell + point_in_cell (:311411-311434)
foreach (var portal in start.Portals) foreach (var portal in start.Portals)
{
probedCells?.Add(portal.OtherCellId);
if (PointInCell( if (PointInCell(
cache, cache,
cache.GetCellStruct(portal.OtherCellId), cache.GetCellStruct(portal.OtherCellId),
@ -704,6 +716,7 @@ public static class CellTransit
{ {
return portal.OtherCellId; return portal.OtherCellId;
} }
}
} }
return 0u; return 0u;
@ -1068,7 +1081,11 @@ public static class CellTransit
sphereRadius)) sphereRadius))
{ {
uint recovered = FindVisibleChildCell( uint recovered = FindVisibleChildCell(
cache, currentCellId, worldSphereCenter, useStabList: true); cache,
currentCellId,
worldSphereCenter,
useStabList: true,
(candidates as CellArray)?.UnionTarget);
if (recovered != 0u && recovered != currentCellId) if (recovered != 0u && recovered != currentCellId)
return recovered; return recovered;
} }
@ -1078,6 +1095,14 @@ public static class CellTransit
return currentCellId; 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) private static int EffectiveSphereCount(IReadOnlyList<Sphere> worldSpheres, int numSpheres)
{ {
if (numSpheres <= 0 || worldSpheres.Count == 0) return 0; 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 /// when the restriction weenie can't be resolved — so production MUST
/// wire this to the live table for the fix to actually admit anyone. /// wire this to the live table for the fix to actually admit anyone.
/// </summary> /// </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( internal sealed record LandblockPhysics(
TerrainSurface Terrain, TerrainSurface Terrain,
@ -1553,8 +1568,10 @@ public sealed class PhysicsEngine
private AdjustedSetPosition AdjustSetPosition( private AdjustedSetPosition AdjustSetPosition(
uint seedCellId, uint seedCellId,
Vector3 cellLocalPosition, Vector3 cellLocalPosition,
Vector3 firstWorldSphereCenter) Vector3 firstWorldSphereCenter,
CellArray queryFootprint)
{ {
queryFootprint.Add(seedCellId);
uint low = seedCellId & 0xFFFFu; uint low = seedCellId & 0xFFFFu;
bool lowInRange = low is (>= 1u and <= 0x40u) bool lowInRange = low is (>= 1u and <= 0x40u)
or (>= 0x0100u and <= 0xFFFDu) or (>= 0x0100u and <= 0xFFFDu)
@ -1582,7 +1599,8 @@ public sealed class PhysicsEngine
cache, cache,
seedCellId, seedCellId,
firstWorldSphereCenter, firstWorldSphereCenter,
useStabList: true); useStabList: true,
queryFootprint);
if (child != 0u) if (child != 0u)
{ {
return new AdjustedSetPosition( return new AdjustedSetPosition(
@ -1606,6 +1624,7 @@ public sealed class PhysicsEngine
bool adjusted = LandDefs.AdjustToOutside( bool adjusted = LandDefs.AdjustToOutside(
ref adjustedCell, ref adjustedCell,
ref adjustedLocal); ref adjustedLocal);
queryFootprint.Add(adjustedCell);
bool resident = adjusted bool resident = adjusted
&& IsLandblockTerrainResident(adjustedCell); && IsLandblockTerrainResident(adjustedCell);
return new AdjustedSetPosition( return new AdjustedSetPosition(
@ -1635,33 +1654,54 @@ public sealed class PhysicsEngine
} }
Transition transition = RentTransition(); Transition transition = RentTransition();
CellArray queryFootprint =
transition.SpherePath.SetPositionQueryFootprint;
try try
{ {
queryFootprint.Clear();
transition.SpherePath.CellCandidates.UnionTarget = queryFootprint;
InitializeSetPositionTransition(transition, request); InitializeSetPositionTransition(transition, request);
bool randomOnly = request.Flags.HasFlag( bool randomOnly = request.Flags.HasFlag(
PhysicsSetPositionFlags.RandomScatter); PhysicsSetPositionFlags.RandomScatter);
PhysicsSetPositionResult result;
if (randomOnly) if (randomOnly)
{ {
return SetScatterPositionInternal( result = SetScatterPositionInternal(
transition, transition,
request, 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 = // Scatter retains one append-only query union across every inner
SetPositionInternal(transition, request, handleCollisions); // attempt. Materialize it exactly once at the public transaction
if (result.Error != PhysicsSetPositionError.Ok // boundary; copying it per attempt is quadratic at retail's
&& request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter)) // maximum retry count.
return result with
{ {
return SetScatterPositionInternal( QueriedCellIds = queryFootprint.OrderedIds.ToImmutableArray(),
transition, };
request,
handleCollisions);
}
return result;
} }
finally finally
{ {
transition.SpherePath.CellCandidates.UnionTarget = null;
ReturnTransition(transition); ReturnTransition(transition);
} }
} }
@ -1686,7 +1726,8 @@ public sealed class PhysicsEngine
private PhysicsSetPositionResult SetScatterPositionInternal( private PhysicsSetPositionResult SetScatterPositionInternal(
Transition transition, Transition transition,
in PhysicsSetPositionRequest request, in PhysicsSetPositionRequest request,
Func<PhysicsSetPositionCollisionReport, bool>? handleCollisions) Func<PhysicsSetPositionCollisionReport, bool>? handleCollisions,
CellArray queryFootprint)
{ {
PhysicsSetPositionResult result = ErrorResult( PhysicsSetPositionResult result = ErrorResult(
request, request,
@ -1706,7 +1747,8 @@ public sealed class PhysicsEngine
result = SetPositionInternal( result = SetPositionInternal(
transition, transition,
scattered, scattered,
handleCollisions); handleCollisions,
queryFootprint);
if (result.Error == PhysicsSetPositionError.Ok) if (result.Error == PhysicsSetPositionError.Ok)
break; break;
} }
@ -1716,7 +1758,8 @@ public sealed class PhysicsEngine
private PhysicsSetPositionResult SetPositionInternal( private PhysicsSetPositionResult SetPositionInternal(
Transition transition, Transition transition,
in PhysicsSetPositionRequest request, in PhysicsSetPositionRequest request,
Func<PhysicsSetPositionCollisionReport, bool>? handleCollisions) Func<PhysicsSetPositionCollisionReport, bool>? handleCollisions,
CellArray queryFootprint)
{ {
transition.SpherePath.CellCandidates.Clear(); transition.SpherePath.CellCandidates.Clear();
transition.SpherePath.ClearWalkable(); transition.SpherePath.ClearWalkable();
@ -1731,7 +1774,8 @@ public sealed class PhysicsEngine
AdjustedSetPosition adjusted = AdjustSetPosition( AdjustedSetPosition adjusted = AdjustSetPosition(
request.CellId, request.CellId,
request.CellLocalPosition, request.CellLocalPosition,
firstWorldCenter); firstWorldCenter,
queryFootprint);
if (!adjusted.Resident) if (!adjusted.Resident)
{ {
return new PhysicsSetPositionResult( return new PhysicsSetPositionResult(
@ -1849,7 +1893,9 @@ public sealed class PhysicsEngine
} }
if (spherePath.CurCellId == 0u) if (spherePath.CurCellId == 0u)
{ {
return ErrorResult(request, PhysicsSetPositionError.NoCell); return ErrorResult(
request,
PhysicsSetPositionError.NoCell);
} }
bool inContact = collision.ContactPlaneValid; bool inContact = collision.ContactPlaneValid;

View file

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

View file

@ -78,6 +78,7 @@ public sealed class ShadowObjectRegistry
_collisionWorld.Current.ShadowOwnerFreeSlots; _collisionWorld.Current.ShadowOwnerFreeSlots;
private readonly HashSet<uint> _prefixScratch = new(); private readonly HashSet<uint> _prefixScratch = new();
private readonly List<uint> _removedPrefixScratch = new(); private readonly List<uint> _removedPrefixScratch = new();
private ulong _mutationRevision;
internal event Action<uint, ulong>? OwnerMutated; internal event Action<uint, ulong>? OwnerMutated;
internal event Action<uint, uint>? OwnerPrefixMembershipChanged; internal event Action<uint, uint>? OwnerPrefixMembershipChanged;
@ -101,6 +102,7 @@ public sealed class ShadowObjectRegistry
"A populated shadow registry cannot change collision roots."); "A populated shadow registry cannot change collision roots.");
} }
_collisionWorld = collisionWorld; _collisionWorld = collisionWorld;
AdvanceMutationRevision();
} }
internal sealed record RegistrationRecord( internal sealed record RegistrationRecord(
@ -123,8 +125,20 @@ public sealed class ShadowObjectRegistry
? version ? version
: 0UL; : 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) private void BumpOwnerVersion(uint entityId)
{ {
AdvanceMutationRevision();
ulong version = checked(GetOwnerVersion(entityId) + 1UL); ulong version = checked(GetOwnerVersion(entityId) + 1UL);
_ownerVersions[entityId] = version; _ownerVersions[entityId] = version;
RefreshOwnerPrefixIndex(entityId); RefreshOwnerPrefixIndex(entityId);
@ -1308,6 +1322,7 @@ public sealed class ShadowObjectRegistry
DeregisterCore(entityId, publishMutation: false); DeregisterCore(entityId, publishMutation: false);
RemoveOwnerPrefixMembership(entityId); RemoveOwnerPrefixMembership(entityId);
_ownerVersions.Remove(entityId); _ownerVersions.Remove(entityId);
AdvanceMutationRevision();
return; return;
} }
if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells)) if (!_entityToCells.TryGetValue(entityId, out List<uint>? cells))
@ -1877,6 +1892,13 @@ public sealed class ShadowObjectRegistry
/// </summary> /// </summary>
public void Clear() 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(); _cells.Clear();
_entityToCells.Clear(); _entityToCells.Clear();
_suspendedEntities.Clear(); _suspendedEntities.Clear();

View file

@ -719,6 +719,7 @@ public sealed class SpherePath
private Vector3[]? _walkableVertexStorage; private Vector3[]? _walkableVertexStorage;
private Vector3[]? _lastWalkableVertexStorage; private Vector3[]? _lastWalkableVertexStorage;
internal readonly CellArray CellCandidates = new(); internal readonly CellArray CellCandidates = new();
internal readonly CellArray SetPositionQueryFootprint = new();
internal readonly CellOrderScratchArena OrderedCellScratch = new(); internal readonly CellOrderScratchArena OrderedCellScratch = new();
internal Vector3[]? RetainedWalkableVertexStorage => _walkableVertexStorage; internal Vector3[]? RetainedWalkableVertexStorage => _walkableVertexStorage;
@ -938,6 +939,8 @@ public sealed class SpherePath
if (_lastWalkableVertexStorage is not null) if (_lastWalkableVertexStorage is not null)
System.Array.Clear(_lastWalkableVertexStorage); System.Array.Clear(_lastWalkableVertexStorage);
CellCandidates.Clear(); CellCandidates.Clear();
CellCandidates.UnionTarget = null;
SetPositionQueryFootprint.Clear();
OrderedCellScratch.ResetForReuse(); OrderedCellScratch.ResetForReuse();
} }

View file

@ -704,6 +704,9 @@ public sealed class PlayerMovementController
internal bool IsSealedPublicationCandidate => _publicationLifecycle internal bool IsSealedPublicationCandidate => _publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.CandidateSealed; is PlayerMovementControllerPublicationLifecycle.CandidateSealed;
internal bool IsRuntimeOwnedDormant => _publicationLifecycle
is PlayerMovementControllerPublicationLifecycle.RuntimeOwnedDormant;
internal void SealPublicationCandidate() internal void SealPublicationCandidate()
{ {
if (_publicationLifecycle if (_publicationLifecycle

View file

@ -14,6 +14,15 @@ internal enum RuntimeLocalPlayerPhysicsPublicationStatus
Discarded, Discarded,
} }
internal enum RuntimeLocalPlayerPhysicsActivationStatus
{
Evaluated,
DeferredCell,
RejectedPlacement,
RejectedAuthority,
RejectedToken,
}
internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken( internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken(
RuntimeEntityKey Entity, RuntimeEntityKey Entity,
RuntimeEntityPlacementToken Placement, RuntimeEntityPlacementToken Placement,
@ -31,6 +40,33 @@ internal readonly record struct RuntimeLocalPlayerPhysicsPublicationToken(
&& Entity == Placement.Entity; && 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( internal readonly record struct RuntimeLocalPlayerPhysicsCandidateSnapshot(
Vector3 Position, Vector3 Position,
Quaternion Orientation, Quaternion Orientation,
@ -45,10 +81,13 @@ public readonly record struct
bool IsBound, bool IsBound,
bool IsDisposed, bool IsDisposed,
int CandidateCount, int CandidateCount,
int PendingActivationCount,
ulong LastPublicationId) ulong LastPublicationId)
{ {
internal bool IsConverged => !IsBound internal bool IsConverged => !IsBound
|| (IsDisposed && CandidateCount == 0); || (IsDisposed
&& CandidateCount == 0
&& PendingActivationCount == 0);
} }
/// <summary> /// <summary>
@ -74,12 +113,28 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
internal required PhysicsBody Body { get; init; } 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 RuntimeEntityDirectory _entities;
private readonly RuntimePhysicsState _physics; private readonly RuntimePhysicsState _physics;
private readonly RuntimeLocalPlayerMovementState _movement; private readonly RuntimeLocalPlayerMovementState _movement;
private readonly RuntimeLocalPlayerIdentityState _identity; private readonly RuntimeLocalPlayerIdentityState _identity;
private Candidate? _candidate; private Candidate? _candidate;
private Activation? _activation;
private ulong _nextPublicationId; private ulong _nextPublicationId;
private ulong _nextActivationId;
private ulong _nextEvaluationId;
private bool _disposed; private bool _disposed;
internal RuntimeLocalPlayerPhysicsPublicationState( internal RuntimeLocalPlayerPhysicsPublicationState(
@ -161,9 +216,15 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
} }
internal RuntimeLocalPlayerPhysicsPublicationStatus Commit( 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); ObjectDisposedException.ThrowIf(_disposed, this);
activationToken = default;
if (!token.IsValid if (!token.IsValid
|| _candidate is not { } candidate || _candidate is not { } candidate
|| candidate.Token != token) || candidate.Token != token)
@ -184,10 +245,104 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
candidate.Record.ObjectClock); candidate.Record.ObjectClock);
candidate.Record.SetPhysicsBody(candidate.Body); candidate.Record.SetPhysicsBody(candidate.Body);
_movement.CommitRuntimeOwnedController(candidate.Controller); _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; _candidate = null;
return RuntimeLocalPlayerPhysicsPublicationStatus.Committed; 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( internal RuntimeLocalPlayerPhysicsPublicationStatus Discard(
in RuntimeLocalPlayerPhysicsPublicationToken token) in RuntimeLocalPlayerPhysicsPublicationToken token)
{ {
@ -231,12 +386,14 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
IsBound: true, IsBound: true,
_disposed, _disposed,
_candidate is null ? 0 : 1, _candidate is null ? 0 : 1,
_activation is null ? 0 : 1,
_nextPublicationId); _nextPublicationId);
internal void ResetSession() internal void ResetSession()
{ {
ObjectDisposedException.ThrowIf(_disposed, this); ObjectDisposedException.ThrowIf(_disposed, this);
DiscardCurrent(); DiscardCurrent();
DiscardActivation();
} }
public void Dispose() public void Dispose()
@ -244,6 +401,7 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
if (_disposed) if (_disposed)
return; return;
DiscardCurrent(); DiscardCurrent();
DiscardActivation();
_disposed = true; _disposed = true;
} }
@ -251,7 +409,8 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
RuntimeEntityRecord record, RuntimeEntityRecord record,
in RuntimeEntityPlacementToken placement, in RuntimeEntityPlacementToken placement,
in RuntimeSetPositionCommand command) => in RuntimeSetPositionCommand command) =>
record.Key is { } key _activation is null
&& record.Key is { } key
&& key == placement.Entity && key == placement.Entity
&& _entities.IsCurrent(record) && _entities.IsCurrent(record)
&& !record.DeleteAcceptedForTeardown && !record.DeleteAcceptedForTeardown
@ -276,7 +435,8 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
command); command);
private bool IsCurrent(Candidate candidate) => private bool IsCurrent(Candidate candidate) =>
candidate.Controller.IsSealedPublicationCandidate _activation is null
&& candidate.Controller.IsSealedPublicationCandidate
&& candidate.Controller.OwnsPhysicsBody(candidate.Body) && candidate.Controller.OwnsPhysicsBody(candidate.Body)
&& _entities.SessionLifetimeVersion && _entities.SessionLifetimeVersion
== candidate.Token.SessionGenerationAuthority == candidate.Token.SessionGenerationAuthority
@ -313,4 +473,55 @@ internal sealed class RuntimeLocalPlayerPhysicsPublicationState : IDisposable
_candidate = null; _candidate = null;
candidate?.Controller.DiscardRuntimeCandidate(); 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.Core.Physics;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
@ -1042,6 +1044,7 @@ public sealed class RuntimePhysicsState : IDisposable
private bool _suppressCollisionOwnerJournal; private bool _suppressCollisionOwnerJournal;
private long _nextCollisionPreparationSequence; private long _nextCollisionPreparationSequence;
private long _latestCollisionPreparationStartSequence; private long _latestCollisionPreparationStartSequence;
private ulong _collisionWorldAuthority = 1UL;
private readonly List<Action<RuntimeCollisionGenerationCommitted>> private readonly List<Action<RuntimeCollisionGenerationCommitted>>
_collisionGenerationCommittedObservers = new(); _collisionGenerationCommittedObservers = new();
private bool _disposed; private bool _disposed;
@ -1898,6 +1901,7 @@ public sealed class RuntimePhysicsState : IDisposable
EnsureNotDisposed(); EnsureNotDisposed();
EnsureCollisionMutationThread(); EnsureCollisionMutationThread();
uint canonical = CanonicalLandblock(landblockId); uint canonical = CanonicalLandblock(landblockId);
AdvanceCollisionWorldAuthority();
if (_collisionAdmissions.Remove( if (_collisionAdmissions.Remove(
canonical, canonical,
out RuntimeCollisionAdmission? superseded)) out RuntimeCollisionAdmission? superseded))
@ -2013,6 +2017,7 @@ public sealed class RuntimePhysicsState : IDisposable
admission.Generation); admission.Generation);
_collisionGenerations[admission.LandblockId] = checked( _collisionGenerations[admission.LandblockId] = checked(
admission.Generation + 1UL); admission.Generation + 1UL);
AdvanceCollisionWorldAuthority();
} }
TrimCollisionOwnerJournal(); TrimCollisionOwnerJournal();
} }
@ -2226,6 +2231,7 @@ public sealed class RuntimePhysicsState : IDisposable
try try
{ {
Engine.CommitLandblockReplacement(replacement); Engine.CommitLandblockReplacement(replacement);
AdvanceCollisionWorldAuthority();
} }
finally finally
{ {
@ -2496,6 +2502,7 @@ public sealed class RuntimePhysicsState : IDisposable
private void InvalidateCollisionAdmission(uint landblockId) private void InvalidateCollisionAdmission(uint landblockId)
{ {
AdvanceCollisionWorldAuthority();
ulong currentGeneration = _collisionGenerations.TryGetValue( ulong currentGeneration = _collisionGenerations.TryGetValue(
landblockId, landblockId,
out ulong current) out ulong current)
@ -2540,6 +2547,130 @@ public sealed class RuntimePhysicsState : IDisposable
: 1UL; : 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( internal bool HandleSetPositionCollisions(
RuntimeEntityRecord record, RuntimeEntityRecord record,
ulong positionAuthorityVersion, ulong positionAuthorityVersion,

View file

@ -1,5 +1,6 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Numerics; using System.Numerics;
using AcDream.Core.Items;
using AcDream.Core.Net.Messages; using AcDream.Core.Net.Messages;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using AcDream.Runtime.Entities; using AcDream.Runtime.Entities;
@ -82,6 +83,32 @@ internal readonly record struct RuntimeSetPositionCommand(
float ShadowWorldOffsetY = 0f, float ShadowWorldOffsetY = 0f,
RuntimePortalPlacementAuthority Portal = default); 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( public readonly record struct RuntimePlacementProjectionToken(
ulong Sequence, ulong Sequence,
ulong Revision, ulong Revision,
@ -597,6 +624,98 @@ internal sealed class RuntimeSetPositionState : IDisposable
&& IsPreparationAuthorityCurrent(operation, authority); && 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( internal RuntimeSetPositionOutcome SubmitPreparedPlacement(
in RuntimeEntityPlacementToken token, in RuntimeEntityPlacementToken token,
in RuntimeSetPositionCommand command) => in RuntimeSetPositionCommand command) =>
@ -1881,6 +2000,69 @@ internal sealed class RuntimeSetPositionState : IDisposable
&& operation.Record.PlacementCommitVersion && operation.Record.PlacementCommitVersion
== operation.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) => private bool IsVelocityCurrent(Operation operation) =>
operation.SourceVelocityAuthorityVersion == 0UL operation.SourceVelocityAuthorityVersion == 0UL
|| operation.Record.VelocityAuthorityVersion || operation.Record.VelocityAuthorityVersion

View file

@ -1,6 +1,8 @@
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Numerics; using System.Numerics;
using AcDream.Core.Physics; using AcDream.Core.Physics;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
namespace AcDream.Core.Tests.Physics; namespace AcDream.Core.Tests.Physics;
@ -32,7 +34,7 @@ public sealed class PhysicsSetPositionTests
var local = new Vector3(193f, 12f, 7f); var local = new Vector3(193f, 12f, 7f);
uint expectedCell = Cell; uint expectedCell = Cell;
Vector3 expectedLocal = local; Vector3 expectedLocal = local;
Assert.True(LandDefs.AdjustToOutside( Assert.True(AcDream.Core.Physics.LandDefs.AdjustToOutside(
ref expectedCell, ref expectedCell,
ref expectedLocal)); ref expectedLocal));
@ -46,6 +48,9 @@ public sealed class PhysicsSetPositionTests
Assert.Equal(expectedCell, result.CellId); Assert.Equal(expectedCell, result.CellId);
Assert.Equal(expectedLocal, result.CellLocalPosition); Assert.Equal(expectedLocal, result.CellLocalPosition);
Assert.Equal(new Vector3(193f, 12f, 7f), result.Position); Assert.Equal(new Vector3(193f, 12f, 7f), result.Position);
Assert.Equal(
new[] { Cell, expectedCell }.Distinct(),
result.QueriedCellIds);
} }
[Fact] [Fact]
@ -82,6 +87,194 @@ public sealed class PhysicsSetPositionTests
Assert.Equal(0u, result.CellId); Assert.Equal(0u, result.CellId);
Assert.Equal(local, result.CellLocalPosition); Assert.Equal(local, result.CellLocalPosition);
Assert.Equal(local, result.Position); 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] [Fact]
@ -867,6 +1060,91 @@ public sealed class PhysicsSetPositionTests
Assert.Equal(new Vector3(11f, 11f, 10f), result.Position); 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] [Fact]
public void Scatter_ReusesOneTransitionAndFailedInnerProbeCannotLeakIntoSuccess() public void Scatter_ReusesOneTransitionAndFailedInnerProbeCannotLeakIntoSuccess()
{ {
@ -1038,4 +1316,43 @@ public sealed class PhysicsSetPositionTests
worldOffsetX, worldOffsetX,
worldOffsetY); 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;
}
} }