diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs
index 35daf3f..64f9f5c 100644
--- a/src/AcDream.App/Input/PlayerMovementController.cs
+++ b/src/AcDream.App/Input/PlayerMovementController.cs
@@ -444,6 +444,93 @@ public sealed class PlayerMovementController
// Apply resolved position.
_body.Position = resolveResult.Position;
+ // L.3a (2026-04-30): retail wall-bounce / velocity reflection.
+ //
+ // Retail's CPhysicsObj::handle_all_collisions runs after every
+ // SetPositionInternal. It reads the wall normal that the
+ // transition's slide computed and reflects the body's velocity:
+ //
+ // v_new = v - (1 + elasticity) * dot(v, n) * n
+ //
+ // This is what gives retail its "bouncy" feel — fast head-on
+ // jumps push the player back from the wall, glancing angles
+ // produce a small deflection. acdream's transition resolver
+ // SLID position correctly but never updated velocity, so the
+ // player kept driving into walls until the controller's input
+ // changed direction. Felt sticky / fragile.
+ //
+ // Suppression rule (apply_bounce): grounded movement on a wall
+ // SHOULDN'T bounce — sliding along a corridor is expected. Only
+ // airborne wall hits reflect. Mirrors retail's `var_10_1` guard
+ // and ACE PhysicsObj.cs:2656-2660 `apply_bounce`.
+ //
+ // Inelastic flag (spell projectiles, missiles) zeros velocity
+ // entirely instead of reflecting. The player never has it set.
+ //
+ // Sources:
+ // acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions)
+ // acclient.h:2834 (INELASTIC_PS = 0x20000)
+ // ACE PhysicsObj.cs:2656-2721 (line-for-line port)
+ // PhysicsGlobals.DefaultElasticity = 0.05f, MaxElasticity = 0.1f
+ if (resolveResult.CollisionNormalValid)
+ {
+ bool prevOnWalkable = _body.OnWalkable;
+ bool nowOnWalkable = resolveResult.IsOnGround;
+
+ // apply_bounce: bounce ONLY when the body stays airborne both
+ // before and after this step. That is: jumping into a wall
+ // mid-flight, hitting a ceiling, etc. Specifically NOT:
+ //
+ // - prev grounded + now grounded → wall-slide along corridor
+ // (bounce would feel sticky on every wall touch).
+ // - prev airborne + now grounded → terrain landing
+ // (terrain normal is mostly +Z; reflecting downward velocity
+ // would push the body upward and prevent the landing snap
+ // from firing — player perpetually micro-bouncing on the
+ // floor instead of resting).
+ // - prev grounded + now airborne → walked off cliff
+ // (gravity should take over, not lateral bounce).
+ //
+ // Sledding mode reverts to retail's broader rule (bounce
+ // unless both grounded), since sledding intentionally bounces
+ // off ramps.
+ //
+ // This is more conservative than retail's strict
+ // `!(prev && now && !sledding)` rule — retail bounces on
+ // landing too, but at elasticity 0.05 the visual effect is
+ // imperceptible there. acdream's per-frame architecture
+ // amplifies the artifact (the post-reflection upward Z
+ // defeats the controller's `Velocity.Z <= 0` landing-snap
+ // gate), so we suppress it on landing to avoid the
+ // micro-bounce death spiral.
+ bool applyBounce = _body.State.HasFlag(PhysicsStateFlags.Sledding)
+ ? !(prevOnWalkable && nowOnWalkable)
+ : (!prevOnWalkable && !nowOnWalkable);
+
+ if (applyBounce)
+ {
+ if (_body.State.HasFlag(PhysicsStateFlags.Inelastic))
+ {
+ // Full stop on impact. Spell projectiles / missiles.
+ _body.Velocity = Vector3.Zero;
+ }
+ else
+ {
+ var v = _body.Velocity;
+ var n = resolveResult.CollisionNormal;
+ float dotVN = Vector3.Dot(v, n);
+ if (dotVN < 0f)
+ {
+ // Reflect the into-wall component back out.
+ // Player elasticity is 0.05 → 105% of perpendicular
+ // velocity reflects (subtle bounce).
+ float k = -(dotVN * (_body.Elasticity + 1f));
+ _body.Velocity = v + n * k;
+ }
+ }
+ }
+ }
+
bool justLanded = false;
if (resolveResult.IsOnGround)
{
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index 9c93915..2c2b20a 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -31,6 +31,14 @@ public enum PhysicsStateFlags : uint
ReportCollisions = 0x00000010,
Gravity = 0x00000400, // bit 10 — apply downward gravity
Hidden = 0x00001000,
+ ///
+ /// L.3a (2026-04-30): retail INELASTIC_PS bit (acclient.h:2834).
+ /// When set, wall-collisions zero the velocity instead of reflecting.
+ /// Used by spell projectiles and missiles that should embed/explode on
+ /// impact rather than bounce. The player NEVER has this flag set —
+ /// player wall-hits use the reflection path with elasticity ~0.05.
+ ///
+ Inelastic = 0x00020000, // bit 17 — retail INELASTIC_PS
Sledding = 0x00800000, // bit 23 — sledding (modified friction)
}
@@ -44,6 +52,7 @@ public enum TransientStateFlags : uint
None = 0,
Contact = 0x00000001, // bit 0 — touching any surface
OnWalkable = 0x00000002, // bit 1 — standing on a walkable surface
+ Sliding = 0x00000004, // bit 2 — carry sliding normal into next transition
Active = 0x00000080, // bit 7 — object needs per-frame update
}
@@ -87,6 +96,9 @@ public sealed class PhysicsBody
/// Ground contact-plane normal (+0x130/134/138).
public Vector3 GroundNormal { get; set; } = Vector3.UnitZ;
+ /// Last wall/object sliding normal (retail transient Sliding state).
+ public Vector3 SlidingNormal { get; set; }
+
// ── persisted contact-plane state (retail PhysicsObj fields) ───────────
//
// Retail's PhysicsObj carries its last contact plane FORWARD across frames.
@@ -113,6 +125,18 @@ public sealed class PhysicsBody
/// Whether the contact plane is a water surface (affects step behavior).
public bool ContactPlaneIsWater { get; set; }
+ /// Whether the previous walkable polygon is available for edge slide.
+ public bool WalkablePolygonValid { get; set; }
+
+ /// Most recent walkable polygon plane (world-space).
+ public System.Numerics.Plane WalkablePlane { get; set; }
+
+ /// Most recent walkable polygon vertices (world-space).
+ public Vector3[]? WalkableVertices { get; set; }
+
+ /// Up vector used by the most recent walkable polygon probe.
+ public Vector3 WalkableUp { get; set; } = Vector3.UnitZ;
+
/// Elasticity coefficient (+0xB0).
public float Elasticity { get; set; } = 0.05f;
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 22f48ef..2fee39b 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -518,8 +518,29 @@ public sealed class PhysicsEngine
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;
@@ -548,8 +569,43 @@ public sealed class PhysicsEngine
{
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.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;
+
if (ok)
{
bool onGround = ci.ContactPlaneValid
@@ -558,7 +614,9 @@ public sealed class PhysicsEngine
return new ResolveResult(
sp.CheckPos,
ResolveOutdoorCellId(sp.CheckPos, sp.CheckCellId),
- onGround);
+ onGround,
+ collisionNormalValid,
+ collisionNormal);
}
// Transition failed (e.g., stuck in corner, too many steps).
@@ -574,6 +632,8 @@ public sealed class PhysicsEngine
return new ResolveResult(
sp.CheckPos,
ResolveOutdoorCellId(sp.CheckPos, partialCellId),
- partialOnGround);
+ partialOnGround,
+ collisionNormalValid,
+ collisionNormal);
}
}
diff --git a/src/AcDream.Core/Physics/ResolveResult.cs b/src/AcDream.Core/Physics/ResolveResult.cs
index cc7fef8..63d1845 100644
--- a/src/AcDream.Core/Physics/ResolveResult.cs
+++ b/src/AcDream.Core/Physics/ResolveResult.cs
@@ -6,8 +6,25 @@ namespace AcDream.Core.Physics;
/// Result of : the validated
/// position after collision, the cell the entity ended up in,
/// and whether they're standing on a surface.
+///
+///
+/// L.3a (2026-04-30): added optional collision-normal fields so the
+/// caller (typically )
+/// can apply retail's velocity-reflection bounce
+/// (v_new = v - (1 + elasticity) * dot(v, n) * n) to the
+/// PhysicsBody after the geometric resolve completes. ACE port mirror:
+/// references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2692-2697;
+/// retail equivalent: CPhysicsObj::handle_all_collisions at
+/// acclient_2013_pseudo_c.txt:282699-282715.
+///
///
public readonly record struct ResolveResult(
Vector3 Position,
uint CellId,
- bool IsOnGround);
+ bool IsOnGround,
+ /// True when a wall collision occurred during this resolve
+ /// and is meaningful.
+ bool CollisionNormalValid = false,
+ /// Outward surface normal of the wall the sphere hit. Used
+ /// by the velocity-reflection step. Pointing away from the wall.
+ Vector3 CollisionNormal = default);