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);
///
/// Top-level physics resolver that combines and
/// to resolve entity movement with step-height
/// enforcement and outdoor/indoor cell transitions.
///
///
/// Landblocks are registered via with their
/// terrain, indoor cells, and world-space offsets.
/// 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.
///
///
public sealed class PhysicsEngine
{
private readonly Dictionary _landblocks = new();
/// Number of registered landblocks (diagnostic).
public int LandblockCount => _landblocks.Count;
///
/// Cell-based spatial index for static object collision.
/// Populated during landblock streaming; queried by the Transition system.
///
public ShadowObjectRegistry ShadowObjects { get; } = new();
///
/// 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.
///
public PhysicsDataCache? DataCache { get; set; }
private sealed record LandblockPhysics(
TerrainSurface Terrain,
IReadOnlyList Cells,
IReadOnlyList Portals,
float WorldOffsetX,
float WorldOffsetY);
///
/// Register a landblock with its terrain surface, indoor cells, portal
/// planes, and world-space origin offset.
///
public void AddLandblock(uint landblockId, TerrainSurface terrain,
IReadOnlyList cells, IReadOnlyList portals,
float worldOffsetX, float worldOffsetY)
{
_landblocks[landblockId] = new LandblockPhysics(terrain, cells, portals, worldOffsetX, worldOffsetY);
}
///
/// Remove a previously registered landblock, including its shadow objects.
///
public void RemoveLandblock(uint landblockId)
{
_landblocks.Remove(landblockId);
ShadowObjects.RemoveLandblock(landblockId);
}
///
/// 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.
///
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;
}
///
/// 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.
///
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;
}
///
/// 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
/// ObjCell.get_water_depth. Used by
/// to visually submerge characters in water
/// without needing a separate water surface mesh.
///
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;
}
///
/// Sample the outdoor terrain plane (Z + sloped normal) at the given
/// world-space XY position. The returned
/// has the true terrain-triangle normal (NOT a flat (0,0,1)), and
/// its D is set so the plane passes through the sampled point. Used
/// by to build a CORRECT contact plane — a flat
/// plane breaks slope tracking because AdjustOffset's projection
/// onto a flat plane cannot impart the Z component that horizontal
/// velocity needs to follow the slope.
///
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;
}
///
/// Sample the outdoor terrain walkable triangle at the given world-space
/// XY position. This carries the same plane as
/// plus world-space triangle vertices for retail precipice-slide.
///
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;
}
///
/// 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.
///
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;
}
///
/// Resolve an entity's movement from by
/// applying (XY only) and computing the correct Z
/// from the terrain or indoor cell floor beneath the candidate position.
///
///
/// Step-height enforcement rejects horizontal movement when the upward Z
/// change exceeds . Downhill movement is
/// always accepted. Returns false
/// when no loaded landblock covers the candidate position.
///
///
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);
}
///
/// 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 if the transition fails.
///
///
/// 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 PhysicsObj.get_object_info → InitContactPlane at
/// PhysicsObj.cs:2598-2604). That seed is critical for slope
/// tracking: AdjustOffset 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.
///
///
///
/// On return, the plane discovered during this call is written BACK to
/// , 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 null and accept the first-frame
/// hiccup.
///
///
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);
}
}