diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs
index 304b396..c139ff2 100644
--- a/src/AcDream.App/Rendering/GameWindow.cs
+++ b/src/AcDream.App/Rendering/GameWindow.cs
@@ -1764,49 +1764,41 @@ public sealed class GameWindow : IDisposable
// 3. Fallback: 1.0m (conservative default for trees / small objects).
foreach (var entity in lb.Entities)
{
- float bestRadius = 0f;
- uint physicsGfxId = 0;
+ // Register EACH physics-enabled part so multi-part Setups
+ // (buildings, trees) have all their collision geometry registered.
+ // Each part gets its own ShadowEntry with its world-space transform.
+ var entityRoot =
+ System.Numerics.Matrix4x4.CreateFromQuaternion(entity.Rotation) *
+ System.Numerics.Matrix4x4.CreateTranslation(entity.Position);
- uint sourceId = entity.SourceGfxObjOrSetupId;
- if ((sourceId & 0xFF000000u) == 0x01000000u)
+ uint partIndex = 0;
+ foreach (var meshRef in entity.MeshRefs)
{
- // Direct GfxObj stab.
- var cached = _physicsDataCache.GetGfxObj(sourceId);
- if (cached?.BSP?.Root is not null)
- {
- physicsGfxId = sourceId;
- bestRadius = cached.BoundingSphere?.Radius ?? 1f;
- }
- }
- else if ((sourceId & 0xFF000000u) == 0x02000000u)
- {
- // Setup (multi-part building / creature proxy). Use the first
- // part that has a physics BSP; register using Setup.Radius for
- // the broad-phase sphere so the query covers the whole assembly.
- var setupCached = _physicsDataCache.GetSetup(sourceId);
- if (setupCached is not null && setupCached.Radius > 0f)
- bestRadius = setupCached.Radius;
+ var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId);
+ if (partCached?.BSP?.Root is null) { partIndex++; continue; }
- foreach (var meshRef in entity.MeshRefs)
- {
- var partCached = _physicsDataCache.GetGfxObj(meshRef.GfxObjId);
- if (partCached?.BSP?.Root is not null)
- {
- physicsGfxId = meshRef.GfxObjId;
- if (bestRadius <= 0f)
- bestRadius = partCached.BoundingSphere?.Radius ?? 1f;
- break; // register just the first physics part for MVP
- }
- }
- }
+ // Compute the part's world-space position from its transform.
+ var partWorld = meshRef.PartTransform * entityRoot;
+ var partPos = new System.Numerics.Vector3(partWorld.M41, partWorld.M42, partWorld.M43);
- if (physicsGfxId != 0)
- {
- float reg_radius = bestRadius > 0f ? bestRadius : 1f;
+ // Extract rotation from the world matrix.
+ System.Numerics.Quaternion partRot;
+ if (System.Numerics.Matrix4x4.Decompose(partWorld,
+ out _, out partRot, out _))
+ { /* decompose succeeded */ }
+ else
+ partRot = entity.Rotation;
+
+ float partRadius = partCached.BoundingSphere?.Radius ?? 1f;
+
+ // Use a unique sub-ID per part: entity.Id * 256 + partIndex.
+ uint partId = entity.Id * 256u + partIndex;
_physicsEngine.ShadowObjects.Register(
- entity.Id, physicsGfxId,
- entity.Position, reg_radius,
+ partId, meshRef.GfxObjId,
+ partPos, partRot, partRadius,
origin.X, origin.Y, lb.LandblockId);
+
+ partIndex++;
}
}
diff --git a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
index e75861f..7453c79 100644
--- a/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
+++ b/src/AcDream.Core/Physics/ShadowObjectRegistry.cs
@@ -20,14 +20,12 @@ public sealed class ShadowObjectRegistry
///
/// Register an entity into the cells it overlaps based on world position + radius.
///
- public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, float radius,
- float worldOffsetX, float worldOffsetY, uint landblockId)
+ public void Register(uint entityId, uint gfxObjId, Vector3 worldPos, Quaternion rotation,
+ float radius, float worldOffsetX, float worldOffsetY, uint landblockId)
{
// Deregister first if already registered (handles position updates)
Deregister(entityId);
- // Compute which cells the entity's bounding sphere overlaps.
- // Each cell is 24×24m within a 192m landblock.
float localX = worldPos.X - worldOffsetX;
float localY = worldPos.Y - worldOffsetY;
@@ -36,7 +34,7 @@ public sealed class ShadowObjectRegistry
int minCy = Math.Max(0, (int)((localY - radius) / 24f));
int maxCy = Math.Min(7, (int)((localY + radius) / 24f));
- var entry = new ShadowEntry(entityId, gfxObjId, worldPos, radius);
+ var entry = new ShadowEntry(entityId, gfxObjId, worldPos, rotation, radius);
var cellIds = new List();
uint lbPrefix = landblockId & 0xFFFF0000u;
@@ -148,4 +146,9 @@ public sealed class ShadowObjectRegistry
public int TotalRegistered => _entityToCells.Count;
}
-public readonly record struct ShadowEntry(uint EntityId, uint GfxObjId, Vector3 Position, float Radius);
+public readonly record struct ShadowEntry(
+ uint EntityId,
+ uint GfxObjId,
+ Vector3 Position,
+ Quaternion Rotation,
+ float Radius);
diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs
index 9bc07a3..19b1846 100644
--- a/src/AcDream.Core/Physics/TransitionTypes.cs
+++ b/src/AcDream.Core/Physics/TransitionTypes.cs
@@ -614,33 +614,30 @@ public sealed class Transition
///
/// Query the ShadowObjectRegistry for nearby static objects and run
- /// sphere-vs-BSP collision against each. On hit, sets the sliding normal
- /// and returns Slid so the caller redirects movement along the surface.
+ /// sphere-vs-BSP collision against each. On hit, calls SlideSphere to
+ /// compute a wall-slide offset and returns the result.
///
/// Object-local transform: the player sphere is mapped into each object's
/// local space via the inverse of (Rotation, Position) before the BSP query.
/// The hit normal is then rotated back to world space.
///
- /// Task 7 implementation.
+ /// Ported from pseudocode section 4 (ObjCell.FindObjCollisions) and
+ /// section 6 (SlideSphere).
///
private TransitionState FindObjCollisions(PhysicsEngine engine)
{
if (engine.DataCache is null) return TransitionState.OK;
var sp = SpherePath;
- var ci = CollisionInfo;
Vector3 footCenter = sp.GlobalSphere[0].Origin;
float sphereRadius = sp.GlobalSphere[0].Radius;
- // Find which landblock the player foot sphere is in.
if (!engine.TryGetLandblockContext(footCenter.X, footCenter.Y,
out uint landblockId, out float worldOffsetX, out float worldOffsetY))
return TransitionState.OK;
- // Query radius includes sphere radius plus a generous margin so we
- // don't miss objects whose BSP extends beyond their bounding sphere.
- float queryRadius = sphereRadius + 5f;
+ float queryRadius = sphereRadius + 10f;
engine.ShadowObjects.GetNearbyObjects(
footCenter, queryRadius,
worldOffsetX, worldOffsetY, landblockId,
@@ -648,59 +645,142 @@ public sealed class Transition
foreach (var obj in _nearbyObjs)
{
- // Broad-phase: skip if spheres can't possibly overlap.
+ // Broad-phase: sphere-sphere.
float dist = Vector3.Distance(footCenter, obj.Position);
- if (dist > sphereRadius + obj.Radius + CollisionPrimitives.Epsilon)
+ if (dist > sphereRadius + obj.Radius + 1f)
continue;
- // Narrow-phase: fetch cached BSP physics data for this GfxObj.
var physics = engine.DataCache.GetGfxObj(obj.GfxObjId);
if (physics?.BSP?.Root is null) continue;
- // Transform the player sphere center into object-local space.
- // The object transform is: worldPos = Rotation * localPos + objPosition.
- // For static objects stored in ShadowEntry we don't have a rotation
- // stored (they are stabs/buildings whose orientation varies per entity).
- // For the broad-phase pass here we treat them as axis-aligned
- // (rotation = Identity), which is conservative: it over-reports hits
- // but never misses them. Full per-part rotation requires storing
- // Quaternion in ShadowEntry — deferred to a follow-up task.
- Vector3 localSphereCenter = footCenter - obj.Position;
+ // Transform player sphere to object-local space using the
+ // object's world rotation and position.
+ var invRot = Quaternion.Inverse(obj.Rotation);
+ Vector3 localSphereCenter = Vector3.Transform(footCenter - obj.Position, invRot);
if (!BSPQuery.SphereIntersectsPoly(
physics.BSP.Root,
physics.PhysicsPolygons,
physics.Vertices,
localSphereCenter, sphereRadius,
- out _, out Vector3 hitNormal))
+ out _, out Vector3 localHitNormal))
continue;
- // Hit: set sliding normal (XY plane, no Z component) so the
- // player slides along the surface rather than tunnelling through.
- if (hitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
+ // Transform hit normal back to world space.
+ Vector3 worldHitNormal = Vector3.Transform(localHitNormal, obj.Rotation);
+
+ if (worldHitNormal.LengthSquared() > PhysicsGlobals.EpsilonSq)
{
- ci.SetSlidingNormal(hitNormal);
- ci.CollidedWithEnvironment = true;
- return TransitionState.Slid;
+ worldHitNormal = Vector3.Normalize(worldHitNormal);
+
+ // Use the retail SlideSphere algorithm to compute wall-slide.
+ // currPos = GlobalCurrCenter (where we came from this step).
+ Vector3 currPos = sp.GlobalCurrCenter[0].Origin;
+ return SlideSphere(worldHitNormal, currPos);
}
}
return TransitionState.OK;
}
+ // -----------------------------------------------------------------------
+ // SlideSphere — wall slide projection
+ // -----------------------------------------------------------------------
+
+ ///
+ /// Compute a wall-slide offset after a collision. Projects the sphere's
+ /// displacement along the crease between the collision normal and the
+ /// contact plane, producing smooth wall-sliding behavior.
+ ///
+ /// Ported from pseudocode section 6 (SlideSphere — environment collision
+ /// normal variant). ACE: Sphere.SlideSphere(Transition, ref Vector3, Vector3).
+ /// Decompiled: FUN_00538180.
+ ///
+ private TransitionState SlideSphere(Vector3 collisionNormal, Vector3 currPos)
+ {
+ var sp = SpherePath;
+ var ci = CollisionInfo;
+
+ // Degenerate case: zero collision normal — nudge halfway.
+ if (collisionNormal.LengthSquared() < PhysicsGlobals.EpsilonSq)
+ {
+ Vector3 halfOffset = (currPos - sp.GlobalSphere[0].Origin) * 0.5f;
+ sp.AddOffsetToCheckPos(halfOffset);
+ return TransitionState.Adjusted;
+ }
+
+ ci.SetCollisionNormal(collisionNormal);
+
+ // gDelta: displacement from currPos to the current check sphere center.
+ // In the retail code this includes a block offset for cross-landblock
+ // transitions; for outdoor single-landblock movement this is zero.
+ Vector3 gDelta = sp.GlobalSphere[0].Origin - currPos;
+
+ // Get the contact plane (prefer current, fall back to last known).
+ System.Numerics.Plane contactPlane;
+ if (ci.ContactPlaneValid)
+ contactPlane = ci.ContactPlane;
+ else if (ci.LastKnownContactPlaneValid)
+ contactPlane = ci.LastKnownContactPlane;
+ else
+ contactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f);
+
+ // Crease direction = cross(collisionNormal, contactPlane.Normal).
+ Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal);
+ float dirLenSq = direction.LengthSquared();
+
+ if (dirLenSq >= PhysicsGlobals.EpsilonSq)
+ {
+ // Crease exists: project displacement onto it.
+ float diff = Vector3.Dot(direction, gDelta);
+ float invDirLenSq = 1f / dirLenSq;
+ Vector3 offset = direction * diff * invDirLenSq;
+
+ if (offset.LengthSquared() < PhysicsGlobals.EpsilonSq)
+ return TransitionState.Collided;
+
+ // Subtract current displacement to get the correction vector.
+ offset -= gDelta;
+ sp.AddOffsetToCheckPos(offset);
+ return TransitionState.Slid;
+ }
+
+ // Collision normal and contact plane are parallel.
+ if (Vector3.Dot(collisionNormal, contactPlane.Normal) >= 0f)
+ {
+ // Same direction: project out along collision normal.
+ float diff = Vector3.Dot(collisionNormal, gDelta);
+ Vector3 offset = -collisionNormal * diff;
+ sp.AddOffsetToCheckPos(offset);
+ return TransitionState.Slid;
+ }
+
+ // Opposing normals: give up, reverse direction.
+ // Retail returns OK here to allow retry with the reversed normal.
+ Vector3 reversed = -gDelta;
+ if (reversed.LengthSquared() > PhysicsGlobals.EpsilonSq)
+ {
+ reversed = Vector3.Normalize(reversed);
+ ci.SetCollisionNormal(reversed);
+ }
+ return TransitionState.OK;
+ }
+
// -----------------------------------------------------------------------
// Offset adjustment (contact-plane + slide-plane projection)
// -----------------------------------------------------------------------
///
/// Project the per-step movement offset to avoid pushing into the contact
- /// surface or slide plane.
+ /// surface or slide plane. Also performs a safety check to keep the sphere
+ /// above the contact plane.
///
/// Ported from pseudocode section 6 (AdjustOffset).
/// ACE: Transition.AdjustOffset(Vector3 offset).
///
private Vector3 AdjustOffset(Vector3 offset)
{
+ var sp = SpherePath;
var ci = CollisionInfo;
Vector3 result = offset;
@@ -749,7 +829,30 @@ public sealed class Transition
{
// Moving away from contact plane: snap to plane surface.
// SnapToPlane: remove any component that would violate the plane.
- result -= ci.ContactPlane.Normal * (collisionAngle - 0f);
+ result -= ci.ContactPlane.Normal * collisionAngle;
+ }
+
+ // Safety check: ensure the sphere stays above the contact plane.
+ // Ported from pseudocode section 6 (AdjustOffset safety block).
+ if (ci.ContactPlaneCellId != 0 && !ci.ContactPlaneIsWater)
+ {
+ Vector3 globCenter = sp.GlobalSphere[0].Origin;
+ float radius = sp.GlobalSphere[0].Radius;
+
+ // Signed distance from sphere center to contact plane.
+ // For outdoor terrain within the same landblock, block offset is zero.
+ float dist = Vector3.Dot(globCenter, ci.ContactPlane.Normal)
+ + ci.ContactPlane.D;
+
+ if (dist < radius - PhysicsGlobals.EPSILON)
+ {
+ // Sphere is below the contact plane — push it up.
+ float zDist = (radius - dist) / ci.ContactPlane.Normal.Z;
+ if (radius > MathF.Abs(zDist))
+ {
+ sp.AddOffsetToCheckPos(new Vector3(0f, 0f, zDist));
+ }
+ }
}
return result;
diff --git a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
index af869c1..2f2b463 100644
--- a/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
+++ b/tests/AcDream.Core.Tests/Physics/ShadowObjectRegistryTests.cs
@@ -23,7 +23,7 @@ public class ShadowObjectRegistryTests
public void Register_SingleEntity_TotalRegisteredIsOne()
{
var reg = new ShadowObjectRegistry();
- reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
+ reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
Assert.Equal(1, reg.TotalRegistered);
}
@@ -31,8 +31,8 @@ public class ShadowObjectRegistryTests
public void Register_SameEntityTwice_NoDuplicate()
{
var reg = new ShadowObjectRegistry();
- reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
- reg.Register(1u, 0x01000001u, new Vector3(13f, 12f, 50f), 1f, OffX, OffY, LbId); // re-register (position update)
+ reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
+ reg.Register(1u, 0x01000001u, new Vector3(13f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId); // re-register (position update)
Assert.Equal(1, reg.TotalRegistered);
}
@@ -45,7 +45,7 @@ public class ShadowObjectRegistryTests
{
// Entity at local (12, 12) = cell (0,0) = cellId prefix | 1.
var reg = new ShadowObjectRegistry();
- reg.Register(42u, 0x01000002u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
+ reg.Register(42u, 0x01000002u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
uint cellId = LbId | 1u; // cx=0, cy=0 → 0*8+0+1 = 1
var objs = reg.GetObjectsInCell(cellId);
@@ -59,7 +59,7 @@ public class ShadowObjectRegistryTests
// Entity at local (24, 12) with radius=2 spans cells cx=0 and cx=1 in X.
// Cell 0,0 = prefix|1; cell 1,0 = prefix|(1*8+0+1)=prefix|9.
var reg = new ShadowObjectRegistry();
- reg.Register(7u, 0x01000003u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId);
+ reg.Register(7u, 0x01000003u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId);
uint cell00 = LbId | 1u; // cx=0, cy=0
uint cell10 = LbId | 9u; // cx=1, cy=0
@@ -76,7 +76,7 @@ public class ShadowObjectRegistryTests
public void Deregister_RemovesFromAllCells()
{
var reg = new ShadowObjectRegistry();
- reg.Register(5u, 0x01000004u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId);
+ reg.Register(5u, 0x01000004u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId);
reg.Deregister(5u);
Assert.Equal(0, reg.TotalRegistered);
@@ -101,10 +101,10 @@ public class ShadowObjectRegistryTests
{
const uint otherLb = 0xAAAA0000u;
var reg = new ShadowObjectRegistry();
- reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
+ reg.Register(1u, 0x01000001u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
// Entity 2 lives in otherLb whose world origin is at X=192. Place it at
// world X=204 so localX = 204-192 = 12, which maps to cell (0,0) of otherLb.
- reg.Register(2u, 0x01000002u, new Vector3(204f, 12f, 50f), 1f, 192f, 0f, otherLb);
+ reg.Register(2u, 0x01000002u, new Vector3(204f, 12f, 50f), Quaternion.Identity, 1f, 192f, 0f, otherLb);
reg.RemoveLandblock(LbId);
@@ -120,7 +120,7 @@ public class ShadowObjectRegistryTests
public void GetNearbyObjects_QueryCoversEntity_ReturnsIt()
{
var reg = new ShadowObjectRegistry();
- reg.Register(10u, 0x01000005u, new Vector3(30f, 30f, 50f), 1f, OffX, OffY, LbId);
+ reg.Register(10u, 0x01000005u, new Vector3(30f, 30f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
var results = new List();
reg.GetNearbyObjects(new Vector3(30f, 30f, 50f), 5f, OffX, OffY, LbId, results);
@@ -134,7 +134,7 @@ public class ShadowObjectRegistryTests
{
var reg = new ShadowObjectRegistry();
// Entity at local (12, 12) — cell (0,0).
- reg.Register(11u, 0x01000006u, new Vector3(12f, 12f, 50f), 1f, OffX, OffY, LbId);
+ reg.Register(11u, 0x01000006u, new Vector3(12f, 12f, 50f), Quaternion.Identity, 1f, OffX, OffY, LbId);
var results = new List();
// Query at local (180, 180) — cell (7,7) — far away.
@@ -148,7 +148,7 @@ public class ShadowObjectRegistryTests
{
// Entity spans cells 0,0 and 1,0 (local X≈24, radius=2).
var reg = new ShadowObjectRegistry();
- reg.Register(20u, 0x01000007u, new Vector3(24f, 12f, 50f), 2f, OffX, OffY, LbId);
+ reg.Register(20u, 0x01000007u, new Vector3(24f, 12f, 50f), Quaternion.Identity, 2f, OffX, OffY, LbId);
var results = new List();
// Large query covers both cells; entity must appear exactly once.
@@ -171,7 +171,7 @@ public class ShadowObjectRegistryTests
{
var reg = new ShadowObjectRegistry();
// Small radius so entity sits in exactly one cell.
- reg.Register(99u, 0x01000008u, new Vector3(lx + 0.5f, ly + 0.5f, 50f), 0.1f, OffX, OffY, LbId);
+ reg.Register(99u, 0x01000008u, new Vector3(lx + 0.5f, ly + 0.5f, 50f), Quaternion.Identity, 0.1f, OffX, OffY, LbId);
uint cellId = LbId | expectedLow;
var objs = reg.GetObjectsInCell(cellId);