diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 7db0c83..a7b834f 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1394,6 +1394,9 @@ public sealed class GameWindow : IDisposable
// Step 4: build LoadedCell for portal visibility.
BuildLoadedCell(envCellId, envCell, cellStruct, cellOrigin, cellTransform);
+
+ // Cache CellStruct physics BSP for indoor collision.
+ _physicsDataCache.CacheCellStruct(envCellId, cellStruct, cellTransform);
}
}
}
diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs
index 97f3419..fd8d8cc 100644
--- a/src/AcDream.Core/Physics/BSPQuery.cs
+++ b/src/AcDream.Core/Physics/BSPQuery.cs
@@ -1142,4 +1142,164 @@ public static class BSPQuery
return SphereIntersectsPoly(node.NegNode, polygons, vertices,
sphereCenter, sphereRadius, movement, out hitPolyId, out hitNormal);
}
+
+ // -----------------------------------------------------------------------
+ // 14. SphereIntersectsPolyWithTime — swept-sphere BSP query using
+ // FindTimeOfCollision for exact parametric contact time.
+ // Fix 4: replaces static overlap + ad-hoc t computation.
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Movement-aware sphere-BSP intersection that uses
+ /// to compute the
+ /// exact parametric time of first contact. Returns the earliest collision
+ /// across all polygons in the BSP tree.
+ ///
+ ///
+ /// Unlike which
+ /// tests static overlap at start and end positions, this method finds the
+ /// precise contact time via swept-sphere analysis.
+ ///
+ ///
+ public static bool SphereIntersectsPolyWithTime(
+ PhysicsBSPNode? node,
+ Dictionary polygons,
+ VertexArray vertices,
+ Vector3 sphereCenter,
+ float sphereRadius,
+ Vector3 movement,
+ out ushort hitPolyId,
+ out Vector3 hitNormal,
+ out float hitTime)
+ {
+ hitPolyId = 0;
+ hitNormal = Vector3.Zero;
+ hitTime = float.MaxValue;
+
+ if (node is null) return false;
+
+ SphereIntersectsPolyWithTimeRecurse(
+ node, polygons, vertices,
+ sphereCenter, sphereRadius, movement,
+ ref hitPolyId, ref hitNormal, ref hitTime);
+
+ return hitTime < float.MaxValue;
+ }
+
+ private static void SphereIntersectsPolyWithTimeRecurse(
+ PhysicsBSPNode? node,
+ Dictionary polygons,
+ VertexArray vertices,
+ Vector3 sphereCenter,
+ float sphereRadius,
+ Vector3 movement,
+ ref ushort hitPolyId,
+ ref Vector3 hitNormal,
+ ref float bestTime)
+ {
+ if (node is null) return;
+
+ // Broad phase: bounding sphere + movement extent
+ float dist = Vector3.Distance(sphereCenter, node.BoundingSphere.Origin);
+ if (dist > sphereRadius + node.BoundingSphere.Radius + movement.Length() + 0.1f)
+ return;
+
+ // Leaf node: test each polygon with FindTimeOfCollision
+ if (node.Type == BSPNodeType.Leaf)
+ {
+ foreach (var polyIdx in node.Polygons)
+ {
+ if (!polygons.TryGetValue(polyIdx, out var poly)) continue;
+ if (!TryGetPolyPlane(poly, vertices, out var polyPlane, out var polyVerts))
+ continue;
+
+ // Front-face culling: only collide if moving toward this face.
+ if (Vector3.Dot(movement, polyPlane.Normal) >= 0f)
+ continue;
+
+ // Use FindTimeOfCollision for exact parametric contact time.
+ if (CollisionPrimitives.FindTimeOfCollision(
+ polyPlane, polyVerts,
+ sphereCenter, sphereRadius,
+ movement, out float t))
+ {
+ // FindTimeOfCollision returns t such that contact = origin - movement*t.
+ // For our purposes, a positive t means the sphere reaches the polygon
+ // when travelling along 'movement'. We want the absolute value as
+ // our parametric time (0=start, 1=end of movement).
+ float absT = MathF.Abs(t);
+ if (absT < bestTime)
+ {
+ bestTime = absT;
+ hitPolyId = polyIdx;
+ hitNormal = polyPlane.Normal;
+ }
+ }
+ else
+ {
+ // Fallback: static overlap test at start and end positions.
+ if (CollisionPrimitives.SphereIntersectsPoly(
+ polyPlane, polyVerts, sphereCenter, sphereRadius, out _))
+ {
+ if (0f < bestTime)
+ {
+ bestTime = 0f;
+ hitPolyId = polyIdx;
+ hitNormal = polyPlane.Normal;
+ }
+ }
+ else
+ {
+ Vector3 endCenter = sphereCenter + movement;
+ if (CollisionPrimitives.SphereIntersectsPoly(
+ polyPlane, polyVerts, endCenter, sphereRadius, out _))
+ {
+ if (1f < bestTime)
+ {
+ bestTime = 1f;
+ hitPolyId = polyIdx;
+ hitNormal = polyPlane.Normal;
+ }
+ }
+ }
+ }
+ }
+ return;
+ }
+
+ // Internal node: classify against splitting plane
+ float splitDist = Vector3.Dot(node.SplittingPlane.Normal, sphereCenter)
+ + node.SplittingPlane.D;
+ float reach = sphereRadius + movement.Length();
+
+ if (splitDist >= reach)
+ {
+ SphereIntersectsPolyWithTimeRecurse(
+ node.PosNode, polygons, vertices,
+ sphereCenter, sphereRadius, movement,
+ ref hitPolyId, ref hitNormal, ref bestTime);
+ return;
+ }
+
+ if (splitDist <= -reach)
+ {
+ SphereIntersectsPolyWithTimeRecurse(
+ node.NegNode, polygons, vertices,
+ sphereCenter, sphereRadius, movement,
+ ref hitPolyId, ref hitNormal, ref bestTime);
+ return;
+ }
+
+ // Straddles: check both sides to find the earliest collision.
+ SphereIntersectsPolyWithTimeRecurse(
+ node.PosNode, polygons, vertices,
+ sphereCenter, sphereRadius, movement,
+ ref hitPolyId, ref hitNormal, ref bestTime);
+
+ SphereIntersectsPolyWithTimeRecurse(
+ node.NegNode, polygons, vertices,
+ sphereCenter, sphereRadius, movement,
+ ref hitPolyId, ref hitNormal, ref bestTime);
+ }
}
diff --git a/src/AcDream.Core/Physics/PhysicsDataCache.cs b/src/AcDream.Core/Physics/PhysicsDataCache.cs
index 7304a77..688bba7 100644
--- a/src/AcDream.Core/Physics/PhysicsDataCache.cs
+++ b/src/AcDream.Core/Physics/PhysicsDataCache.cs
@@ -1,4 +1,5 @@
using System.Collections.Concurrent;
+using System.Numerics;
using DatReaderWriter.DBObjs;
using DatReaderWriter.Enums;
using DatReaderWriter.Types;
@@ -15,6 +16,7 @@ public sealed class PhysicsDataCache
{
private readonly ConcurrentDictionary _gfxObj = new();
private readonly ConcurrentDictionary _setup = new();
+ private readonly ConcurrentDictionary _cellStruct = new();
///
/// Extract and cache the physics BSP + polygon data from a GfxObj.
@@ -53,10 +55,35 @@ public sealed class PhysicsDataCache
};
}
+ ///
+ /// Extract and cache the physics BSP + polygon data from a CellStruct
+ /// (indoor room geometry). No-ops if the id is already cached or the
+ /// CellStruct has no physics BSP.
+ ///
+ public void CacheCellStruct(uint envCellId, CellStruct cellStruct,
+ Matrix4x4 worldTransform)
+ {
+ if (_cellStruct.ContainsKey(envCellId)) return;
+ if (cellStruct.PhysicsBSP?.Root is null) return;
+
+ Matrix4x4.Invert(worldTransform, out var inverseTransform);
+
+ _cellStruct[envCellId] = new CellPhysics
+ {
+ BSP = cellStruct.PhysicsBSP,
+ PhysicsPolygons = cellStruct.PhysicsPolygons,
+ Vertices = cellStruct.VertexArray,
+ WorldTransform = worldTransform,
+ InverseWorldTransform = inverseTransform,
+ };
+ }
+
public GfxObjPhysics? GetGfxObj(uint id) => _gfxObj.TryGetValue(id, out var p) ? p : null;
public SetupPhysics? GetSetup(uint id) => _setup.TryGetValue(id, out var p) ? p : null;
+ public CellPhysics? GetCellStruct(uint id) => _cellStruct.TryGetValue(id, out var p) ? p : null;
public int GfxObjCount => _gfxObj.Count;
public int SetupCount => _setup.Count;
+ public int CellStructCount => _cellStruct.Count;
}
/// Cached physics data for a single GfxObj part.
@@ -78,3 +105,17 @@ public sealed class SetupPhysics
public float StepUpHeight { get; init; }
public float StepDownHeight { get; init; }
}
+
+///
+/// Cached physics data for an indoor cell's room geometry (CellStruct).
+/// Used for wall/floor/ceiling collision in EnvCells.
+/// ACE: EnvCell.find_env_collisions queries CellStructure.PhysicsBSP.
+///
+public sealed class CellPhysics
+{
+ public required PhysicsBSPTree BSP { get; init; }
+ public required Dictionary PhysicsPolygons { get; init; }
+ public required VertexArray Vertices { get; init; }
+ public Matrix4x4 WorldTransform { get; init; }
+ public Matrix4x4 InverseWorldTransform { get; init; }
+}
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 1774313..16f7fcf 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -511,18 +511,69 @@ public sealed class Transition
var sp = SpherePath;
var ci = CollisionInfo;
- // Sample terrain Z at the foot sphere's world position.
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
+ // ── Indoor cell BSP collision ────────────────────────────────────
+ // If the player is in an indoor cell (low 16 bits >= 0x0100),
+ // query the CellStruct's PhysicsBSP for wall/floor/ceiling collision.
+ // ACE: EnvCell.find_env_collisions -> CellStructure.PhysicsBSP.find_collisions
+ uint cellLow = sp.CheckCellId & 0xFFFFu;
+ if (cellLow >= 0x0100 && engine.DataCache is not null)
+ {
+ var cellPhysics = engine.DataCache.GetCellStruct(sp.CheckCellId);
+ if (cellPhysics?.BSP?.Root is not null)
+ {
+ // Transform player sphere to cell-local space.
+ var localCenter = Vector3.Transform(footCenter, cellPhysics.InverseWorldTransform);
+ var localCurrCenter = Vector3.Transform(sp.GlobalCurrCenter[0].Origin, cellPhysics.InverseWorldTransform);
+
+ var localSphere = new DatReaderWriter.Types.Sphere
+ {
+ Origin = localCenter,
+ Radius = sphereRadius,
+ };
+
+ // Second sphere (head) in local space, if present.
+ DatReaderWriter.Types.Sphere? localSphere1 = null;
+ if (sp.NumSphere > 1)
+ {
+ var headCenter = sp.GlobalSphere[1].Origin;
+ localSphere1 = new DatReaderWriter.Types.Sphere
+ {
+ Origin = Vector3.Transform(headCenter, cellPhysics.InverseWorldTransform),
+ Radius = sp.GlobalSphere[1].Radius,
+ };
+ }
+
+ // Use the full 6-path BSP dispatcher for retail-faithful collision.
+ var cellState = BSPQuery.FindCollisions(
+ cellPhysics.BSP.Root,
+ cellPhysics.PhysicsPolygons,
+ cellPhysics.Vertices,
+ this,
+ localSphere,
+ localSphere1,
+ localCurrCenter,
+ Vector3.UnitZ, // local space Z is up
+ 1.0f); // scale = 1.0 for cell geometry
+
+ if (cellState != TransitionState.OK)
+ {
+ if (!ObjectInfo.State.HasFlag(ObjectInfoState.Contact))
+ ci.CollidedWithEnvironment = true;
+ return cellState;
+ }
+ }
+ }
+
+ // ── Outdoor terrain collision ────────────────────────────────────
+ // Sample terrain Z at the foot sphere's world position.
float? terrainZ = engine.SampleTerrainZ(footCenter.X, footCenter.Y);
if (terrainZ is null)
return TransitionState.OK; // no terrain loaded here — allow pass-through
// Build the terrain contact plane (flat ground: Normal = +Z, D = -terrainZ).
- // For sloped terrain we'd need the surface normal from the triangle; for MVP
- // we use the vertical plane which matches flat terrain exactly and gives
- // conservative results on slopes (terrain Z is already interpolated correctly).
var contactPlane = new System.Numerics.Plane(
new Vector3(0f, 0f, 1f), -terrainZ.Value);
@@ -646,113 +697,108 @@ public sealed class Transition
worldOffsetX, worldOffsetY, landblockId,
_nearbyObjs);
-
// Find the EARLIEST collision along the movement path.
+ // Test both foot sphere (index 0) and head sphere (index 1) if present.
float bestT = float.MaxValue;
Vector3 bestNormal = Vector3.Zero;
+ bool bestIsHeadSphere = false;
- foreach (var obj in _nearbyObjs)
+ for (int sphereIdx = 0; sphereIdx < sp.NumSphere; sphereIdx++)
{
- // Broad-phase: can the moving sphere reach this object?
- // Use horizontal distance for cylinders (Z extent is checked separately).
- Vector3 deltaToCurr = currPos - obj.Position;
- float distToCurr;
- if (obj.CollisionType == ShadowCollisionType.Cylinder)
- distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y);
- else
- distToCurr = deltaToCurr.Length();
- float maxReach = sphereRadius + obj.Radius + movement.Length() + 2f;
- if (distToCurr > maxReach)
- continue;
+ Vector3 sphereCheckPos = sp.GlobalSphere[sphereIdx].Origin;
+ Vector3 sphereCurrPos = sp.GlobalCurrCenter[sphereIdx].Origin;
+ float sphRadius = sp.GlobalSphere[sphereIdx].Radius;
+ Vector3 sphMovement = sphereCheckPos - sphereCurrPos;
- float t;
- Vector3 worldHitNormal;
-
- if (obj.CollisionType == ShadowCollisionType.BSP)
+ foreach (var obj in _nearbyObjs)
{
- var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
- if (physics?.BSP?.Root is null) continue;
-
- // Transform to object-local space.
- var invRot = Quaternion.Inverse(obj.Rotation);
- Vector3 localCurrPos = Vector3.Transform(currPos - obj.Position, invRot);
- Vector3 localMovement = Vector3.Transform(movement, invRot);
-
- // Use movement-aware BSP query with front-face culling.
- if (!BSPQuery.SphereIntersectsPoly(
- physics.BSP.Root,
- physics.PhysicsPolygons,
- physics.Vertices,
- localCurrPos, sphereRadius,
- localMovement,
- out _, out Vector3 localHitNormal))
+ // Broad-phase: can the moving sphere reach this object?
+ Vector3 deltaToCurr = sphereCurrPos - obj.Position;
+ float distToCurr;
+ if (obj.CollisionType == ShadowCollisionType.Cylinder)
+ distToCurr = MathF.Sqrt(deltaToCurr.X * deltaToCurr.X + deltaToCurr.Y * deltaToCurr.Y);
+ else
+ distToCurr = deltaToCurr.Length();
+ float maxReach = sphRadius + obj.Radius + sphMovement.Length() + 2f;
+ if (distToCurr > maxReach)
continue;
- worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation);
+ float t;
+ Vector3 worldHitNormal;
- // Compute parametric contact time: how far along the movement
- // does the sphere first touch this polygon?
- // Project the center-to-plane distance onto the movement direction.
- float planeDist = Vector3.Dot(localHitNormal, localCurrPos) -
- Vector3.Dot(localHitNormal, Vector3.Zero); // plane through origin in local
- float approach = -Vector3.Dot(localHitNormal, localMovement);
- if (approach > PhysicsGlobals.EPSILON)
- t = (planeDist - sphereRadius) / approach;
- else
- t = 0f; // already touching or parallel
- t = Math.Clamp(t, 0f, 1f);
- }
- else
- {
- // Cylinder swept-sphere test.
- // Find parametric time when moving sphere first contacts the cylinder.
- Vector3 deltaCurr = currPos - obj.Position;
- float dx = deltaCurr.X, dy = deltaCurr.Y;
- float mx = movement.X, my = movement.Y;
- float combinedR = sphereRadius + obj.Radius;
-
- // Quadratic: |curr_xy + t*move_xy|^2 = combinedR^2
- float a = mx * mx + my * my;
- float b = 2f * (dx * mx + dy * my);
- float c = dx * dx + dy * dy - combinedR * combinedR;
-
- if (a < PhysicsGlobals.EPSILON)
+ if (obj.CollisionType == ShadowCollisionType.BSP)
{
- // Not moving horizontally — check static overlap.
- if (c > 0f) continue;
- t = 0f;
+ var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
+ if (physics?.BSP?.Root is null) continue;
+
+ // Transform to object-local space.
+ var invRot = Quaternion.Inverse(obj.Rotation);
+ Vector3 localCurrPos = Vector3.Transform(sphereCurrPos - obj.Position, invRot);
+ Vector3 localMovement = Vector3.Transform(sphMovement, invRot);
+
+ // Use movement-aware BSP query with front-face culling.
+ if (!BSPQuery.SphereIntersectsPolyWithTime(
+ physics.BSP.Root,
+ physics.PhysicsPolygons,
+ physics.Vertices,
+ localCurrPos, sphRadius,
+ localMovement,
+ out _, out Vector3 localHitNormal, out t))
+ continue;
+
+ worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation);
+ t = Math.Clamp(t, 0f, 1f);
}
else
{
- float disc = b * b - 4f * a * c;
- if (disc < 0f) continue; // no intersection
- float sqrtDisc = MathF.Sqrt(disc);
- t = (-b - sqrtDisc) / (2f * a); // first contact time
- if (t > 1f) continue; // contact is past this step
- if (t < 0f) t = 0f; // already overlapping
+ // Cylinder swept-sphere test.
+ Vector3 deltaCurr = sphereCurrPos - obj.Position;
+ float dx = deltaCurr.X, dy = deltaCurr.Y;
+ float mx = sphMovement.X, my = sphMovement.Y;
+ float combinedR = sphRadius + obj.Radius;
+
+ float a = mx * mx + my * my;
+ float b = 2f * (dx * mx + dy * my);
+ float c = dx * dx + dy * dy - combinedR * combinedR;
+
+ if (a < PhysicsGlobals.EPSILON)
+ {
+ if (c > 0f) continue;
+ t = 0f;
+ }
+ else
+ {
+ float disc = b * b - 4f * a * c;
+ if (disc < 0f) continue;
+ float sqrtDisc = MathF.Sqrt(disc);
+ t = (-b - sqrtDisc) / (2f * a);
+ if (t > 1f) continue;
+ if (t < 0f) t = 0f;
+ }
+
+ // Vertical check at contact time.
+ Vector3 contactPos = sphereCurrPos + sphMovement * t;
+ float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
+ float playerBottom = contactPos.Z - sphRadius;
+ float playerTop = contactPos.Z + sphRadius;
+ if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z)
+ continue;
+
+ // Normal: radial at contact point.
+ Vector3 contactDelta = contactPos - obj.Position;
+ float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y);
+ if (hDist < PhysicsGlobals.EPSILON)
+ worldHitNormal = Vector3.UnitX;
+ else
+ worldHitNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f));
}
- // Vertical check at contact time.
- Vector3 contactPos = currPos + movement * t;
- float cylTop = obj.CylHeight > 0 ? obj.CylHeight : obj.Radius * 4f;
- float playerBottom = contactPos.Z - sphereRadius;
- float playerTop = contactPos.Z + sphereRadius;
- if (playerBottom > obj.Position.Z + cylTop || playerTop < obj.Position.Z)
- continue;
-
- // Normal: radial at contact point.
- Vector3 contactDelta = contactPos - obj.Position;
- float hDist = MathF.Sqrt(contactDelta.X * contactDelta.X + contactDelta.Y * contactDelta.Y);
- if (hDist < PhysicsGlobals.EPSILON)
- worldHitNormal = Vector3.UnitX;
- else
- worldHitNormal = Vector3.Normalize(new Vector3(contactDelta.X, contactDelta.Y, 0f));
- }
-
- if (t < bestT && worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
- {
- bestT = t;
- bestNormal = Vector3.Normalize(worldHitNormal);
+ if (t < bestT && worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
+ {
+ bestT = t;
+ bestNormal = Vector3.Normalize(worldHitNormal);
+ bestIsHeadSphere = (sphereIdx == 1);
+ }
}
}
@@ -761,10 +807,24 @@ public sealed class Transition
return TransitionState.OK;
}
+ // ── Fix 3: Contact-path step-up attempt ─────────────────────────
+ // When in contact with ground and hitting a low obstacle (not the head
+ // sphere), try stepping up before falling back to slide.
+ // ACE: BSPTree.find_collisions path 5 — Contact|OnWalkable → step_sphere_up.
+ if (!bestIsHeadSphere
+ && ObjectInfo.Contact
+ && bestNormal.Z > PhysicsGlobals.EPSILON
+ && bestNormal.Z < PhysicsGlobals.FloorZ)
+ {
+ // The surface is angled (not a vertical wall, not a floor) —
+ // attempt step-up. Set the flag for the transition system.
+ sp.StepUp = true;
+ sp.StepUpNormal = bestNormal;
+ ci.SetCollisionNormal(bestNormal);
+ return TransitionState.OK;
+ }
+
// Already overlapping at the START of the step (bestT == 0 or very small).
- // This happens when the player spawns inside an object or a previous
- // step left them penetrating. Push out along the collision normal
- // instead of sliding — sliding with zero displacement gets stuck.
if (bestT <= PhysicsGlobals.EPSILON)
{
Vector3 pushOut = bestNormal * (sphereRadius * 0.5f + 0.01f);
@@ -775,12 +835,10 @@ public sealed class Transition
}
// Rewind the sphere to just BEFORE the contact point.
- // Use t slightly before bestT to ensure no penetration.
if (bestT < 1f)
{
float safeT = MathF.Max(0f, bestT - 0.02f);
Vector3 contactPos = currPos + movement * safeT;
- // Additional push along normal to clear the surface.
contactPos += bestNormal * 0.02f;
sp.SetCheckPos(contactPos, sp.CheckCellId);
}