diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 8281655c..4c37cc4a 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -74,6 +74,12 @@ by the DAT-authored portal-space viewport. Recent additions and splits:
AD-47/AD-48 (Vulkan sample/present behavior), AD-50..AD-52 (Campaign N), and
AD-53..AD-55 (Campaign P response-layer findings).
+AD-2 clarification (placement Slice 4A, 2026-07-31): Core never creates cells
+during placement, so retail `DoNotCreateCells` has no differential synchronous
+loader branch there. Slice 4B must preserve the flag while mapping successful
+deferred placement to exact-cell, generation-scoped asynchronous admission;
+the presence of the flag in the immutable request is not claimed as exactness.
+
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
| ~~AD-53~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `Transition.CliffSlide` now consumes only `collision_info.last_known_contact_plane.N`, exactly as retail does. The invented `LastWalkablePlane -> LastKnownContactPlane -> UnitZ` fallback chain is gone; invalid/default or parallel data takes retail's degenerate `OK_TS` return. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`CliffSlide`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::cliff_slide` pc:272397 (0050a6d0); `last_known_contact_plane` maintenance pc:272659-272668 (~0050ad07) |
@@ -126,7 +132,7 @@ AD-53..AD-55 (Campaign P response-layer findings).
---
-## 3. Documented approximation (AP) — 87 active rows (AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
+## 3. Documented approximation (AP) — 87 active rows (AP-1 narrowed 2026-07-31 by placement/streaming Slice 4A — the pure canonical retail `SetPosition` transaction exists, but production routes and lost-cell lifetime remain on the legacy resolver until Slice 4B; AP-5 retired 2026-07-31 at Campaign P Slice 2A — every successful `step_down` now performs retail's final `PLACEMENT_INSERT`; AP-3/AP-4 retired 2026-07-31 at Campaign P Slice 1B — `transitional_insert` and `edge_slide` now preserve retail's valid-contact early return and Branch-1-first order; AP-127 retired 2026-07-31 by #268 — the complete augmentation chain is shared by character UI and Runtime movement; AP-30 retired 2026-07-30 by the movement parity audit — retail Frame::is_equal genuinely uses the 0.0002 epsilon [byte-confirmed], so the row recorded a NON-divergence; acdream already matches; AP-129 narrowed 2026-07-30 at the P4 Opus review fix — `CanMoveInto`/`RestrictionDB::IsAllowedIn` are now ported and fed end-to-end (CreateObject HouseOwner/HouseRestrictions/Monarch tail fields + live `House_UpdateRestrictions 0x0248`, resolved through `PhysicsEngine.Objects`), retiring the original "CanMoveInto entirely unmodeled, unconditional fail-closed" gap the row described — the review was triggered by `RestrictionObjPrevalenceInspectionTests` showing 103,766 of 729,888 installed EnvCells (the whole housing estate) carry a baked `RestrictionObj`, so the unconditional fail-closed default would have locked every house for every player including its own owner; AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@@ -134,7 +140,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| # | Divergence | Where (file:line) | Why it is safe / justified | Risk if assumption breaks | Retail oracle |
|---|---|---|---|---|---|
-| AP-1 | Snap-path Z settle: validated claims ground on their own walkable polys, but floor-less claims (thresholds, stair lips) fall through to a legacy nearest-in-Z scan over every CellSurface in the landblock; retail settles via `CheckPositionInternal` → `find_valid_position` | `src/AcDream.Core/Physics/PhysicsEngine.cs:614` | `find_valid_position` unported; the **#111** fix narrowed the legacy pick's blast radius (validated claims bypass it) rather than replacing it | A threshold/stair-lip snap can still pick a neighbouring cell's same-height floor by iteration order — wrong cell or Z at login/teleport arrival (the #111 clobber class) | `SetPositionInternal` :283426 → find_valid_position |
+| AP-1 | **NARROWED 2026-07-31 (placement/streaming Slice 4A).** Core now exposes the pure retail `SetPosition` → `AdjustPosition` → `CheckPositionInternal` → `find_valid_position` transaction, including exact sphere initialization, two distinct placement validators, step-down schedule, force classes, flags/scatter, signed no-slide predicate, error values, and an explicit deferred-residence result. Production zero-delta routes deliberately still use the legacy nearest-in-Z/terrain-lift resolver because its `ResolveResult` cannot represent a successful lost-cell transition. Slice 4B must cut every authoritative route to the canonical packet and install the Runtime lost-cell owner before this row retires. | `src/AcDream.Core/Physics/PhysicsSetPosition.cs`; `src/AcDream.Core/Physics/PhysicsEngine.cs`; `src/AcDream.Core/Physics/TransitionTypes.cs`; `tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs`; `docs/research/2026-07-31-canonical-set-position.md` | Split preserves production behavior while the immutable placement mechanism and its oracle gates land independently | Until 4B, fresh spawn, same-generation refresh, authoritative Position, portal arrival, external teleport, parent detach, pickup release, and world-drop hydration can still run the old approximation and cannot park/wake an exact lost-cell frame | `CPhysicsObj::SetPosition` 0x005160C0; `SetPositionInternal` 0x00515BD0; `AdjustPosition` 0x00511D80; `CheckPositionInternal` 0x00511E90; `CTransition::find_valid_position` 0x0050C310; `find_placement_position` 0x0050C170; `validate_placement_transition` 0x0050ADC0; `validate_placement` 0x0050B210 |
| ~~AP-3~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `TransitionalInsert` now returns `OK_TS` immediately for every valid contact plane. Its ordinary StepDown tail is reachable only from invalid contact and retains the retail Contact / `!sphere_path.step_down` / check-cell / ObjectInfo.StepDown gates plus the exact one-versus-two-sphere probe split. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`TransitionalInsert`, `GetStepDownProbePlan`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::transitional_insert` 0x0050B6F0, named-retail pseudo-C pc:273191–273307 |
| ~~AP-4~~ | **RETIRED 2026-07-31 (Campaign P Slice 1B).** `EdgeSlideAfterStepDownFailed` now evaluates retail Branch 1 (`!OnWalkable || !EdgeSlide` → restore + `OK_TS`) before the steep-contact `CliffSlide` branch. The former compensation is removed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed`); `tests/AcDream.Core.Tests/Physics/RetailEdgeResponseOrderingTests.cs` | — | — | `CTransition::edge_slide` 0x0050B3D0, named-retail pseudo-C pc:273001–273090 |
diff --git a/docs/research/2026-07-31-canonical-set-position.md b/docs/research/2026-07-31-canonical-set-position.md
new file mode 100644
index 00000000..f166645e
--- /dev/null
+++ b/docs/research/2026-07-31-canonical-set-position.md
@@ -0,0 +1,142 @@
+# Canonical retail `SetPosition` — placement/streaming Slice 4A
+
+## Scope
+
+This note pins the pure physics half of the placement/streaming closeout.
+Slice 4A lands the retail placement transaction as a separately testable Core
+mechanism. It deliberately does **not** replace the production snap-only
+resolver yet: Runtime lost-cell ownership and the complete inbound-route
+cutover remain Slice 4B. AP-1 and AD-1 therefore remain active until that
+cutover is complete.
+
+Named-retail oracle, Sept 2013 EoR:
+
+- `CPhysicsObj::SetPosition` `0x005160C0`
+- `CPhysicsObj::SetPositionInternal` `0x00515BD0`
+- `CPhysicsObj::AdjustPosition` `0x00511D80`
+- `CPhysicsObj::CheckPositionInternal` `0x00511E90`
+- `CTransition::find_valid_position` `0x0050C310`
+- `CTransition::find_placement_position` `0x0050C170`
+- `CTransition::find_placement_pos` `0x0050BA50`
+- `CTransition::validate_placement_transition` `0x0050ADC0`
+- `CTransition::validate_placement` `0x0050B210`
+- `CPhysicsObj::ForceIntoCell` `0x00515660`
+- `CPhysicsObj::handle_all_collisions` `0x00514780`
+
+## Retail transaction
+
+```text
+SetPosition(request):
+ transition = makeTransition() // GENERAL_FAILURE if none
+ init_object(transition, object)
+
+ if the PartArray has no spheres:
+ init_sphere(1, dummy center=(0,0,0.1), radius=0.1, scale=1)
+ else:
+ init_sphere(first min(count,2) authored spheres, exact object scale)
+
+ if flags & RANDOM_SCATTER (0x200):
+ return scatter only
+
+ result = SetPositionInternal(request)
+ if result != OK and flags & SCATTER (0x100):
+ return scatter
+ return result
+
+SetPositionInternal(request):
+ AdjustPosition(request frame, first sphere, noCreate=(flags & 0x20))
+ if no resident cell:
+ store the adjusted authoritative frame and enter lost-cell lifetime
+ return OK
+
+ if the live weenie is Hook, Storage, or Corpse:
+ return ForceIntoCell(resident cell, frame)
+
+ set do_not_load_cells from flag 0x20
+ if !CheckPositionInternal(...):
+ handled = handle_all_collisions(...)
+ return handled ? COLLIDED : NO_VALID_POSITION
+ if transition.curr_cell == null:
+ return NO_CELL
+ commit the complete transition
+ return OK
+```
+
+`AdjustPosition` branches on the claimed cell shape. A direct outdoor claim
+runs pure `LandDefs::adjust_to_outside` normalization before visible-cell
+lookup. An indoor claim first resolves the visible cell and child; only a
+resident indoor cell marked `seen_outside` falls back to outdoor
+normalization. An absent indoor cell (including the `0xFFFF` sentinel) remains
+the exact claimed cell/frame. A map-edge outdoor normalization failure stores
+cell zero with the otherwise unchanged frame. The old `max(terrainZ, z)` lift
+and nearest-in-Z scan do not occur in this canonical mechanism.
+
+`CheckPositionInternal` calls the complete placement transition. Without the
+slide flag, retail accepts the result only when signed
+`resolvedX-requestedX <= 0.0500000007`, the same signed Y condition holds, and
+the cell is unchanged. Z is not part of that predicate. The resolved origin is
+accepted while the requested orientation remains intact.
+
+## Two validators, not one
+
+The similarly named retail helpers have different contracts and stay separate
+in the port:
+
+- `validate_placement_transition` is the inner `find_placement_pos` validator.
+ Any non-OK state from `COLLIDED` through `SLID`, when sliding is permitted,
+ resets `COLLISIONINFO`; it never retries placement.
+- `validate_placement` is the outer initial/final validator. Only `ADJUSTED`
+ or `SLID`, and only while its retry argument is true, performs one
+ `placement_insert`; `COLLIDED` neither resets nor retries.
+
+Step-down is disabled only for missiles. For fewer than two spheres retail
+first clamps the requested height to half the radius when the sphere diameter
+is less than or equal to that height. It then performs one full probe when the
+diameter is greater than the resulting height, otherwise two half probes.
+Equality belongs to the two-half-probe branch.
+
+## Modern seam
+
+`PhysicsEngine.SetPosition` returns one immutable
+`PhysicsSetPositionResult`. `SetPositionError` retains the header values
+(`OK=0`, `GENERAL=1`, `NO_VALID=2`, `NO_CELL=3`, `COLLIDED=4`,
+`INVALID_ARGS=0x100`) while `PhysicsResidenceDisposition` separately reports
+`Committed`, `DeferredCell`, or `Unchanged`. Missing content is therefore
+successful-but-deferred, never misreported as a placement failure.
+
+The result carries the complete commit packet: root/cell-local frame,
+contact/walkable/water state, sliding and collision normals, stationary-fall
+counter, a complete immutable `COLLISIONINFO` snapshot for the real
+`handle_all_collisions` callback (including contact/last-contact, sliding,
+collision normal, stationary-fall, environment, adjustment, and object
+fields), the callback result, and an explicit shadow action. A PhysicsBSP or changed-cell force commit
+requests canonical shadow recalculation; a non-BSP transition replaces its
+shadows only when the transition produced a nonempty cell array, otherwise it
+preserves the prior list. Unchanged force placement changes only the frame.
+
+Core never creates cells synchronously, so retail flag `0x20`
+(`DoNotCreateCells`) has no differential loader branch inside this pure
+mechanism. Both flag states can only observe already-published immutable cell
+content and otherwise return `DeferredCell`. Carrying the flag into exact-cell,
+generation-scoped async admission is part of the still-open Slice 4B
+adaptation tracked with AD-2; this slice does not claim a dead SpherePath field
+as exact behavior.
+
+The existing public `Resolve` compatibility entry remains entirely unchanged
+for production movement and zero-delta callers. Its result cannot represent a
+successful-but-deferred residence, so hiding `DeferredCell` inside
+`ResolveResult.Ok` would corrupt the contract. Slice 4B will route every
+authoritative placement family through `SetPosition`, atomically install its
+packet, and own exact lost-cell wakeup/commit.
+
+## Automated oracle
+
+`PhysicsSetPositionTests` pins error values, absent/invalid outdoor and indoor
+claims, cross-landblock frame normalization, map-edge failure and the `0xFFFF`
+sentinel, dummy/authored sphere setup, nonpositive scale, Ethereal seeding,
+explicit-only PathClipped, missile step-down, exact equality schedules, both validators,
+the late compass sample's float bits, signed no-slide behavior, actual
+collision-handler mapping, force-class policy, explicit shadow actions, null
+current-cell wakeup, ten-record scratch exhaustion, exact scatter ordering,
+failed-probe scratch lifetime, and deferred-scatter stop. The legacy public
+Resolve fixture remains unchanged until Slice 4B.
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index d4d7610b..597bed30 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -89,6 +89,14 @@ public sealed class PhysicsEngine
TransitionState,
TransitionState>? TransitionCellCollisionTestHook { get; set; }
+ ///
+ /// Deterministic seam for retail Random::RollDice(-1, 1) used by
+ /// SetPosition scatter. Values are expected in [0,1); production uses the
+ /// process RNG and tests inject a fixed sequence.
+ ///
+ internal Func SetPositionRandomUnit { get; set; } =
+ Random.Shared.NextDouble;
+
///
/// True once the landblock covering has had its
/// terrain + cells registered via . Accepts a canonical
@@ -1530,6 +1538,391 @@ public sealed class PhysicsEngine
/// green).
///
///
+ private readonly record struct AdjustedSetPosition(
+ uint CellId,
+ Vector3 CellLocalPosition,
+ bool Resident);
+
+ ///
+ /// SetPosition's exact AdjustPosition input/output shape. Unlike the
+ /// camera helper above, this carries retail's block-local frame and can
+ /// therefore run outdoor LandDefs::adjust_to_outside without
+ /// consulting the resident-landblock registry. A valid adjusted id with
+ /// no visible cell is retained for the lost-cell path.
+ ///
+ private AdjustedSetPosition AdjustSetPosition(
+ uint seedCellId,
+ Vector3 cellLocalPosition,
+ Vector3 firstWorldSphereCenter)
+ {
+ uint low = seedCellId & 0xFFFFu;
+ bool lowInRange = low is (>= 1u and <= 0x40u)
+ or (>= 0x0100u and <= 0xFFFDu)
+ or 0xFFFFu;
+ if (!lowInRange)
+ return new AdjustedSetPosition(
+ seedCellId,
+ cellLocalPosition,
+ Resident: false);
+
+ uint adjustedCell = seedCellId;
+ Vector3 adjustedLocal = cellLocalPosition;
+ if (low >= 0x0100u)
+ {
+ PhysicsDataCache? cache = DataCache;
+ if (cache is null || cache.GetCellStruct(seedCellId) is null)
+ {
+ return new AdjustedSetPosition(
+ seedCellId,
+ cellLocalPosition,
+ Resident: false);
+ }
+
+ uint child = CellTransit.FindVisibleChildCell(
+ cache,
+ seedCellId,
+ firstWorldSphereCenter,
+ useStabList: true);
+ if (child != 0u)
+ {
+ return new AdjustedSetPosition(
+ child,
+ adjustedLocal,
+ Resident: cache.GetCellStruct(child) is not null);
+ }
+
+ CellPhysics? claimed = cache.GetCellStruct(seedCellId);
+ if (claimed is null || !claimed.SeenOutside)
+ {
+ return new AdjustedSetPosition(
+ seedCellId,
+ adjustedLocal,
+ Resident: false);
+ }
+ }
+
+ // Outdoor adjustment is pure cell-relative LandDefs math. Residency
+ // is observed only after the id/frame have been mutated.
+ bool adjusted = LandDefs.AdjustToOutside(
+ ref adjustedCell,
+ ref adjustedLocal);
+ bool resident = adjusted
+ && IsLandblockTerrainResident(adjustedCell);
+ return new AdjustedSetPosition(
+ adjustedCell,
+ adjustedLocal,
+ resident);
+ }
+
+ /// Canonical retail placement transaction:
+ /// CPhysicsObj::SetPosition (0x005160C0) ->
+ /// SetPositionInternal (0x00515BD0) ->
+ /// AdjustPosition (0x00511D80) ->
+ /// CheckPositionInternal (0x00511E90) ->
+ /// CTransition::find_valid_position (0x0050C310).
+ /// A destination whose cell is not resident returns DeferredCell; callers
+ /// must retain the authoritative frame rather than demoting it outdoors.
+ ///
+ internal PhysicsSetPositionResult SetPosition(
+ in PhysicsSetPositionRequest request,
+ Func? handleCollisions = null)
+ {
+ if (_transitionScratch?.ActiveDepth >= TransitionScratchArena.Capacity)
+ {
+ return ErrorResult(
+ request,
+ PhysicsSetPositionError.GeneralFailure);
+ }
+
+ Transition transition = RentTransition();
+ try
+ {
+ InitializeSetPositionTransition(transition, request);
+ bool randomOnly = request.Flags.HasFlag(
+ PhysicsSetPositionFlags.RandomScatter);
+ if (randomOnly)
+ {
+ return SetScatterPositionInternal(
+ transition,
+ request,
+ handleCollisions);
+ }
+
+ PhysicsSetPositionResult result =
+ SetPositionInternal(transition, request, handleCollisions);
+ if (result.Error != PhysicsSetPositionError.Ok
+ && request.Flags.HasFlag(PhysicsSetPositionFlags.Scatter))
+ {
+ return SetScatterPositionInternal(
+ transition,
+ request,
+ handleCollisions);
+ }
+ return result;
+ }
+ finally
+ {
+ ReturnTransition(transition);
+ }
+ }
+
+ private void InitializeSetPositionTransition(
+ Transition transition,
+ in PhysicsSetPositionRequest request)
+ {
+ transition.ObjectInfo.StepUpHeight = request.StepUpHeight;
+ transition.ObjectInfo.StepDownHeight = request.StepDownHeight;
+ transition.ObjectInfo.StepDown =
+ !request.MoverPhysicsState.HasFlag(PhysicsStateFlags.Missile);
+ transition.ObjectInfo.MoverPhysicsState = request.MoverPhysicsState;
+ transition.ObjectInfo.SelfEntityId = request.MovingEntityId;
+ transition.ObjectInfo.State = request.MoverFlags;
+ transition.ObjectInfo.Ethereal = request.MoverPhysicsState.HasFlag(
+ PhysicsStateFlags.Ethereal);
+ transition.SpherePath.PlacementAllowsSliding =
+ request.Flags.HasFlag(PhysicsSetPositionFlags.Slide);
+ }
+
+ private PhysicsSetPositionResult SetScatterPositionInternal(
+ Transition transition,
+ in PhysicsSetPositionRequest request,
+ Func? handleCollisions)
+ {
+ PhysicsSetPositionResult result = ErrorResult(
+ request,
+ PhysicsSetPositionError.GeneralFailure);
+ for (uint attempt = 0u; attempt < request.ScatterAttempts; attempt++)
+ {
+ float dx = ((float)((SetPositionRandomUnit() * 2d) - 1d))
+ * request.ScatterRadiusX;
+ float dy = ((float)((SetPositionRandomUnit() * 2d) - 1d))
+ * request.ScatterRadiusY;
+ var scattered = request with
+ {
+ Position = request.Position + new Vector3(dx, dy, 0f),
+ CellLocalPosition = request.CellLocalPosition
+ + new Vector3(dx, dy, 0f),
+ };
+ result = SetPositionInternal(
+ transition,
+ scattered,
+ handleCollisions);
+ if (result.Error == PhysicsSetPositionError.Ok)
+ break;
+ }
+ return result;
+ }
+
+ private PhysicsSetPositionResult SetPositionInternal(
+ Transition transition,
+ in PhysicsSetPositionRequest request,
+ Func? handleCollisions)
+ {
+ transition.SpherePath.CellCandidates.Clear();
+ transition.SpherePath.ClearWalkable();
+ ImmutableArray spheres = request.Spheres;
+ float sphereScale = spheres.IsDefaultOrEmpty ? 1f : request.Scale;
+ Vector3 firstLocalCenter = spheres.IsDefaultOrEmpty
+ ? new Vector3(0f, 0f, PhysicsGlobals.DummySphereRadius)
+ : spheres[0].Origin * sphereScale;
+ Vector3 firstWorldCenter =
+ Vector3.Transform(firstLocalCenter, request.Orientation)
+ + request.Position;
+ AdjustedSetPosition adjusted = AdjustSetPosition(
+ request.CellId,
+ request.CellLocalPosition,
+ firstWorldCenter);
+ if (!adjusted.Resident)
+ {
+ return new PhysicsSetPositionResult(
+ PhysicsSetPositionError.Ok,
+ PhysicsResidenceDisposition.DeferredCell,
+ request.Position,
+ request.Orientation,
+ adjusted.CellId,
+ adjusted.CellLocalPosition,
+ CrossCellIds: ImmutableArray.Empty,
+ CollidedObjectIds: ImmutableArray.Empty);
+ }
+
+ bool forceIntoCell = request.PlacementClass is
+ PhysicsPlacementClass.Hook
+ or PhysicsPlacementClass.Storage
+ or PhysicsPlacementClass.Corpse;
+ if (forceIntoCell)
+ {
+ if (adjusted.CellId == 0u)
+ {
+ return ErrorResult(
+ request,
+ PhysicsSetPositionError.NoCell);
+ }
+ bool changedCell = request.CurrentCellId is null
+ || request.CurrentCellId.Value != adjusted.CellId;
+ return new PhysicsSetPositionResult(
+ PhysicsSetPositionError.Ok,
+ PhysicsResidenceDisposition.Committed,
+ request.Position,
+ request.Orientation,
+ adjusted.CellId,
+ adjusted.CellLocalPosition,
+ CellChanged: changedCell,
+ ShadowAction: changedCell
+ ? PhysicsShadowCommitAction.Recalculate
+ : PhysicsShadowCommitAction.None,
+ CrossCellIds: ImmutableArray.Empty,
+ CollidedObjectIds: ImmutableArray.Empty);
+ }
+
+ transition.SpherePath.InitPath(
+ request.Position,
+ request.Position,
+ adjusted.CellId,
+ spheres,
+ sphereScale,
+ request.Orientation,
+ request.Orientation);
+ transition.SpherePath.InsertType = InsertType.Placement;
+ transition.SpherePath.PlacementAllowsSliding =
+ request.Flags.HasFlag(PhysicsSetPositionFlags.Slide);
+
+ bool valid = transition.FindValidPosition(this);
+ SpherePath spherePath = transition.SpherePath;
+ if (valid
+ && !request.Flags.HasFlag(PhysicsSetPositionFlags.Slide))
+ {
+ valid = AcceptNoSlidePlacement(
+ spherePath.CurPos,
+ request.Position,
+ spherePath.CurCellId,
+ adjusted.CellId);
+ }
+
+ CollisionInfo collision = transition.CollisionInfo;
+ var collisionReport = new PhysicsSetPositionCollisionReport(
+ collision.ContactPlaneValid,
+ collision.ContactPlane,
+ collision.ContactPlaneCellId,
+ collision.ContactPlaneIsWater,
+ collision.LastKnownContactPlaneValid,
+ collision.LastKnownContactPlane,
+ collision.LastKnownContactPlaneCellId,
+ collision.LastKnownContactPlaneIsWater,
+ collision.SlidingNormalValid,
+ collision.SlidingNormal,
+ collision.CollisionNormalValid,
+ collision.CollisionNormal,
+ collision.CollidedWithEnvironment,
+ collision.FramesStationaryFall,
+ collision.AdjustOffset,
+ collision.LastCollidedObjectGuid,
+ collision.CollideObjectGuids.ToImmutableArray());
+ bool collisionHandlerResult = !valid
+ && handleCollisions?.Invoke(collisionReport) == true;
+ if (!valid)
+ {
+ return new PhysicsSetPositionResult(
+ collisionHandlerResult
+ ? PhysicsSetPositionError.Collided
+ : PhysicsSetPositionError.NoValidPosition,
+ PhysicsResidenceDisposition.Unchanged,
+ request.Position,
+ request.Orientation,
+ request.CellId,
+ request.CellLocalPosition,
+ InContact: collision.ContactPlaneValid,
+ OnWalkable: PhysicsObjUpdate.IsWalkableContact(
+ collision.ContactPlaneValid,
+ collision.ContactPlane.Normal),
+ ContactPlane: collision.ContactPlane,
+ ContactPlaneCellId: collision.ContactPlaneCellId,
+ ContactPlaneIsWater: collision.ContactPlaneIsWater,
+ SlidingNormalValid: collision.SlidingNormalValid,
+ SlidingNormal: collision.SlidingNormal,
+ CollisionNormalValid: collision.CollisionNormalValid,
+ CollisionNormal: collision.CollisionNormal,
+ FramesStationaryFall: collision.FramesStationaryFall,
+ CollisionHandlerResult: collisionHandlerResult,
+ CollidedWithEnvironment: collision.CollidedWithEnvironment,
+ CrossCellIds: ImmutableArray.Empty,
+ CollidedObjectIds: collisionReport.CollidedObjectIds);
+ }
+ if (spherePath.CurCellId == 0u)
+ {
+ return ErrorResult(request, PhysicsSetPositionError.NoCell);
+ }
+
+ bool inContact = collision.ContactPlaneValid;
+ bool onWalkable = PhysicsObjUpdate.IsWalkableContact(
+ inContact,
+ collision.ContactPlane.Normal);
+ Vector3 resultLocal = adjusted.CellLocalPosition
+ + (spherePath.CurPos - request.Position)
+ - LandDefs.GetBlockOffset(adjusted.CellId, spherePath.CurCellId);
+ bool hasPhysicsBsp = request.MoverPhysicsState.HasFlag(
+ PhysicsStateFlags.HasPhysicsBsp);
+ ImmutableArray transitionCells =
+ spherePath.CellCandidates.OrderedIds.ToImmutableArray();
+ PhysicsShadowCommitAction shadowAction = hasPhysicsBsp
+ ? PhysicsShadowCommitAction.Recalculate
+ : transitionCells.Length != 0
+ ? PhysicsShadowCommitAction.Replace
+ : PhysicsShadowCommitAction.Preserve;
+ return new PhysicsSetPositionResult(
+ PhysicsSetPositionError.Ok,
+ PhysicsResidenceDisposition.Committed,
+ spherePath.CurPos,
+ // CheckPositionInternal mutates only origin in no-slide mode;
+ // SetPosition retains the requested frame orientation.
+ request.Orientation,
+ spherePath.CurCellId,
+ resultLocal,
+ inContact,
+ onWalkable,
+ collision.ContactPlane,
+ collision.ContactPlaneCellId,
+ collision.ContactPlaneIsWater,
+ collision.SlidingNormalValid,
+ collision.SlidingNormal,
+ collision.CollisionNormalValid,
+ collision.CollisionNormal,
+ collision.FramesStationaryFall,
+ collision.CollidedWithEnvironment,
+ collisionHandlerResult,
+ CellChanged: request.CurrentCellId is null
+ || request.CurrentCellId.Value != spherePath.CurCellId,
+ ShadowAction: shadowAction,
+ CrossCellIds: shadowAction == PhysicsShadowCommitAction.Replace
+ ? transitionCells
+ : ImmutableArray.Empty,
+ CollidedObjectIds:
+ collision.CollideObjectGuids.ToImmutableArray());
+ }
+
+ private static PhysicsSetPositionResult ErrorResult(
+ in PhysicsSetPositionRequest request,
+ PhysicsSetPositionError error) => new(
+ error,
+ PhysicsResidenceDisposition.Unchanged,
+ request.Position,
+ request.Orientation,
+ request.CellId,
+ request.CellLocalPosition,
+ CrossCellIds: ImmutableArray.Empty,
+ CollidedObjectIds: ImmutableArray.Empty);
+
+ internal static bool AcceptNoSlidePlacement(
+ Vector3 resolvedPosition,
+ Vector3 requestedPosition,
+ uint resolvedCellId,
+ uint adjustedCellId)
+ {
+ Vector3 displacement = resolvedPosition - requestedPosition;
+ return displacement.X <= 0.0500000007f
+ && displacement.Y <= 0.0500000007f
+ && resolvedCellId == adjustedCellId;
+ }
+
///
/// #111: the walkable floor Z of 's PHYSICS
/// polygons under the world XY, nearest to .
diff --git a/src/AcDream.Core/Physics/PhysicsSetPosition.cs b/src/AcDream.Core/Physics/PhysicsSetPosition.cs
new file mode 100644
index 00000000..1ac3de2c
--- /dev/null
+++ b/src/AcDream.Core/Physics/PhysicsSetPosition.cs
@@ -0,0 +1,155 @@
+using System.Collections.Immutable;
+using System.Numerics;
+
+namespace AcDream.Core.Physics;
+
+///
+/// Retail SetPositionError (acclient.h, enum 491). This is
+/// deliberately independent of :
+/// losing the destination cell is a successful SetPosition operation whose
+/// residence is deferred, not a placement error.
+///
+internal enum PhysicsSetPositionError
+{
+ Ok = 0,
+ GeneralFailure = 1,
+ NoValidPosition = 2,
+ NoCell = 3,
+ Collided = 4,
+ InvalidArguments = 0x100,
+}
+
+internal enum PhysicsResidenceDisposition
+{
+ Committed,
+ DeferredCell,
+ Unchanged,
+}
+
+///
+/// Shadow-list operation performed by retail's SetPosition commit tail.
+/// Recalculate delegates to the canonical live shadow-shape owner (the
+/// PhysicsBSP/bounding-box path cannot be reconstructed from placement
+/// spheres); Replace consumes ;
+/// Preserve intentionally leaves the existing list untouched.
+///
+internal enum PhysicsShadowCommitAction
+{
+ None,
+ Recalculate,
+ Replace,
+ Preserve,
+}
+
+internal readonly record struct PhysicsSetPositionCollisionReport(
+ bool ContactPlaneValid,
+ Plane ContactPlane,
+ uint ContactPlaneCellId,
+ bool ContactPlaneIsWater,
+ bool LastKnownContactPlaneValid,
+ Plane LastKnownContactPlane,
+ uint LastKnownContactPlaneCellId,
+ bool LastKnownContactPlaneIsWater,
+ bool SlidingNormalValid,
+ Vector3 SlidingNormal,
+ bool CollisionNormalValid,
+ Vector3 CollisionNormal,
+ bool CollidedWithEnvironment,
+ int FramesStationaryFall,
+ Vector3 AdjustOffset,
+ uint? LastCollidedObjectId,
+ ImmutableArray CollidedObjectIds);
+
+[Flags]
+internal enum PhysicsSetPositionFlags : uint
+{
+ None = 0,
+ Placement = 0x001,
+ Teleport = 0x002,
+ Restore = 0x004,
+ Slide = 0x010,
+ DoNotCreateCells = 0x020,
+ Scatter = 0x100,
+ RandomScatter = 0x200,
+ Line = 0x400,
+ SendPositionEvent = 0x1000,
+}
+
+///
+/// The three retail weenie classifications which bypass placement collision
+/// in CPhysicsObj::SetPositionInternal after AdjustPosition succeeds.
+/// Runtime derives this from the canonical object record; callers cannot use a
+/// free boolean to force ordinary objects through geometry.
+///
+internal enum PhysicsPlacementClass
+{
+ Ordinary,
+ Hook,
+ Storage,
+ Corpse,
+}
+
+///
+/// Complete immutable input to retail CPhysicsObj::SetPosition. World
+/// position feeds acdream's flat collision representation; cell-local position
+/// is retail's Position.frame.origin and is the only input to
+/// LandDefs::adjust_to_outside.
+///
+internal readonly record struct PhysicsSetPositionRequest(
+ Vector3 Position,
+ Quaternion Orientation,
+ uint CellId,
+ Vector3 CellLocalPosition,
+ ImmutableArray Spheres,
+ float Scale,
+ float StepUpHeight,
+ float StepDownHeight,
+ PhysicsStateFlags MoverPhysicsState = PhysicsStateFlags.None,
+ ObjectInfoState MoverFlags = ObjectInfoState.None,
+ uint MovingEntityId = 0u,
+ PhysicsPlacementClass PlacementClass = PhysicsPlacementClass.Ordinary,
+ PhysicsSetPositionFlags Flags = PhysicsSetPositionFlags.Placement,
+ Vector3 Line = default,
+ float ScatterRadiusX = 0f,
+ float ScatterRadiusY = 0f,
+ uint ScatterAttempts = 0u,
+ // Null models retail's distinct `this->cell == nullptr` state. The
+ // retained Position cell id may equal the destination while the object is
+ // still in the lost-cell list; that wake must change_cell and reflood.
+ uint? CurrentCellId = null);
+
+///
+/// Immutable commit packet produced by the pure placement transaction. Runtime
+/// installs this packet atomically into the canonical body/spatial owner before
+/// publishing one presentation delta.
+///
+internal readonly record struct PhysicsSetPositionResult(
+ PhysicsSetPositionError Error,
+ PhysicsResidenceDisposition Residence,
+ Vector3 Position,
+ Quaternion Orientation,
+ uint CellId,
+ Vector3 CellLocalPosition,
+ bool InContact = false,
+ bool OnWalkable = false,
+ Plane ContactPlane = default,
+ uint ContactPlaneCellId = 0u,
+ bool ContactPlaneIsWater = false,
+ bool SlidingNormalValid = false,
+ Vector3 SlidingNormal = default,
+ bool CollisionNormalValid = false,
+ Vector3 CollisionNormal = default,
+ int FramesStationaryFall = 0,
+ bool CollidedWithEnvironment = false,
+ bool CollisionHandlerResult = false,
+ bool CellChanged = false,
+ PhysicsShadowCommitAction ShadowAction = PhysicsShadowCommitAction.None,
+ ImmutableArray CrossCellIds = default,
+ ImmutableArray CollidedObjectIds = default)
+{
+ internal bool IsSuccessful => Error == PhysicsSetPositionError.Ok;
+ internal bool IsCommitted =>
+ IsSuccessful && Residence == PhysicsResidenceDisposition.Committed;
+ internal bool IsDeferred =>
+ IsSuccessful && Residence == PhysicsResidenceDisposition.DeferredCell;
+}
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 1c459731..4fcac576 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -1575,6 +1575,79 @@ public sealed class Transition
return transitionState == TransitionState.OK;
}
+ ///
+ /// Retail CTransition::find_valid_position (0x0050C310). Placement
+ /// insertions use the complete outer find_placement_position
+ /// transaction; ordinary movement uses the transitional sweep.
+ ///
+ internal bool FindValidPosition(PhysicsEngine engine) =>
+ SpherePath.InsertType == InsertType.Transition
+ ? FindTransitionalPosition(engine)
+ : FindPlacementPosition(engine);
+
+ ///
+ /// Retail CTransition::find_placement_position (0x0050C170):
+ /// INITIAL_PLACEMENT insertion, other-cell validation, the inner compass
+ /// search, optional placement step-down, and final validation.
+ ///
+ internal bool FindPlacementPosition(PhysicsEngine engine)
+ {
+ SpherePath sp = SpherePath;
+ sp.SetCheckPos(sp.CurPos, sp.CurCellId);
+ sp.InsertType = InsertType.InitialPlacement;
+
+ TransitionState initial = ValidatePlacement(
+ engine,
+ InitialPlacementInsert(engine),
+ retryPlacement: true);
+ if (initial != TransitionState.OK)
+ return false;
+
+ sp.InsertType = InsertType.Placement;
+ if (!FindPlacementPos(engine))
+ return false;
+
+ if (ObjectInfo.StepDown)
+ {
+ const float placementWalkableAllowance = 0.0871556997f;
+ float stepDownHeight = ObjectInfo.StepDownHeight;
+ sp.WalkableAllowance = placementWalkableAllowance;
+ sp.SaveCheckPos();
+ InsertType savedInsert = sp.InsertType;
+ sp.InsertType = InsertType.Transition;
+
+ (float probeHeight, int probeCount) = GetStepDownProbePlan(
+ sp.NumSphere,
+ sp.GlobalSphere[0].Radius,
+ stepDownHeight);
+ bool stepped = DoStepDown(
+ probeHeight,
+ placementWalkableAllowance,
+ engine);
+ if (!stepped && probeCount > 1)
+ {
+ stepped = DoStepDown(
+ probeHeight,
+ placementWalkableAllowance,
+ engine);
+ }
+ if (!stepped)
+ {
+ sp.RestoreCheckPos();
+ CollisionInfo.ContactPlaneValid = false;
+ CollisionInfo.ContactPlaneIsWater = false;
+ }
+ sp.InsertType = savedInsert;
+ sp.ClearWalkable();
+ }
+
+ return ValidatePlacement(
+ engine,
+ TransitionState.OK,
+ retryPlacement: true)
+ == TransitionState.OK;
+ }
+
///
/// Retail CTransition::find_placement_pos (0x0050BA50).
/// Tests the requested position first, then searches concentric rings up
@@ -1613,15 +1686,19 @@ public sealed class Transition
sphereRadius = 0.48f;
}
- float stepCountExact = 4f / sphereRadius;
+ double stepCountExact = 4d / (double)sphereRadius;
if (fakeSphere)
- stepCountExact *= 0.5f;
- if (stepCountExact <= 1f)
+ stepCountExact *= 0.5d;
+ if (stepCountExact <= 1d)
return false;
- int stepCount = (int)MathF.Ceiling(stepCountExact);
- float distancePerStep = adjustRadius / stepCount;
- float radiansPerStep = MathF.PI * distancePerStep / sphereRadius;
+ int stepCount = (int)Math.Ceiling(stepCountExact);
+ float distancePerStep = (float)((double)adjustRadius / stepCount);
+ // Retail stores the literal 3.14159989f here; do not substitute the
+ // BCL's more precise PI because it changes late compass samples.
+ float radiansPerStep = (float)(
+ ((double)distancePerStep / sphereRadius)
+ * 3.14159989f);
float totalDistance = 0f;
float totalRadians = 0f;
@@ -1630,23 +1707,25 @@ public sealed class Transition
totalDistance += distancePerStep;
totalRadians += radiansPerStep;
- int sampleCount = (int)MathF.Ceiling(totalRadians) * 2;
- float headingStep = 360f / sampleCount;
+ int sampleCount = (int)Math.Ceiling((double)totalRadians) * 2;
+ float headingStep = (float)(360d / sampleCount);
for (int sample = 0; sample < sampleCount; sample++)
{
sp.SetCheckPos(sp.CurPos, sp.CurCellId);
// Frame::set_heading/get_vector_heading: 0 degrees is +Y,
- // 90 degrees is +X in AC's compass convention.
- float headingRadians = headingStep * sample * (MathF.PI / 180f);
- var offset = new Vector3(
- MathF.Sin(headingRadians) * totalDistance,
- MathF.Cos(headingRadians) * totalDistance,
- 0f);
+ // 90 degrees is +X in AC's compass convention. Retail promotes
+ // the float heading to x87 precision, multiplies by this exact
+ // degree-to-radian constant, then rounds sin/cos back to float.
+ float heading = headingStep * sample;
+ Vector3 offset = GetPlacementCompassOffset(
+ heading,
+ totalDistance);
sp.GlobalOffset = AdjustOffset(offset);
- if (sp.GlobalOffset.Length() < PhysicsGlobals.EPSILON)
+ if (sp.GlobalOffset.LengthSquared()
+ < PhysicsGlobals.EpsilonSq)
continue;
sp.AddOffsetToCheckPos(sp.GlobalOffset);
@@ -1664,7 +1743,20 @@ public sealed class Transition
return false;
}
- private TransitionState ValidatePlacementTransition(TransitionState transitionState)
+ internal static Vector3 GetPlacementCompassOffset(
+ float headingDegrees,
+ float distance)
+ {
+ const double DegreesToRadians = 0.017453292519943295d;
+ double radians = (double)headingDegrees * DegreesToRadians;
+ return new Vector3(
+ (float)Math.Sin(radians) * distance,
+ (float)Math.Cos(radians) * distance,
+ 0f);
+ }
+
+ private TransitionState ValidatePlacementTransition(
+ TransitionState transitionState)
{
var sp = SpherePath;
if (sp.CheckCellId == 0)
@@ -1682,7 +1774,9 @@ public sealed class Transition
sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
}
}
- else if (sp.PlacementAllowsSliding)
+ else if (transitionState > TransitionState.OK
+ && transitionState <= TransitionState.Slid
+ && sp.PlacementAllowsSliding)
{
// COLLISIONINFO::init at retail 0x0050B052. Placement probes are
// independent; a failed compass sample must not bias the next one.
@@ -1692,6 +1786,58 @@ public sealed class Transition
return transitionState;
}
+ internal TransitionState ValidatePlacementTransitionForTest(
+ TransitionState transitionState) =>
+ ValidatePlacementTransition(transitionState);
+
+ ///
+ /// Retail CTransition::validate_placement (0x0050B210), used only
+ /// by the outer placement transaction. Adjusted/Slid receives one
+ /// placement_insert retry when requested; Collided never retries and this
+ /// validator never clears CollisionInfo.
+ ///
+ private TransitionState ValidatePlacement(
+ PhysicsEngine engine,
+ TransitionState transitionState,
+ bool retryPlacement)
+ {
+ SpherePath sp = SpherePath;
+ if (sp.CheckCellId == 0u)
+ return TransitionState.Collided;
+
+ if (transitionState == TransitionState.OK)
+ {
+ sp.CurPos = sp.CheckPos;
+ sp.CurCellId = sp.CheckCellId;
+ sp.CurOrientation = sp.CheckOrientation;
+ for (int i = 0; i < sp.NumSphere; i++)
+ {
+ sp.GlobalCurrCenter[i].Origin =
+ Vector3.Transform(
+ sp.LocalSphere[i].Origin,
+ sp.CurOrientation)
+ + sp.CurPos;
+ sp.GlobalCurrCenter[i].Radius = sp.LocalSphere[i].Radius;
+ }
+ }
+ else if ((transitionState is TransitionState.Adjusted
+ or TransitionState.Slid)
+ && retryPlacement)
+ {
+ return ValidatePlacement(
+ engine,
+ PlacementInsert(engine),
+ retryPlacement: false);
+ }
+ return transitionState;
+ }
+
+ internal TransitionState ValidatePlacementForTest(
+ PhysicsEngine engine,
+ TransitionState transitionState,
+ bool retryPlacement) =>
+ ValidatePlacement(engine, transitionState, retryPlacement);
+
// -----------------------------------------------------------------------
// Per-step collision check
// -----------------------------------------------------------------------
@@ -1749,10 +1895,10 @@ public sealed class Transition
float diameter = sphereRadius * 2f;
float probeHeight = requestedHeight;
- if (numSpheres < 2 && diameter < probeHeight)
+ if (numSpheres < 2 && diameter <= probeHeight)
probeHeight = sphereRadius * 0.5f;
- if (diameter >= probeHeight)
+ if (diameter > probeHeight)
return (probeHeight, 1);
return (probeHeight * 0.5f, 2);
@@ -2176,6 +2322,54 @@ public sealed class Transition
return state;
}
+ ///
+ /// The initial half of retail find_placement_position. This is
+ /// deliberately not : initial placement
+ /// performs one primary insert and, only on OK, the other-cell pass. It
+ /// never enters transitional collide/step/edge response branches.
+ ///
+ private TransitionState InitialPlacementInsert(PhysicsEngine engine)
+ {
+ SpherePath sp = SpherePath;
+ if (sp.CheckCellId == 0u)
+ return TransitionState.Collided;
+
+ TransitionState result = InsertIntoCell(
+ engine,
+ sp.CheckCellId,
+ numAttempts: 3);
+ if (result == TransitionState.OK)
+ {
+ result = RunCheckOtherCellsAndAdvance(
+ engine,
+ sp.GlobalSphere[0].Origin,
+ sp.GlobalSphere[0].Radius);
+ }
+ return result;
+ }
+
+ ///
+ /// Retail CTransition::placement_insert (0x0050B1D0), used by
+ /// validate_placement for exactly one Adjusted/Slid retry.
+ ///
+ private TransitionState PlacementInsert(PhysicsEngine engine)
+ {
+ SpherePath sp = SpherePath;
+ if (sp.CheckCellId == 0u)
+ return TransitionState.Collided;
+
+ TransitionState result = InsertIntoCell(
+ engine,
+ sp.CheckCellId,
+ numAttempts: 3);
+ return result == TransitionState.OK
+ ? RunCheckOtherCellsAndAdvance(
+ engine,
+ sp.GlobalSphere[0].Origin,
+ sp.GlobalSphere[0].Radius)
+ : result;
+ }
+
///
/// Primary-cell virtual find_collisions composition. A non-OK
/// response terminates this pass, so an inner retry always restarts from
diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs
new file mode 100644
index 00000000..6b88f5ef
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs
@@ -0,0 +1,1041 @@
+using System.Collections.Immutable;
+using System.Numerics;
+using AcDream.Core.Physics;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Named-retail oracle fixtures for CPhysicsObj::SetPosition 0x005160C0,
+/// SetPositionInternal 0x00515BD0, AdjustPosition 0x00511D80, and
+/// CTransition::find_placement_position 0x0050C170.
+///
+public sealed class PhysicsSetPositionTests
+{
+ private const uint Landblock = 0xA9B40000u;
+ private const uint Cell = Landblock | 0x0001u;
+
+ [Fact]
+ public void SetPositionErrorValues_MatchRetailHeader()
+ {
+ Assert.Equal(0, (int)PhysicsSetPositionError.Ok);
+ Assert.Equal(1, (int)PhysicsSetPositionError.GeneralFailure);
+ Assert.Equal(2, (int)PhysicsSetPositionError.NoValidPosition);
+ Assert.Equal(3, (int)PhysicsSetPositionError.NoCell);
+ Assert.Equal(4, (int)PhysicsSetPositionError.Collided);
+ Assert.Equal(0x100, (int)PhysicsSetPositionError.InvalidArguments);
+ }
+
+ [Fact]
+ public void MissingOutdoorCell_AdjustsFrameThenDefersWithOk()
+ {
+ var engine = new PhysicsEngine();
+ var local = new Vector3(193f, 12f, 7f);
+ uint expectedCell = Cell;
+ Vector3 expectedLocal = local;
+ Assert.True(LandDefs.AdjustToOutside(
+ ref expectedCell,
+ ref expectedLocal));
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(Cell, local, position: new Vector3(193f, 12f, 7f)));
+
+ Assert.Equal(PhysicsSetPositionError.Ok, result.Error);
+ Assert.Equal(
+ PhysicsResidenceDisposition.DeferredCell,
+ result.Residence);
+ Assert.Equal(expectedCell, result.CellId);
+ Assert.Equal(expectedLocal, result.CellLocalPosition);
+ Assert.Equal(new Vector3(193f, 12f, 7f), result.Position);
+ }
+
+ [Fact]
+ public void ResidentCrossLandblockPlacement_RebasesLocalFrameAndPreservesWorldPosition()
+ {
+ PhysicsEngine engine = FlatEngine();
+ const uint destinationLandblock = 0xAAB40000u;
+ AddFlatLandblock(engine, destinationLandblock, worldOffsetX: 192f);
+ var world = new Vector3(193f, 12f, 7f);
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(Cell, world, world) with
+ {
+ MoverPhysicsState = PhysicsStateFlags.Missile,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.Equal(world, result.Position);
+ Assert.Equal(destinationLandblock | 0x0001u, result.CellId);
+ Assert.Equal(new Vector3(1f, 12f, 7f), result.CellLocalPosition);
+ }
+
+ [Fact]
+ public void OutdoorMapEdgeFailure_ZeroesCellAndDefersExactFrame()
+ {
+ var engine = new PhysicsEngine();
+ const uint southWestCell = 0x00000001u;
+ var local = new Vector3(-1f, 12f, 7f);
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(southWestCell, local, local));
+
+ Assert.True(result.IsDeferred);
+ Assert.Equal(0u, result.CellId);
+ Assert.Equal(local, result.CellLocalPosition);
+ Assert.Equal(local, result.Position);
+ }
+
+ [Fact]
+ public void OutdoorBlockSentinel_IsAnIndoorShapedLostCellSentinel()
+ {
+ var engine = new PhysicsEngine();
+ const uint sentinel = Landblock | 0xFFFFu;
+ var local = new Vector3(25f, 49f, 7f);
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(sentinel, local, local));
+
+ Assert.True(result.IsDeferred);
+ Assert.Equal(sentinel, result.CellId);
+ Assert.Equal(local, result.CellLocalPosition);
+ }
+
+ [Fact]
+ public void InvalidLowCellId_ParksWithoutRewritingAuthoritativeFrame()
+ {
+ var engine = new PhysicsEngine();
+ const uint invalid = Landblock | 0x0050u;
+ var local = new Vector3(11f, 13f, 17f);
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(invalid, local, local));
+
+ Assert.True(result.IsDeferred);
+ Assert.Equal(PhysicsSetPositionError.Ok, result.Error);
+ Assert.Equal(invalid, result.CellId);
+ Assert.Equal(local, result.CellLocalPosition);
+ }
+
+ [Fact]
+ public void MissingIndoorCell_ParksExactCellAndFrame()
+ {
+ var engine = new PhysicsEngine();
+ const uint indoor = Landblock | 0x0107u;
+ var local = new Vector3(31f, -4f, 9f);
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(indoor, local, new Vector3(31f, -4f, 9f)));
+
+ Assert.True(result.IsDeferred);
+ Assert.Equal(indoor, result.CellId);
+ Assert.Equal(local, result.CellLocalPosition);
+ }
+
+ [Fact]
+ public void EmptySphereList_UsesRetailDummyAtScaleOne()
+ {
+ PhysicsEngine engine = FlatEngine();
+ Vector3 capturedOrigin = default;
+ float capturedRadius = 0f;
+ engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, observed) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment)
+ {
+ capturedOrigin = transition.SpherePath.LocalSphere[0].Origin;
+ capturedRadius = transition.SpherePath.LocalSphere[0].Radius;
+ }
+ return observed;
+ };
+
+ _ = engine.SetPosition(Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f),
+ scale: -7f));
+
+ Assert.Equal(new Vector3(0f, 0f, 0.1f), capturedOrigin);
+ Assert.Equal(0.1f, capturedRadius);
+ }
+
+ [Fact]
+ public void AuthoredSphereList_CapsAtTwoAndPreservesNonPositiveScale()
+ {
+ PhysicsEngine engine = FlatEngine();
+ int count = 0;
+ Vector3 firstOrigin = default;
+ float firstRadius = 0f;
+ Vector3 secondOrigin = default;
+ float secondRadius = 0f;
+ engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, _) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment)
+ {
+ count = transition.SpherePath.NumSphere;
+ firstOrigin = transition.SpherePath.LocalSphere[0].Origin;
+ firstRadius = transition.SpherePath.LocalSphere[0].Radius;
+ secondOrigin = transition.SpherePath.LocalSphere[1].Origin;
+ secondRadius = transition.SpherePath.LocalSphere[1].Radius;
+ }
+ return TransitionState.Collided;
+ };
+ ImmutableArray spheres = ImmutableArray.Create(
+ new FlatCollisionSphere(new Vector3(1f, 2f, 3f), 4f),
+ new FlatCollisionSphere(new Vector3(5f, 6f, 7f), 8f),
+ new FlatCollisionSphere(new Vector3(9f), 10f));
+
+ _ = engine.SetPosition(Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f),
+ spheres,
+ scale: -2f));
+
+ Assert.Equal(2, count);
+ Assert.Equal(new Vector3(-2f, -4f, -6f), firstOrigin);
+ Assert.Equal(-8f, firstRadius);
+ Assert.Equal(new Vector3(-10f, -12f, -14f), secondOrigin);
+ Assert.Equal(-16f, secondRadius);
+ }
+
+ [Theory]
+ [InlineData(false, true)]
+ [InlineData(true, false)]
+ public void StepDown_IsDisabledOnlyForMissiles(
+ bool missile,
+ bool expectedStepDown)
+ {
+ PhysicsEngine engine = FlatEngine();
+ bool? captured = null;
+ engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, observed) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment)
+ captured = transition.ObjectInfo.StepDown;
+ return observed;
+ };
+ PhysicsSetPositionRequest request = Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ MoverPhysicsState = missile
+ ? PhysicsStateFlags.Missile
+ : PhysicsStateFlags.Gravity,
+ };
+
+ _ = engine.SetPosition(request);
+
+ Assert.Equal(expectedStepDown, captured);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ public void ObjectInfoEthereal_IsSeededFromPhysicsState(
+ bool ethereal,
+ bool expected)
+ {
+ PhysicsEngine engine = FlatEngine();
+ bool? captured = null;
+ engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, observed) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment)
+ captured = transition.ObjectInfo.Ethereal;
+ return observed;
+ };
+
+ _ = engine.SetPosition(Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ MoverPhysicsState = ethereal
+ ? PhysicsStateFlags.Ethereal
+ : PhysicsStateFlags.Gravity,
+ });
+
+ Assert.Equal(expected, captured);
+ }
+
+ [Theory]
+ [InlineData(false, false)]
+ [InlineData(true, true)]
+ public void PathClipped_ComesOnlyFromExplicitObjectInfoFlags(
+ bool explicitPathClipped,
+ bool expected)
+ {
+ PhysicsEngine engine = FlatEngine();
+ bool? captured = null;
+ engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, observed) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment)
+ captured = transition.ObjectInfo.PathClipped;
+ return observed;
+ };
+
+ _ = engine.SetPosition(Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ MoverPhysicsState = PhysicsStateFlags.Missile,
+ MoverFlags = explicitPathClipped
+ ? ObjectInfoState.PathClipped
+ : ObjectInfoState.None,
+ });
+
+ Assert.Equal(expected, captured);
+ }
+
+ [Fact]
+ public void DoNotCreateCells_MissingDestinationStillDefersWithoutCreation()
+ {
+ var engine = new PhysicsEngine();
+
+ PhysicsSetPositionResult result = engine.SetPosition(Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ Flags = PhysicsSetPositionFlags.Placement
+ | PhysicsSetPositionFlags.Slide
+ | PhysicsSetPositionFlags.DoNotCreateCells,
+ });
+
+ Assert.True(result.IsDeferred);
+ Assert.Equal(0, engine.LandblockCount);
+ }
+
+ [Fact]
+ public void SuccessfulPlacement_RetainsRequestedOrientation()
+ {
+ PhysicsEngine engine = FlatEngine();
+ PhysicsSetPositionRequest request = Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f));
+
+ PhysicsSetPositionResult result = engine.SetPosition(request);
+
+ Assert.True(result.IsCommitted);
+ Assert.Equal(request.Orientation, result.Orientation);
+ }
+
+ [Fact]
+ public void OuterPlacementStartsWithInitialPlacementInsert()
+ {
+ PhysicsEngine engine = FlatEngine();
+ InsertType? captured = null;
+ engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, observed) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment
+ && captured is null)
+ {
+ captured = transition.SpherePath.InsertType;
+ }
+ return observed;
+ };
+
+ _ = engine.SetPosition(Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)));
+
+ Assert.Equal(InsertType.InitialPlacement, captured);
+ }
+
+ [Theory]
+ [InlineData(1, 0.5f, 1.0f, 0.25f, 1)]
+ [InlineData(2, 0.5f, 1.0f, 0.5f, 2)]
+ [InlineData(1, 0.5f, 0.4f, 0.4f, 1)]
+ public void StepDownProbePlan_PreservesRetailEqualityBoundaries(
+ int sphereCount,
+ float radius,
+ float requested,
+ float expectedHeight,
+ int expectedCount)
+ {
+ Assert.Equal(
+ (expectedHeight, expectedCount),
+ Transition.GetStepDownProbePlan(
+ sphereCount,
+ radius,
+ requested));
+ }
+
+ [Theory]
+ [InlineData(-100f, 0f, 7f, 7u, true)]
+ [InlineData(0.0500000007f, 0.0500000007f, 500f, 7u, true)]
+ [InlineData(0.0501f, 0f, 0f, 7u, false)]
+ [InlineData(0f, 0.0501f, 0f, 7u, false)]
+ [InlineData(0f, 0f, 0f, 8u, false)]
+ public void NoSlideAcceptance_IsSignedXYSameCellAndIgnoresZ(
+ float dx,
+ float dy,
+ float dz,
+ uint resolvedCell,
+ bool expected)
+ {
+ Assert.Equal(
+ expected,
+ PhysicsEngine.AcceptNoSlidePlacement(
+ new Vector3(dx, dy, dz),
+ Vector3.Zero,
+ resolvedCell,
+ adjustedCellId: 7u));
+ }
+
+ [Fact]
+ public void PlacementCompass_LateSampleMatchesRetailFloatBits()
+ {
+ PhysicsEngine engine = FlatEngine();
+ var transition = new Transition();
+ transition.SpherePath.InitPath(
+ Vector3.Zero,
+ Vector3.Zero,
+ Cell,
+ sphereRadius: 0.48f);
+ transition.SpherePath.InsertType = InsertType.Placement;
+ transition.SpherePath.PlacementAllowsSliding = true;
+ Vector3 lastCheck = default;
+ engine.TransitionCellCollisionTestHook =
+ (observed, phase, _, _) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment)
+ lastCheck = observed.SpherePath.CheckPos;
+ return TransitionState.Collided;
+ };
+
+ Assert.False(transition.FindPlacementPos(engine));
+
+ Assert.Equal(unchecked((int)0xBEEDC24F),
+ BitConverter.SingleToInt32Bits(lastCheck.X));
+ Assert.Equal(unchecked((int)0x407E44DE),
+ BitConverter.SingleToInt32Bits(lastCheck.Y));
+ }
+
+ [Theory]
+ [InlineData(TransitionState.Collided)]
+ [InlineData(TransitionState.Adjusted)]
+ [InlineData(TransitionState.Slid)]
+ public void InnerPlacementValidator_ResetsCollisionInfoForEveryFailure(
+ TransitionState state)
+ {
+ Transition transition = PlacementTransition();
+ transition.SpherePath.PlacementAllowsSliding = true;
+ transition.CollisionInfo.SetContactPlane(
+ new Plane(Vector3.UnitZ, 0f),
+ Cell);
+ transition.CollisionInfo.SetSlidingNormal(Vector3.UnitX);
+
+ TransitionState result =
+ transition.ValidatePlacementTransitionForTest(state);
+
+ Assert.Equal(state, result);
+ Assert.False(transition.CollisionInfo.ContactPlaneValid);
+ Assert.False(transition.CollisionInfo.SlidingNormalValid);
+ }
+
+ [Fact]
+ public void InnerPlacementValidator_DoesNotResetWhenSlidingDisabled()
+ {
+ Transition transition = PlacementTransition();
+ transition.SpherePath.PlacementAllowsSliding = false;
+ transition.CollisionInfo.SetContactPlane(
+ new Plane(Vector3.UnitZ, 0f),
+ Cell);
+
+ _ = transition.ValidatePlacementTransitionForTest(
+ TransitionState.Collided);
+
+ Assert.True(transition.CollisionInfo.ContactPlaneValid);
+ }
+
+ [Fact]
+ public void OuterPlacementValidator_CollidedNeverRetriesOrResets()
+ {
+ PhysicsEngine engine = FlatEngine();
+ int passes = 0;
+ engine.TransitionCellCollisionTestHook =
+ (_, _, _, observed) =>
+ {
+ passes++;
+ return observed;
+ };
+ Transition transition = PlacementTransition();
+ transition.CollisionInfo.SetContactPlane(
+ new Plane(Vector3.UnitZ, 0f),
+ Cell);
+
+ TransitionState result = transition.ValidatePlacementForTest(
+ engine,
+ TransitionState.Collided,
+ retryPlacement: true);
+
+ Assert.Equal(TransitionState.Collided, result);
+ Assert.Equal(0, passes);
+ Assert.True(transition.CollisionInfo.ContactPlaneValid);
+ }
+
+ [Theory]
+ [InlineData(TransitionState.Adjusted)]
+ [InlineData(TransitionState.Slid)]
+ public void OuterPlacementValidator_AdjustedAndSlidRetryExactlyOnce(
+ TransitionState state)
+ {
+ PhysicsEngine engine = FlatEngine();
+ int environmentPasses = 0;
+ engine.TransitionCellCollisionTestHook =
+ (_, phase, _, _) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment)
+ environmentPasses++;
+ return TransitionState.OK;
+ };
+ Transition transition = PlacementTransition();
+
+ TransitionState result = transition.ValidatePlacementForTest(
+ engine,
+ state,
+ retryPlacement: true);
+
+ Assert.Equal(TransitionState.OK, result);
+ Assert.Equal(1, environmentPasses);
+ }
+
+ [Theory]
+ [InlineData(TransitionState.Adjusted)]
+ [InlineData(TransitionState.Slid)]
+ public void OuterPlacementValidator_RetryFalseReturnsOriginalState(
+ TransitionState state)
+ {
+ PhysicsEngine engine = FlatEngine();
+ int passes = 0;
+ engine.TransitionCellCollisionTestHook =
+ (_, _, _, observed) =>
+ {
+ passes++;
+ return observed;
+ };
+ Transition transition = PlacementTransition();
+
+ TransitionState result = transition.ValidatePlacementForTest(
+ engine,
+ state,
+ retryPlacement: false);
+
+ Assert.Equal(state, result);
+ Assert.Equal(0, passes);
+ }
+
+ [Theory]
+ [InlineData(false, (int)PhysicsSetPositionError.NoValidPosition)]
+ [InlineData(true, (int)PhysicsSetPositionError.Collided)]
+ public void FailedCheck_MapsCollisionHandlerResultToRetailError(
+ bool handled,
+ int expectedValue)
+ {
+ var expected = (PhysicsSetPositionError)expectedValue;
+ PhysicsEngine engine = FlatEngine();
+ engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, _) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment)
+ {
+ transition.CollisionInfo.CollidedWithEnvironment = true;
+ transition.CollisionInfo.SetContactPlane(
+ new Plane(Vector3.UnitZ, -3f),
+ Cell,
+ isWater: true);
+ transition.CollisionInfo.SetSlidingNormal(Vector3.UnitX);
+ transition.CollisionInfo.SetCollisionNormal(-Vector3.UnitY);
+ transition.CollisionInfo.FramesStationaryFall = 2;
+ transition.CollisionInfo.AdjustOffset = new Vector3(1f, 2f, 3f);
+ transition.CollisionInfo.CollideObjectGuids.Add(0x12345678u);
+ transition.CollisionInfo.LastCollidedObjectGuid = 0x12345678u;
+ }
+ return TransitionState.Collided;
+ };
+ PhysicsSetPositionCollisionReport observedReport = default;
+
+ PhysicsSetPositionResult result = engine.SetPosition(Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)), report =>
+ {
+ observedReport = report;
+ return handled;
+ });
+
+ Assert.Equal(expected, result.Error);
+ Assert.Equal(handled, result.CollisionHandlerResult);
+ Assert.True(observedReport.CollidedWithEnvironment);
+ Assert.True(observedReport.ContactPlaneValid);
+ Assert.Equal(new Plane(Vector3.UnitZ, -3f), observedReport.ContactPlane);
+ Assert.Equal(Cell, observedReport.ContactPlaneCellId);
+ Assert.True(observedReport.ContactPlaneIsWater);
+ Assert.True(observedReport.LastKnownContactPlaneValid);
+ Assert.Equal(
+ observedReport.ContactPlane,
+ observedReport.LastKnownContactPlane);
+ Assert.Equal(Cell, observedReport.LastKnownContactPlaneCellId);
+ Assert.True(observedReport.LastKnownContactPlaneIsWater);
+ Assert.True(observedReport.SlidingNormalValid);
+ Assert.Equal(Vector3.UnitX, observedReport.SlidingNormal);
+ Assert.True(observedReport.CollisionNormalValid);
+ Assert.Equal(-Vector3.UnitY, observedReport.CollisionNormal);
+ Assert.Equal(2, observedReport.FramesStationaryFall);
+ Assert.Equal(new Vector3(1f, 2f, 3f), observedReport.AdjustOffset);
+ Assert.Equal(0x12345678u, observedReport.LastCollidedObjectId);
+ Assert.Equal(
+ new[] { 0x12345678u },
+ observedReport.CollidedObjectIds.ToArray());
+ Assert.True(result.CollidedWithEnvironment);
+ Assert.True(result.InContact);
+ Assert.True(result.OnWalkable);
+ Assert.True(result.ContactPlaneIsWater);
+ Assert.True(result.SlidingNormalValid);
+ Assert.True(result.CollisionNormalValid);
+ Assert.Equal(2, result.FramesStationaryFall);
+ Assert.Equal(
+ new[] { 0x12345678u },
+ result.CollidedObjectIds.ToArray());
+ Assert.Equal(PhysicsResidenceDisposition.Unchanged, result.Residence);
+ }
+
+ [Theory]
+ [InlineData((int)PhysicsPlacementClass.Hook)]
+ [InlineData((int)PhysicsPlacementClass.Storage)]
+ [InlineData((int)PhysicsPlacementClass.Corpse)]
+ public void RetailForceClasses_BypassPlacementCollision(
+ int placementClassValue)
+ {
+ var placementClass = (PhysicsPlacementClass)placementClassValue;
+ PhysicsEngine engine = FlatEngine();
+ int collisionPasses = 0;
+ engine.TransitionCellCollisionTestHook =
+ (_, _, _, observed) =>
+ {
+ collisionPasses++;
+ return observed;
+ };
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ PlacementClass = placementClass,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.Equal(0, collisionPasses);
+ Assert.True(result.CellChanged);
+ Assert.Equal(
+ PhysicsShadowCommitAction.Recalculate,
+ result.ShadowAction);
+ Assert.Empty(result.CrossCellIds);
+ }
+
+ [Fact]
+ public void OrdinaryObject_CannotRequestForceByFlags()
+ {
+ PhysicsEngine engine = FlatEngine();
+ int collisionPasses = 0;
+ engine.TransitionCellCollisionTestHook =
+ (_, _, _, observed) =>
+ {
+ collisionPasses++;
+ return observed;
+ };
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ PlacementClass = PhysicsPlacementClass.Ordinary,
+ Flags = (PhysicsSetPositionFlags)uint.MaxValue
+ & ~PhysicsSetPositionFlags.RandomScatter
+ & ~PhysicsSetPositionFlags.Scatter,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.True(collisionPasses > 0);
+ }
+
+ [Fact]
+ public void ForceIntoSameCell_SetsFrameWithoutRecalculatingCrossCells()
+ {
+ PhysicsEngine engine = FlatEngine();
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ PlacementClass = PhysicsPlacementClass.Corpse,
+ CurrentCellId = Cell,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.False(result.CellChanged);
+ Assert.Equal(PhysicsShadowCommitAction.None, result.ShadowAction);
+ Assert.Empty(result.CrossCellIds);
+ }
+
+ [Fact]
+ public void ForceIntoChangedCell_RequestsCanonicalShadowRecalculation()
+ {
+ PhysicsEngine engine = FlatEngine();
+ var position = new Vector3(23.8f, 12f, 2.5f);
+ ImmutableArray spheres = ImmutableArray.Create(
+ new FlatCollisionSphere(Vector3.Zero, 0.5f));
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(Cell, position, position, spheres) with
+ {
+ PlacementClass = PhysicsPlacementClass.Hook,
+ CurrentCellId = Landblock | 0x0040u,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.True(result.CellChanged);
+ Assert.Equal(
+ PhysicsShadowCommitAction.Recalculate,
+ result.ShadowAction);
+ Assert.Empty(result.CrossCellIds);
+ }
+
+ [Fact]
+ public void ForceIntoRetainedCellWithNullCurrentPointer_RefloodsShadows()
+ {
+ PhysicsEngine engine = FlatEngine();
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ PlacementClass = PhysicsPlacementClass.Corpse,
+ CurrentCellId = null,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.True(result.CellChanged);
+ Assert.Equal(
+ PhysicsShadowCommitAction.Recalculate,
+ result.ShadowAction);
+ }
+
+ [Fact]
+ public void OrdinaryPhysicsBspPlacement_RequestsCanonicalShadowRecalculation()
+ {
+ PhysicsEngine engine = FlatEngine();
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ MoverPhysicsState = PhysicsStateFlags.HasPhysicsBsp
+ | PhysicsStateFlags.Missile,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.Equal(
+ PhysicsShadowCommitAction.Recalculate,
+ result.ShadowAction);
+ Assert.Empty(result.CrossCellIds);
+ }
+
+ [Fact]
+ public void OrdinarySpherePlacement_ReplacesShadowsWithTransitionCells()
+ {
+ PhysicsEngine engine = FlatEngine();
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ MoverPhysicsState = PhysicsStateFlags.Missile,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.Equal(PhysicsShadowCommitAction.Replace, result.ShadowAction);
+ Assert.Contains(Cell, result.CrossCellIds);
+ }
+
+ [Fact]
+ public void OrdinarySpherePlacementWithNoTransitionCells_PreservesShadows()
+ {
+ // The Core-only/no-cell-graph fixture is the real empty-array shape:
+ // terrain is resident, but async cell topology has not been published.
+ PhysicsEngine engine = FlatEngine(withDataCache: false);
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ MoverPhysicsState = PhysicsStateFlags.Missile,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.Equal(PhysicsShadowCommitAction.Preserve, result.ShadowAction);
+ Assert.Empty(result.CrossCellIds);
+ }
+
+ [Fact]
+ public void RandomScatter_IsDirectAndStopsOnDeferredOk()
+ {
+ var engine = new PhysicsEngine();
+ var random = new Queue(new[] { 1d, 0d, 0.25d, 0.75d });
+ int draws = 0;
+ engine.SetPositionRandomUnit = () =>
+ {
+ draws++;
+ return random.Dequeue();
+ };
+ PhysicsSetPositionRequest request = Request(
+ Cell,
+ new Vector3(10f, 10f, 3f),
+ new Vector3(10f, 10f, 3f)) with
+ {
+ Flags = PhysicsSetPositionFlags.RandomScatter,
+ ScatterRadiusX = 4f,
+ ScatterRadiusY = 2f,
+ ScatterAttempts = 2u,
+ };
+
+ PhysicsSetPositionResult result = engine.SetPosition(request);
+
+ Assert.True(result.IsDeferred);
+ Assert.Equal(2, draws);
+ Assert.Equal(new Vector3(14f, 8f, 3f), result.Position);
+ }
+
+ [Fact]
+ public void ScatterFallback_RunsOnlyAfterNormalError_AndUsesExactAttempts()
+ {
+ PhysicsEngine engine = FlatEngine();
+ int collisionPasses = 0;
+ engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, _) =>
+ {
+ if (phase == TransitionCellCollisionPhase.Environment)
+ {
+ collisionPasses++;
+ transition.CollisionInfo.CollidedWithEnvironment = true;
+ }
+ return TransitionState.Collided;
+ };
+ var random = new Queue(new[] { 0d, 0d, 1d, 1d });
+ int draws = 0;
+ engine.SetPositionRandomUnit = () =>
+ {
+ draws++;
+ return random.Dequeue();
+ };
+ PhysicsSetPositionRequest request = Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ Flags = PhysicsSetPositionFlags.Placement
+ | PhysicsSetPositionFlags.Scatter,
+ ScatterRadiusX = 1f,
+ ScatterRadiusY = 1f,
+ ScatterAttempts = 2u,
+ };
+
+ PhysicsSetPositionResult result = engine.SetPosition(request);
+
+ Assert.Equal(PhysicsSetPositionError.NoValidPosition, result.Error);
+ Assert.Equal(3, collisionPasses);
+ Assert.Equal(4, draws);
+ Assert.Equal(new Vector3(11f, 11f, 10f), result.Position);
+ }
+
+ [Fact]
+ public void Scatter_ReusesOneTransitionAndFailedInnerProbeCannotLeakIntoSuccess()
+ {
+ PhysicsEngine engine = FlatEngine();
+ int placementPasses = 0;
+ bool injectedFailure = false;
+ engine.TransitionCellCollisionTestHook =
+ (transition, phase, _, observed) =>
+ {
+ if (phase != TransitionCellCollisionPhase.Environment)
+ return observed;
+
+ if (transition.SpherePath.InsertType == InsertType.Placement)
+ {
+ placementPasses++;
+ // Attempt 1 reaches the inner placement probe and fails.
+ // The exact inner validator clears this collision scratch.
+ if (!injectedFailure)
+ {
+ injectedFailure = true;
+ transition.CollisionInfo.CollidedWithEnvironment = true;
+ transition.CollisionInfo.SetContactPlane(
+ new Plane(Vector3.UnitZ, 0f),
+ Cell);
+ transition.CollisionInfo.SetSlidingNormal(Vector3.UnitX);
+ return TransitionState.Collided;
+ }
+ }
+ return TransitionState.OK;
+ };
+ engine.SetPositionRandomUnit = () => 0.5d;
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f)) with
+ {
+ Flags = PhysicsSetPositionFlags.RandomScatter
+ | PhysicsSetPositionFlags.Slide,
+ ScatterAttempts = 2u,
+ MoverPhysicsState = PhysicsStateFlags.Missile,
+ });
+
+ Assert.True(result.IsCommitted);
+ Assert.True(injectedFailure);
+ Assert.True(placementPasses >= 2);
+ Assert.False(result.InContact);
+ Assert.False(result.SlidingNormalValid);
+ Assert.False(result.CollidedWithEnvironment);
+ Assert.Empty(result.CollidedObjectIds);
+ }
+
+ [Fact]
+ public void EleventhNestedSetPosition_ReturnsGeneralFailureWithoutThrowing()
+ {
+ PhysicsEngine engine = FlatEngine();
+ PhysicsSetPositionRequest request = Request(
+ Cell,
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f));
+ PhysicsSetPositionResult capacityResult = default;
+ int callbackDepth = 0;
+ engine.TransitionCellCollisionTestHook =
+ (_, phase, _, observed) =>
+ {
+ if (phase != TransitionCellCollisionPhase.Environment)
+ return observed;
+
+ callbackDepth++;
+ PhysicsSetPositionResult nested = engine.SetPosition(request);
+ if (nested.Error == PhysicsSetPositionError.GeneralFailure)
+ capacityResult = nested;
+ return TransitionState.Collided;
+ };
+
+ PhysicsSetPositionResult outer = engine.SetPosition(request);
+
+ Assert.Equal(PhysicsSetPositionError.NoValidPosition, outer.Error);
+ Assert.Equal(10, callbackDepth);
+ Assert.Equal(
+ PhysicsSetPositionError.GeneralFailure,
+ capacityResult.Error);
+ Assert.Equal(
+ PhysicsResidenceDisposition.Unchanged,
+ capacityResult.Residence);
+ }
+
+ [Fact]
+ public void ZeroScatterAttempts_ReturnsGeneralFailureWithoutRandomDraw()
+ {
+ var engine = new PhysicsEngine();
+ int draws = 0;
+ engine.SetPositionRandomUnit = () =>
+ {
+ draws++;
+ return 0.5d;
+ };
+
+ PhysicsSetPositionResult result = engine.SetPosition(
+ Request(Cell, Vector3.One, Vector3.One) with
+ {
+ Flags = PhysicsSetPositionFlags.RandomScatter,
+ ScatterAttempts = 0u,
+ });
+
+ Assert.Equal(
+ PhysicsSetPositionError.GeneralFailure,
+ result.Error);
+ Assert.Equal(PhysicsResidenceDisposition.Unchanged, result.Residence);
+ Assert.Equal(0, draws);
+ }
+
+ private static PhysicsSetPositionRequest Request(
+ uint cellId,
+ Vector3 cellLocal,
+ Vector3 position,
+ ImmutableArray spheres = default,
+ float scale = 1f) => new(
+ position,
+ Quaternion.CreateFromAxisAngle(Vector3.UnitZ, 0.75f),
+ cellId,
+ cellLocal,
+ spheres,
+ scale,
+ StepUpHeight: 0.4f,
+ StepDownHeight: 0.4f,
+ Flags: PhysicsSetPositionFlags.Placement
+ | PhysicsSetPositionFlags.Slide);
+
+ private static Transition PlacementTransition()
+ {
+ var transition = new Transition();
+ transition.SpherePath.InitPath(
+ new Vector3(10f, 10f, 10f),
+ new Vector3(10f, 10f, 10f),
+ Cell,
+ sphereRadius: 0.48f);
+ transition.SpherePath.InsertType = InsertType.Placement;
+ return transition;
+ }
+
+ private static PhysicsEngine FlatEngine(bool withDataCache = true)
+ {
+ var engine = new PhysicsEngine();
+ if (withDataCache)
+ engine.DataCache = new PhysicsDataCache();
+ engine.AddLandblock(
+ Landblock,
+ new TerrainSurface(new byte[81], new float[256]),
+ Array.Empty(),
+ Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+ return engine;
+ }
+
+ private static void AddFlatLandblock(
+ PhysicsEngine engine,
+ uint landblock,
+ float worldOffsetX = 0f,
+ float worldOffsetY = 0f)
+ {
+ engine.AddLandblock(
+ landblock,
+ new TerrainSurface(new byte[81], new float[256]),
+ Array.Empty(),
+ Array.Empty(),
+ worldOffsetX,
+ worldOffsetY);
+ }
+}