From e84a388e6f17c8cb03836e9aee641454b2009868 Mon Sep 17 00:00:00 2001 From: Erik Date: Fri, 31 Jul 2026 20:44:03 +0200 Subject: [PATCH] feat(physics): port canonical retail set-position core --- .../retail-divergence-register.md | 10 +- .../2026-07-31-canonical-set-position.md | 142 +++ src/AcDream.Core/Physics/PhysicsEngine.cs | 393 +++++++ .../Physics/PhysicsSetPosition.cs | 155 +++ src/AcDream.Core/Physics/TransitionTypes.cs | 232 +++- .../Physics/PhysicsSetPositionTests.cs | 1041 +++++++++++++++++ 6 files changed, 1952 insertions(+), 21 deletions(-) create mode 100644 docs/research/2026-07-31-canonical-set-position.md create mode 100644 src/AcDream.Core/Physics/PhysicsSetPosition.cs create mode 100644 tests/AcDream.Core.Tests/Physics/PhysicsSetPositionTests.cs 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); + } +}