Root cause confirmed via two-run diagnostic and the named-retail decomp:
the airborne sweep was colliding with the moving entity's OWN ShadowEntry
because FindObjCollisions had no self-skip filter. Live entities (local
player, remotes) register a Cylinder in ShadowObjectRegistry on spawn
(GameWindow.cs:2545) and UpdatePosition tracks its world position each
tick, so the moving sphere's own cylinder is always at the body's
position. Without a gate, CylinderCollision sees the sphere overlapping
its own cylinder volume and slides the sphere ~1m horizontally on every
frame the path produces non-zero motion.
Why grounded mostly hides it and airborne exposes it:
- Stationary grounded → numSteps=0, TransitionalInsert never runs.
- Walking grounded → push fires but motion escapes the cyl radius and
the deflection blends into normal motion.
- Stationary airborne (jump) → pure +Z motion; the cyl push is the
only horizontal contribution and manifests as a clean ~1m drift.
Run-2 evidence (launch-42-r2.log) — 152 [SWEEP-OBJ] events, every one
with type=Cylinder, gfxObj=0x02000001 (humanoid setup), R=0.679,
H=1.835, at obj.Position EXACTLY matching the body's pre.Position. Run
1 had already ruled out H1 (cpN=(0,0,1) flat, no slope projection).
Retail does the same skip — CObjCell::find_obj_collisions at
named-retail acclient_2013_pseudo_c.txt:308931:
if ((physobj->parent == 0 && physobj != arg2->object_info.object))
`arg2->object_info.object` is the OBJECTINFO::object self-pointer set
by OBJECTINFO::init at acclient_2013_pseudo_c.txt:274435. Our port
mirrors this with an EntityId-based filter:
- ObjectInfo gains a SelfEntityId field (default 0 = no filter).
- ResolveWithTransition gains an optional `uint movingEntityId = 0`
parameter that sets it.
- FindObjCollisions skips entries whose EntityId matches
SelfEntityId when the id is non-zero.
- PlayerMovementController gains a LocalEntityId property; GameWindow
refreshes it per-tick from `_entitiesByServerGuid[_playerServerGuid]`.
- GameWindow's airborne-remote ResolveWithTransition call site passes
`movingEntityId: kv.Key` (kv.Key is the local entity id keying
`_animatedEntities`, same id used at the spawn-time
ShadowObjects.Register).
Default 0 keeps tests and one-shot callers (no registered ShadowEntry)
working unchanged.
Lock-the-fix unit test:
`PhysicsEngineTests.ResolveWithTransition_SelfShadowEntry_NotPushedWhenIdMatches`
registers a humanoid Cylinder at the body's exact position (matching
GameWindow's spawn pattern), then asserts that:
- movingEntityId=0 (control) → unfiltered XY drift > 0.5m
- movingEntityId=registered id (fix) → XY drift ≈ 0
Diagnostic wiring (a36369d + this commit's [SWEEP-OBJ] addition) stays
in tree, env-var gated (ACDREAM_AIRBORNE_DIAG=1) so it produces no
output in normal use but lets us verify the fix on the live client and
debug future regressions.
Build: green. Tests: 355 pass, 6 fail (all pre-existing per the handoff
prompt — verified by stashing this change; the BSPStepUp C3 failure is
on the prior commit too).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
695 lines
30 KiB
C#
695 lines
30 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Numerics;
|
||
|
||
namespace AcDream.Core.Physics;
|
||
|
||
internal readonly record struct TerrainWalkableSample(
|
||
System.Numerics.Plane Plane,
|
||
Vector3[] Vertices,
|
||
float WaterDepth,
|
||
bool IsWater,
|
||
uint CellId);
|
||
|
||
/// <summary>
|
||
/// Top-level physics resolver that combines <see cref="TerrainSurface"/> and
|
||
/// <see cref="CellSurface"/> to resolve entity movement with step-height
|
||
/// enforcement and outdoor/indoor cell transitions.
|
||
///
|
||
/// <para>
|
||
/// Landblocks are registered via <see cref="AddLandblock"/> with their
|
||
/// terrain, indoor cells, and world-space offsets. <see cref="Resolve"/>
|
||
/// takes a current position, the entity's current cell ID, a movement delta,
|
||
/// and a step-up height limit; it returns the validated new position, the
|
||
/// updated cell ID, and whether the entity is standing on a surface.
|
||
/// </para>
|
||
/// </summary>
|
||
public sealed class PhysicsEngine
|
||
{
|
||
private readonly Dictionary<uint, LandblockPhysics> _landblocks = new();
|
||
|
||
/// <summary>Number of registered landblocks (diagnostic).</summary>
|
||
public int LandblockCount => _landblocks.Count;
|
||
|
||
/// <summary>
|
||
/// Cell-based spatial index for static object collision.
|
||
/// Populated during landblock streaming; queried by the Transition system.
|
||
/// </summary>
|
||
public ShadowObjectRegistry ShadowObjects { get; } = new();
|
||
|
||
/// <summary>
|
||
/// Physics BSP cache shared with the streaming loader. Set once by the
|
||
/// host (GameWindow) immediately after construction. The Transition system
|
||
/// reads this during FindObjCollisions to perform narrow-phase BSP tests.
|
||
/// </summary>
|
||
public PhysicsDataCache? DataCache { get; set; }
|
||
|
||
private sealed record LandblockPhysics(
|
||
TerrainSurface Terrain,
|
||
IReadOnlyList<CellSurface> Cells,
|
||
IReadOnlyList<PortalPlane> Portals,
|
||
float WorldOffsetX,
|
||
float WorldOffsetY);
|
||
|
||
/// <summary>
|
||
/// Register a landblock with its terrain surface, indoor cells, portal
|
||
/// planes, and world-space origin offset.
|
||
/// </summary>
|
||
public void AddLandblock(uint landblockId, TerrainSurface terrain,
|
||
IReadOnlyList<CellSurface> cells, IReadOnlyList<PortalPlane> portals,
|
||
float worldOffsetX, float worldOffsetY)
|
||
{
|
||
_landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Remove a previously registered landblock, including its shadow objects.
|
||
/// </summary>
|
||
public void RemoveLandblock(uint landblockId)
|
||
{
|
||
_landblocks.Remove(landblockId);
|
||
ShadowObjects.RemoveLandblock(landblockId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Find the landblock that contains the given world-space XY position and
|
||
/// return its ID plus world-space origin offsets. Returns false when no
|
||
/// registered landblock covers the position.
|
||
/// Used by Transition.FindObjCollisions to build the shadow-object query.
|
||
/// </summary>
|
||
public bool TryGetLandblockContext(float worldX, float worldY,
|
||
out uint landblockId, out float worldOffsetX, out float worldOffsetY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
{
|
||
landblockId = kvp.Key;
|
||
worldOffsetX = lb.WorldOffsetX;
|
||
worldOffsetY = lb.WorldOffsetY;
|
||
return true;
|
||
}
|
||
}
|
||
landblockId = 0;
|
||
worldOffsetX = 0f;
|
||
worldOffsetY = 0f;
|
||
return false;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the outdoor terrain Z at the given world-space XY position.
|
||
/// Searches all registered landblocks; returns null if no landblock covers the position.
|
||
/// Used by Transition.FindEnvCollisions for terrain collision resolution.
|
||
/// </summary>
|
||
public float? SampleTerrainZ(float worldX, float worldY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
return lb.Terrain.SampleZ(localX, localY);
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the per-point water depth at the given world-space XY
|
||
/// (meters by which the character is allowed to sink below the
|
||
/// contact plane — 0.9 on fully-flooded water cells, 0.45 on
|
||
/// partial-water near a water corner, 0.1 on non-water corners of
|
||
/// partial-water cells, 0 on dry cells). Matches ACE
|
||
/// <c>ObjCell.get_water_depth</c>. Used by
|
||
/// <see cref="Transition"/> to visually submerge characters in water
|
||
/// without needing a separate water surface mesh.
|
||
/// </summary>
|
||
public float SampleWaterDepth(float worldX, float worldY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
return lb.Terrain.SampleWaterDepth(localX, localY);
|
||
}
|
||
return 0f;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the outdoor terrain plane (Z + sloped normal) at the given
|
||
/// world-space XY position. The returned <see cref="System.Numerics.Plane"/>
|
||
/// has the true terrain-triangle normal (NOT a flat <c>(0,0,1)</c>), and
|
||
/// its <c>D</c> is set so the plane passes through the sampled point. Used
|
||
/// by <see cref="Transition"/> to build a CORRECT contact plane — a flat
|
||
/// plane breaks slope tracking because <c>AdjustOffset</c>'s projection
|
||
/// onto a flat plane cannot impart the Z component that horizontal
|
||
/// velocity needs to follow the slope.
|
||
/// </summary>
|
||
public System.Numerics.Plane? SampleTerrainPlane(float worldX, float worldY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
{
|
||
var (z, normal) = lb.Terrain.SampleSurface(localX, localY);
|
||
// System.Numerics.Plane convention: dot(Normal, P) + D == 0
|
||
// for points P on the plane. Pick P = (worldX, worldY, z).
|
||
float d = -(normal.X * worldX + normal.Y * worldY + normal.Z * z);
|
||
return new System.Numerics.Plane(normal, d);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sample the outdoor terrain walkable triangle at the given world-space
|
||
/// XY position. This carries the same plane as <see cref="SampleTerrainPlane"/>
|
||
/// plus world-space triangle vertices for retail precipice-slide.
|
||
/// </summary>
|
||
internal TerrainWalkableSample? SampleTerrainWalkable(float worldX, float worldY)
|
||
{
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldX - lb.WorldOffsetX;
|
||
float localY = worldY - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
{
|
||
var sample = lb.Terrain.SampleSurfacePolygon(localX, localY);
|
||
var vertices = new Vector3[sample.Vertices.Length];
|
||
for (int i = 0; i < sample.Vertices.Length; i++)
|
||
{
|
||
var v = sample.Vertices[i];
|
||
vertices[i] = new Vector3(
|
||
v.X + lb.WorldOffsetX,
|
||
v.Y + lb.WorldOffsetY,
|
||
v.Z);
|
||
}
|
||
|
||
var normal = sample.Normal;
|
||
float d = -Vector3.Dot(normal, vertices[0]);
|
||
var plane = new System.Numerics.Plane(normal, d);
|
||
|
||
float waterDepth = lb.Terrain.SampleWaterDepth(localX, localY);
|
||
bool isWater = waterDepth >= 0.45f;
|
||
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
|
||
uint fullCellId = (kvp.Key & 0xFFFF0000u) | lowCellId;
|
||
|
||
return new TerrainWalkableSample(
|
||
plane,
|
||
vertices,
|
||
waterDepth,
|
||
isWater,
|
||
fullCellId);
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve the outdoor cell id that owns a world-space position.
|
||
/// Indoor ids are preserved because EnvCell ownership still comes from
|
||
/// portal/cell BSP state; outdoor ids are derived from the registered
|
||
/// landblock that currently contains the point.
|
||
/// </summary>
|
||
internal uint ResolveOutdoorCellId(Vector3 worldPos, uint fallbackCellId)
|
||
{
|
||
if (fallbackCellId == 0)
|
||
return 0;
|
||
|
||
uint fallbackLow = fallbackCellId & 0xFFFFu;
|
||
if (fallbackLow >= 0x0100u)
|
||
return fallbackCellId;
|
||
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = worldPos.X - lb.WorldOffsetX;
|
||
float localY = worldPos.Y - lb.WorldOffsetY;
|
||
if (localX >= 0f && localX < 192f && localY >= 0f && localY < 192f)
|
||
{
|
||
uint lowCellId = lb.Terrain.ComputeOutdoorCellId(localX, localY);
|
||
return (fallbackCellId & 0xFFFF0000u) == 0
|
||
? lowCellId
|
||
: (kvp.Key & 0xFFFF0000u) | lowCellId;
|
||
}
|
||
}
|
||
|
||
return fallbackCellId;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve an entity's movement from <paramref name="currentPos"/> by
|
||
/// applying <paramref name="delta"/> (XY only) and computing the correct Z
|
||
/// from the terrain or indoor cell floor beneath the candidate position.
|
||
///
|
||
/// <para>
|
||
/// Step-height enforcement rejects horizontal movement when the upward Z
|
||
/// change exceeds <paramref name="stepUpHeight"/>. Downhill movement is
|
||
/// always accepted. Returns <see cref="ResolveResult.IsOnGround"/> false
|
||
/// when no loaded landblock covers the candidate position.
|
||
/// </para>
|
||
/// </summary>
|
||
public ResolveResult Resolve(Vector3 currentPos, uint cellId, Vector3 delta, float stepUpHeight)
|
||
{
|
||
var candidatePos = currentPos + new Vector3(delta.X, delta.Y, 0f);
|
||
|
||
// Find the landblock this candidate position falls in.
|
||
LandblockPhysics? physics = null;
|
||
foreach (var kvp in _landblocks)
|
||
{
|
||
var lb = kvp.Value;
|
||
float localX = candidatePos.X - lb.WorldOffsetX;
|
||
float localY = candidatePos.Y - lb.WorldOffsetY;
|
||
if (localX >= 0 && localX < 192f && localY >= 0 && localY < 192f)
|
||
{
|
||
physics = lb;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (physics is null)
|
||
return new ResolveResult(candidatePos, cellId, IsOnGround: false);
|
||
|
||
float localCandX = candidatePos.X - physics.WorldOffsetX;
|
||
float localCandY = candidatePos.Y - physics.WorldOffsetY;
|
||
|
||
// Check if the candidate position falls on any indoor cell floor.
|
||
// Pick the cell whose floor Z is closest to the entity's current Z.
|
||
CellSurface? bestCell = null;
|
||
float? bestCellZ = null;
|
||
float bestZDist = float.MaxValue;
|
||
|
||
foreach (var cell in physics.Cells)
|
||
{
|
||
float? floorZ = cell.SampleFloorZ(candidatePos.X, candidatePos.Y);
|
||
if (floorZ is not null)
|
||
{
|
||
float dist = MathF.Abs(floorZ.Value - currentPos.Z);
|
||
if (dist < bestZDist)
|
||
{
|
||
bestCell = cell;
|
||
bestCellZ = floorZ;
|
||
bestZDist = dist;
|
||
}
|
||
}
|
||
}
|
||
|
||
// Determine target surface Z and cell.
|
||
float terrainZ = physics.Terrain.SampleZ(localCandX, localCandY);
|
||
float targetZ;
|
||
uint targetCellId;
|
||
|
||
// Only the low 16 bits of cellId carry the cell index. Outdoor
|
||
// cells are 0x0001–0x0040; indoor (EnvCell) cells are 0x0100+.
|
||
// The full 32-bit cellId includes the landblock prefix in the
|
||
// high 16 bits (e.g., 0xA9B40001), so we MUST mask before
|
||
// comparing. Without the mask, every cell looks "indoor" because
|
||
// 0xA9B40001 >= 0x0100 → the engine always takes the "stay
|
||
// indoors" path and snaps Z to an EnvCell floor 28m below.
|
||
bool currentlyIndoor = (cellId & 0xFFFFu) >= 0x0100;
|
||
|
||
if (currentlyIndoor)
|
||
{
|
||
// Check whether the player crosses a portal belonging to the current cell.
|
||
uint currentCellIndex = cellId & 0xFFFFu;
|
||
PortalPlane? crossedPortal = null;
|
||
foreach (var portal in physics.Portals)
|
||
{
|
||
// Only portals owned by the current cell are relevant when indoors.
|
||
if ((portal.OwnerCellId & 0xFFFFu) != currentCellIndex) continue;
|
||
if (portal.IsCrossing(currentPos, candidatePos))
|
||
{
|
||
crossedPortal = portal;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (crossedPortal is not null)
|
||
{
|
||
if (crossedPortal.Value.TargetCellId == 0xFFFFu)
|
||
{
|
||
// Indoor → Outdoor exit.
|
||
targetZ = terrainZ;
|
||
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
|
||
}
|
||
else
|
||
{
|
||
// Indoor → Indoor (room to room).
|
||
uint nextCellIndex = crossedPortal.Value.TargetCellId & 0xFFFFu;
|
||
CellSurface? nextCell = null;
|
||
foreach (var c in physics.Cells)
|
||
{
|
||
if ((c.CellId & 0xFFFFu) == nextCellIndex) { nextCell = c; break; }
|
||
}
|
||
float? nextFloorZ = nextCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
|
||
targetZ = nextFloorZ ?? terrainZ;
|
||
targetCellId = nextCellIndex;
|
||
}
|
||
}
|
||
else if (bestCellZ is not null)
|
||
{
|
||
// Staying in the same indoor cell.
|
||
targetZ = bestCellZ.Value;
|
||
targetCellId = bestCell!.CellId & 0xFFFFu;
|
||
}
|
||
else
|
||
{
|
||
// No cell floor found and no portal crossed — fall back to outdoor.
|
||
targetZ = terrainZ;
|
||
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Outdoor player: check for a portal crossing into an indoor cell.
|
||
// Outside-facing portals have TargetCellId == 0xFFFF (they face the
|
||
// outdoor world); crossing one from the outdoor side enters the OwnerCellId.
|
||
PortalPlane? crossedPortal = null;
|
||
foreach (var portal in physics.Portals)
|
||
{
|
||
if (portal.TargetCellId != 0xFFFFu) continue; // only outside-facing portals
|
||
if (portal.IsCrossing(currentPos, candidatePos))
|
||
{
|
||
crossedPortal = portal;
|
||
break;
|
||
}
|
||
}
|
||
|
||
if (crossedPortal is not null)
|
||
{
|
||
// Outdoor → Indoor: enter the OwnerCellId IF the target cell
|
||
// actually contains the candidate position. Without CellBSP,
|
||
// we verify by checking that SampleFloorZ returns non-null
|
||
// (position is within the cell's floor polygon bounds) AND the
|
||
// floor Z is close to the player's current Z (not a basement
|
||
// 30m below). This prevents the wall-bounce bug where portal
|
||
// planes on upper floors captured outdoor positions.
|
||
uint enterCellIndex = crossedPortal.Value.OwnerCellId & 0xFFFFu;
|
||
CellSurface? enterCell = null;
|
||
foreach (var c in physics.Cells)
|
||
{
|
||
if ((c.CellId & 0xFFFFu) == enterCellIndex) { enterCell = c; break; }
|
||
}
|
||
float? enterFloorZ = enterCell?.SampleFloorZ(candidatePos.X, candidatePos.Y);
|
||
|
||
// Validate: floor must exist AND be within step height of current Z.
|
||
// This rejects transitions to basements, upper floors, and cells
|
||
// whose floor polygon doesn't actually cover this position.
|
||
bool validTransition = enterFloorZ is not null
|
||
&& MathF.Abs(enterFloorZ.Value - currentPos.Z) < stepUpHeight + 2f;
|
||
|
||
if (validTransition)
|
||
{
|
||
targetZ = enterFloorZ!.Value;
|
||
targetCellId = enterCellIndex;
|
||
}
|
||
else
|
||
{
|
||
// Portal crossed but target cell doesn't contain us — stay outdoor.
|
||
targetZ = terrainZ;
|
||
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
|
||
}
|
||
}
|
||
else
|
||
{
|
||
// Stay outdoors on terrain.
|
||
targetZ = terrainZ;
|
||
targetCellId = physics.Terrain.ComputeOutdoorCellId(localCandX, localCandY);
|
||
}
|
||
}
|
||
|
||
// Step-height enforcement: block upward movement that exceeds the limit.
|
||
float zDelta = targetZ - currentPos.Z;
|
||
if (zDelta > stepUpHeight)
|
||
{
|
||
// Too steep to step up — reject horizontal movement.
|
||
return new ResolveResult(currentPos, cellId, IsOnGround: true);
|
||
}
|
||
|
||
return new ResolveResult(
|
||
new Vector3(candidatePos.X, candidatePos.Y, targetZ),
|
||
targetCellId,
|
||
IsOnGround: true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Resolve movement using the CTransition sphere-sweep system.
|
||
/// Subdivides movement into sphere-radius steps, tests terrain collision
|
||
/// at each step, handles step-down for ground contact.
|
||
/// Falls back to the simple <see cref="Resolve"/> if the transition fails.
|
||
///
|
||
/// <para>
|
||
/// <paramref name="body"/> is optional but highly recommended for movement
|
||
/// that runs across multiple frames. When provided, the previous frame's
|
||
/// contact plane is copied INTO the transition's CollisionInfo (mirroring
|
||
/// retail's <c>PhysicsObj.get_object_info → InitContactPlane</c> at
|
||
/// <c>PhysicsObj.cs:2598-2604</c>). That seed is critical for slope
|
||
/// tracking: <c>AdjustOffset</c> projects the Euler offset onto the plane
|
||
/// so horizontal velocity acquires the correct Z component for the slope,
|
||
/// preventing the character from floating on downhill runs where the
|
||
/// per-frame descent exceeds the 4 cm step-down budget.
|
||
/// </para>
|
||
///
|
||
/// <para>
|
||
/// On return, the plane discovered during this call is written BACK to
|
||
/// <paramref name="body"/>, so the next frame's transition starts with
|
||
/// an up-to-date plane seed. Callers without a persistent body (tests,
|
||
/// one-shot movements) can pass <c>null</c> and accept the first-frame
|
||
/// hiccup.
|
||
/// </para>
|
||
/// </summary>
|
||
public ResolveResult ResolveWithTransition(
|
||
Vector3 currentPos, Vector3 targetPos, uint cellId,
|
||
float sphereRadius, float sphereHeight,
|
||
float stepUpHeight, float stepDownHeight,
|
||
bool isOnGround,
|
||
PhysicsBody? body = null,
|
||
ObjectInfoState moverFlags = ObjectInfoState.None,
|
||
uint movingEntityId = 0)
|
||
{
|
||
var transition = new Transition();
|
||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||
transition.ObjectInfo.StepDown = true;
|
||
// Fix #42 (2026-05-05): the moving entity's ShadowEntry must be
|
||
// skipped in FindObjCollisions or the sweep collides with self.
|
||
// Default 0 keeps tests / one-shot callers (no registered entity)
|
||
// working. Plumbed through ObjectInfo because retail stores the
|
||
// self pointer on OBJECTINFO::object (named-retail
|
||
// acclient_2013_pseudo_c.txt:274435 OBJECTINFO::init →
|
||
// this->object = arg2). The skip itself is at
|
||
// CObjCell::find_obj_collisions line 308931.
|
||
transition.ObjectInfo.SelfEntityId = movingEntityId;
|
||
|
||
// Commit C 2026-04-29 — caller-supplied mover flags drive the
|
||
// retail PvP exemption block in FindObjCollisions. The local
|
||
// player passes IsPlayer (and PK/PKLite/Impenetrable when known
|
||
// from PlayerDescription); remote dead-reckoning passes None
|
||
// (matches non-player movement, all targets collide).
|
||
transition.ObjectInfo.State |= moverFlags;
|
||
|
||
if (isOnGround)
|
||
transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable;
|
||
|
||
// K-fix7 (2026-04-26): only seed the contact plane when the body
|
||
// is actually grounded. Pre-seeding while AIRBORNE caused
|
||
// AdjustOffset's "Have a contact plane / Moving away from plane"
|
||
// branch to fire on every jump step — which calls
|
||
// Plane::snap_to_plane on the offset and ZEROES the Z component,
|
||
// killing all upward jump motion (the body's Z velocity stayed
|
||
// ~9 m/s but Position.Z never advanced because every step's
|
||
// offset got snapped flat). Retail's CTransition::init at
|
||
// 0x509dd0 (named-retail line 271954) explicitly clears
|
||
// contact_plane_valid = 0 at the start of every transition
|
||
// resolve, then ValidateWalkable re-establishes it during the
|
||
// sweep when the sphere bottom is within EPSILON of the terrain
|
||
// plane — so for grounded motion the plane is set fresh every
|
||
// resolve, and for airborne motion no plane interferes.
|
||
//
|
||
// We KEEP the seeding when isOnGround for slope-walking
|
||
// continuity (the original concern that motivated the seed) —
|
||
// walking up a hill needs the previous step's slope to project
|
||
// movement properly. Airborne / jumping must start with no
|
||
// plane so AdjustOffset preserves Z.
|
||
if (isOnGround && body is not null && body.ContactPlaneValid)
|
||
{
|
||
transition.CollisionInfo.SetContactPlane(
|
||
body.ContactPlane,
|
||
body.ContactPlaneCellId,
|
||
body.ContactPlaneIsWater);
|
||
}
|
||
|
||
// Retail CPhysicsObj::get_object_info also seeds SlidingNormal when
|
||
// transient_state has bit 2 set. This matters for one-step/frame hits:
|
||
// a wall collision at the end of one transition must project the next
|
||
// frame's movement along the wall instead of hard-stopping again.
|
||
if (body is not null
|
||
&& body.TransientState.HasFlag(TransientStateFlags.Sliding)
|
||
&& body.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||
{
|
||
transition.CollisionInfo.SetSlidingNormal(body.SlidingNormal);
|
||
}
|
||
|
||
transition.SpherePath.InitPath(currentPos, targetPos, cellId, sphereRadius, sphereHeight);
|
||
|
||
if (isOnGround && body is not null
|
||
&& body.WalkablePolygonValid
|
||
&& body.WalkableVertices is { Length: >= 3 })
|
||
{
|
||
transition.SpherePath.SetWalkable(
|
||
body.WalkablePlane,
|
||
body.WalkableVertices,
|
||
body.WalkableUp);
|
||
}
|
||
|
||
bool ok = transition.FindTransitionalPosition(this);
|
||
|
||
var sp = transition.SpherePath;
|
||
var ci = transition.CollisionInfo;
|
||
|
||
// Persist the resulting contact plane state back to the body so the
|
||
// next frame's transition can seed from it. Uses LastKnownContactPlane
|
||
// when current is invalid (e.g., airborne this frame), matching retail.
|
||
if (body is not null)
|
||
{
|
||
if (ci.ContactPlaneValid)
|
||
{
|
||
body.ContactPlaneValid = true;
|
||
body.ContactPlane = ci.ContactPlane;
|
||
body.ContactPlaneCellId = ci.ContactPlaneCellId;
|
||
body.ContactPlaneIsWater = ci.ContactPlaneIsWater;
|
||
}
|
||
else if (ci.LastKnownContactPlaneValid)
|
||
{
|
||
body.ContactPlaneValid = true;
|
||
body.ContactPlane = ci.LastKnownContactPlane;
|
||
body.ContactPlaneCellId = ci.LastKnownContactPlaneCellId;
|
||
body.ContactPlaneIsWater = ci.LastKnownContactPlaneIsWater;
|
||
}
|
||
else
|
||
{
|
||
body.ContactPlaneValid = false;
|
||
}
|
||
|
||
if (sp.HasLastWalkablePolygon && sp.LastWalkableVertices is not null)
|
||
{
|
||
body.WalkablePolygonValid = true;
|
||
body.WalkablePlane = sp.LastWalkablePlane;
|
||
body.WalkableVertices = (Vector3[])sp.LastWalkableVertices.Clone();
|
||
body.WalkableUp = sp.LastWalkableUp;
|
||
}
|
||
else if (!isOnGround && !ci.ContactPlaneValid && !ci.LastKnownContactPlaneValid)
|
||
{
|
||
body.WalkablePolygonValid = false;
|
||
body.WalkableVertices = null;
|
||
}
|
||
|
||
if (ci.SlidingNormalValid
|
||
&& ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
|
||
{
|
||
body.SlidingNormal = ci.SlidingNormal;
|
||
body.TransientState |= TransientStateFlags.Sliding;
|
||
}
|
||
else
|
||
{
|
||
body.SlidingNormal = Vector3.Zero;
|
||
body.TransientState &= ~TransientStateFlags.Sliding;
|
||
}
|
||
|
||
// L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity.
|
||
// Phase 3's reset path sets VelocityKilled when an airborne hit
|
||
// can't find a walkable surface (steep roof, wall) AND the
|
||
// body had a last_known_contact_plane (i.e., was grounded
|
||
// recently). Retail zeros all three velocity components so
|
||
// gravity restarts cleanly next frame.
|
||
//
|
||
// Named-retail: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0)
|
||
// acclient_2013_pseudo_c.txt:274467-274475
|
||
// Called from CTransition::transitional_insert reset path:
|
||
// acclient_2013_pseudo_c.txt:273237 (Phase 3)
|
||
// acclient_2013_pseudo_c.txt:272567 (validate_transition)
|
||
if (transition.ObjectInfo.VelocityKilled)
|
||
{
|
||
if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1")
|
||
Console.WriteLine($"[steep-roof] KILL-VELOCITY-APPLIED Vbefore=({body.Velocity.X:F2},{body.Velocity.Y:F2},{body.Velocity.Z:F2}) → 0,0,0");
|
||
body.Velocity = Vector3.Zero;
|
||
}
|
||
}
|
||
|
||
// L.3a (2026-04-30): surface the wall normal so callers can apply
|
||
// retail's velocity-reflection bounce (CPhysicsObj::handle_all_collisions
|
||
// at acclient_2013_pseudo_c.txt:282699-282715, ACE PhysicsObj.cs:
|
||
// 2692-2697). The reflection itself is applied in
|
||
// PlayerMovementController after the position commit, gated on
|
||
// apply_bounce = !(prevOnWalkable && newOnWalkable) — airborne wall
|
||
// hits bounce, grounded wall slides don't.
|
||
bool collisionNormalValid = ci.CollisionNormalValid;
|
||
Vector3 collisionNormal = ci.CollisionNormal;
|
||
|
||
// #42 diagnostic (2026-05-05): trace airborne sweeps to identify the
|
||
// source of the ~1m XY drift on retail-observed stationary jumps.
|
||
// Gated on ACDREAM_AIRBORNE_DIAG=1 and !isOnGround. One line per
|
||
// resolve call. deltaXY = post - target tells us how much the sweep
|
||
// diverged from the requested target; for a clean stationary +Z
|
||
// jump we expect (0,0). cp=valid with a tilted normal would confirm
|
||
// H1 (initial-overlap depenetration → next-step AdjustOffset projects
|
||
// the +Z offset along a non-+Z normal). User repros at flat plaza /
|
||
// east hillside / north hillside; if drift direction tracks terrain
|
||
// orientation, H1 is the cause; if it tracks actor facing, H2 / H3.
|
||
if (!isOnGround
|
||
&& Environment.GetEnvironmentVariable("ACDREAM_AIRBORNE_DIAG") == "1")
|
||
{
|
||
var post = sp.CheckPos;
|
||
float dx = post.X - targetPos.X;
|
||
float dy = post.Y - targetPos.Y;
|
||
string cpInfo = ci.ContactPlaneValid
|
||
? $"valid cpN=({ci.ContactPlane.Normal.X:F3},{ci.ContactPlane.Normal.Y:F3},{ci.ContactPlane.Normal.Z:F3})"
|
||
: "none";
|
||
Console.WriteLine(
|
||
$"[SWEEP] airborne pre=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) " +
|
||
$"target=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) " +
|
||
$"post=({post.X:F3},{post.Y:F3},{post.Z:F3}) " +
|
||
$"cell={cellId:X8}->{sp.CheckCellId:X8} ok={ok} " +
|
||
$"deltaXY=({dx:F3},{dy:F3}) cp={cpInfo}");
|
||
}
|
||
|
||
if (ok)
|
||
{
|
||
bool onGround = ci.ContactPlaneValid
|
||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
||
|
||
return new ResolveResult(
|
||
sp.CheckPos,
|
||
ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
|
||
onGround,
|
||
collisionNormalValid,
|
||
collisionNormal);
|
||
}
|
||
|
||
// Transition failed (e.g., stuck in corner, too many steps).
|
||
// Use whatever position the transition reached (partial movement)
|
||
// instead of falling back to the no-collision Resolve.
|
||
// If CheckPos hasn't moved from CurPos, the player stays put —
|
||
// this is correct behavior when completely blocked.
|
||
bool partialOnGround = ci.ContactPlaneValid
|
||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|
||
|| isOnGround;
|
||
|
||
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
||
return new ResolveResult(
|
||
sp.CheckPos,
|
||
ResolveOutdoorCellId(sp.CheckPos, partialCellId),
|
||
partialOnGround,
|
||
collisionNormalValid,
|
||
collisionNormal);
|
||
}
|
||
}
|