feat(physics): port canonical retail set-position core

This commit is contained in:
Erik 2026-07-31 20:44:03 +02:00
parent 6b28ff999c
commit e84a388e6f
6 changed files with 1952 additions and 21 deletions

View file

@ -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:273191273307 |
| ~~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:273001273090 |

View file

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

View file

@ -89,6 +89,14 @@ public sealed class PhysicsEngine
TransitionState,
TransitionState>? TransitionCellCollisionTestHook { get; set; }
/// <summary>
/// Deterministic seam for retail <c>Random::RollDice(-1, 1)</c> used by
/// SetPosition scatter. Values are expected in [0,1); production uses the
/// process RNG and tests inject a fixed sequence.
/// </summary>
internal Func<double> SetPositionRandomUnit { get; set; } =
Random.Shared.NextDouble;
/// <summary>
/// True once the landblock covering <paramref name="cellOrLandblockId"/> has had its
/// terrain + cells registered via <see cref="AddLandblock"/>. Accepts a canonical
@ -1530,6 +1538,391 @@ public sealed class PhysicsEngine
/// green).
/// </para>
/// </summary>
private readonly record struct AdjustedSetPosition(
uint CellId,
Vector3 CellLocalPosition,
bool Resident);
/// <summary>
/// SetPosition's exact AdjustPosition input/output shape. Unlike the
/// camera helper above, this carries retail's block-local frame and can
/// therefore run outdoor <c>LandDefs::adjust_to_outside</c> without
/// consulting the resident-landblock registry. A valid adjusted id with
/// no visible cell is retained for the lost-cell path.
/// </summary>
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:
/// <c>CPhysicsObj::SetPosition</c> (0x005160C0) -&gt;
/// <c>SetPositionInternal</c> (0x00515BD0) -&gt;
/// <c>AdjustPosition</c> (0x00511D80) -&gt;
/// <c>CheckPositionInternal</c> (0x00511E90) -&gt;
/// <c>CTransition::find_valid_position</c> (0x0050C310).
/// A destination whose cell is not resident returns DeferredCell; callers
/// must retain the authoritative frame rather than demoting it outdoors.
/// </summary>
internal PhysicsSetPositionResult SetPosition(
in PhysicsSetPositionRequest request,
Func<PhysicsSetPositionCollisionReport, bool>? 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<PhysicsSetPositionCollisionReport, bool>? 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<PhysicsSetPositionCollisionReport, bool>? handleCollisions)
{
transition.SpherePath.CellCandidates.Clear();
transition.SpherePath.ClearWalkable();
ImmutableArray<FlatCollisionSphere> 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<uint>.Empty,
CollidedObjectIds: ImmutableArray<uint>.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<uint>.Empty,
CollidedObjectIds: ImmutableArray<uint>.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<uint>.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<uint> 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<uint>.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<uint>.Empty,
CollidedObjectIds: ImmutableArray<uint>.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;
}
/// <summary>
/// #111: the walkable floor Z of <paramref name="cellId"/>'s PHYSICS
/// polygons under the world XY, nearest to <paramref name="referenceZ"/>.

View file

@ -0,0 +1,155 @@
using System.Collections.Immutable;
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Retail <c>SetPositionError</c> (<c>acclient.h</c>, enum 491). This is
/// deliberately independent of <see cref="PhysicsResidenceDisposition"/>:
/// losing the destination cell is a successful SetPosition operation whose
/// residence is deferred, not a placement error.
/// </summary>
internal enum PhysicsSetPositionError
{
Ok = 0,
GeneralFailure = 1,
NoValidPosition = 2,
NoCell = 3,
Collided = 4,
InvalidArguments = 0x100,
}
internal enum PhysicsResidenceDisposition
{
Committed,
DeferredCell,
Unchanged,
}
/// <summary>
/// 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 <see cref="PhysicsSetPositionResult.CrossCellIds"/>;
/// Preserve intentionally leaves the existing list untouched.
/// </summary>
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<uint> 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,
}
/// <summary>
/// The three retail weenie classifications which bypass placement collision
/// in <c>CPhysicsObj::SetPositionInternal</c> after AdjustPosition succeeds.
/// Runtime derives this from the canonical object record; callers cannot use a
/// free boolean to force ordinary objects through geometry.
/// </summary>
internal enum PhysicsPlacementClass
{
Ordinary,
Hook,
Storage,
Corpse,
}
/// <summary>
/// Complete immutable input to retail <c>CPhysicsObj::SetPosition</c>. World
/// position feeds acdream's flat collision representation; cell-local position
/// is retail's <c>Position.frame.origin</c> and is the only input to
/// <c>LandDefs::adjust_to_outside</c>.
/// </summary>
internal readonly record struct PhysicsSetPositionRequest(
Vector3 Position,
Quaternion Orientation,
uint CellId,
Vector3 CellLocalPosition,
ImmutableArray<FlatCollisionSphere> 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);
/// <summary>
/// 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.
/// </summary>
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<uint> CrossCellIds = default,
ImmutableArray<uint> CollidedObjectIds = default)
{
internal bool IsSuccessful => Error == PhysicsSetPositionError.Ok;
internal bool IsCommitted =>
IsSuccessful && Residence == PhysicsResidenceDisposition.Committed;
internal bool IsDeferred =>
IsSuccessful && Residence == PhysicsResidenceDisposition.DeferredCell;
}

View file

@ -1575,6 +1575,79 @@ public sealed class Transition
return transitionState == TransitionState.OK;
}
/// <summary>
/// Retail <c>CTransition::find_valid_position</c> (0x0050C310). Placement
/// insertions use the complete outer <c>find_placement_position</c>
/// transaction; ordinary movement uses the transitional sweep.
/// </summary>
internal bool FindValidPosition(PhysicsEngine engine) =>
SpherePath.InsertType == InsertType.Transition
? FindTransitionalPosition(engine)
: FindPlacementPosition(engine);
/// <summary>
/// Retail <c>CTransition::find_placement_position</c> (0x0050C170):
/// INITIAL_PLACEMENT insertion, other-cell validation, the inner compass
/// search, optional placement step-down, and final validation.
/// </summary>
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;
}
/// <summary>
/// Retail <c>CTransition::find_placement_pos</c> (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);
/// <summary>
/// Retail <c>CTransition::validate_placement</c> (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.
/// </summary>
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;
}
/// <summary>
/// The initial half of retail <c>find_placement_position</c>. This is
/// deliberately not <see cref="TransitionalInsert"/>: initial placement
/// performs one primary insert and, only on OK, the other-cell pass. It
/// never enters transitional collide/step/edge response branches.
/// </summary>
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;
}
/// <summary>
/// Retail <c>CTransition::placement_insert</c> (0x0050B1D0), used by
/// <c>validate_placement</c> for exactly one Adjusted/Slid retry.
/// </summary>
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;
}
/// <summary>
/// Primary-cell virtual <c>find_collisions</c> composition. A non-OK
/// response terminates this pass, so an inner retry always restarts from

File diff suppressed because it is too large Load diff