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; } /// /// Public surface for callers that only need the local terrain plane /// normal at a world-space XY (e.g., the grounded-remote tick path /// projecting anim root motion onto the slope to avoid the staircase /// between server position updates). Returns null when no registered /// landblock covers the point. Mirrors the plane component of /// without exposing the internal /// TerrainWalkableSample shape. /// public Vector3? SampleTerrainNormal(float worldX, float worldY) { var sample = SampleTerrainWalkable(worldX, worldY); return sample?.Plane.Normal; } /// /// 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}"); } // L.2a slice 1 (2026-05-12): general-purpose resolver probe. // One line per call when PhysicsDiagnostics.ProbeResolveEnabled // is set (env var ACDREAM_PROBE_RESOLVE=1 at startup, or the // DebugPanel checkbox flipped at runtime). Captures every // dimension L.2 cares about: input/output position, input/output // cell, ok-vs-partial, grounded-in vs contact-out, contact-plane // status, wall normal if hit, walkable polygon valid. Zero cost // when off (one static-bool read). if (PhysicsDiagnostics.ProbeResolveEnabled) { var probePost = sp.CheckPos; string probeCp = ci.ContactPlaneValid ? "valid" : (ci.LastKnownContactPlaneValid ? "lastKnown" : "none"); string probeHit; if (collisionNormalValid) { // L.2a slice 2 (2026-05-12): include the hit object's guid + // environment flag so we can tell whether the wall is a building // (CBuildingObj), a door (CC0Cxxxx range), an NPC, or terrain. // Without this we know the wall normal but not the responsible // entity — half the L.2d sub-direction call. string objPart = ci.LastCollidedObjectGuid.HasValue ? System.FormattableString.Invariant( $" obj=0x{ci.LastCollidedObjectGuid.Value:X8}") : ""; string envPart = ci.CollidedWithEnvironment ? " env" : ""; int objCount = ci.CollideObjectGuids.Count; string objCountPart = objCount > 1 ? System.FormattableString.Invariant($" nObj={objCount}") : ""; probeHit = System.FormattableString.Invariant( $"yes n=({collisionNormal.X:F2},{collisionNormal.Y:F2},{collisionNormal.Z:F2}){objPart}{envPart}{objCountPart}"); } else { probeHit = "no"; } Console.WriteLine(System.FormattableString.Invariant( $"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}")); } 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); } }