feat(#182): port handle_all_collisions as a pure Core unit (Slice 2a)

PhysicsObjUpdate.HandleAllCollisions — retail CPhysicsObj::handle_all_collisions
(0x00514780, pc:282647): the velocity 'bleed on block' decision. fsf<=1 → reflect the
into-surface component (v += -(v·n)(elasticity+1)·n) unless staying-on-walkable (retail's
should_reflect guard, restoring the broader rule AD-25 suppressed); INELASTIC zeros
instead; fsf>1 → v=0 entirely (the airborne-stuck fix). Bit round-trip owned by the Core
resolve writeback (Slice 1), not re-encoded here. 7 conformance tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-07-07 14:12:59 +02:00
parent 6c3cd96b7a
commit 78df1370b6
2 changed files with 189 additions and 0 deletions

View file

@ -0,0 +1,78 @@
using System.Numerics;
namespace AcDream.Core.Physics;
/// <summary>
/// Verbatim port of the collision-response tail of retail
/// <c>CPhysicsObj::UpdateObjectInternal</c>: the velocity decision that
/// <c>SetPositionInternal</c> (0x00515330) drives through
/// <c>handle_all_collisions</c> (0x00514780, pc:282647). Kept as a pure function over a
/// <see cref="PhysicsBody"/> + the resolve outcome so the whole decision is unit-testable
/// in Core, independent of the App per-frame loop. The transition INTERNALS
/// (<c>ResolveWithTransition</c> and below) are untouched.
/// </summary>
public static class PhysicsObjUpdate
{
/// <summary>
/// retail <c>handle_all_collisions</c> (0x00514780). Reflects or zeros the body's
/// <see cref="PhysicsBody.Velocity"/> (retail m_velocityVector) based on
/// <see cref="PhysicsBody.FramesStationaryFall"/>:
/// <list type="bullet">
/// <item>fsf ≤ 1 → reflect the into-surface component
/// (<c>v += -(v·n)(elasticity+1)·n</c>, pc:282712) when we should reflect and a
/// collision normal is valid; an INELASTIC mover zeros instead (pc:282720).</item>
/// <item>fsf &gt; 1 → <c>v = 0</c> entirely (pc:282729) — the "bleed on block" that
/// lets gravity resume so a blocked jump falls/glides off (fixes the #182
/// airborne-stuck wedge).</item>
/// </list>
/// The Stationary* transient-bit round-trip (pc:282737-758) is owned by the Core resolve
/// writeback (<c>PhysicsEngine.ResolveWithTransition</c>), not re-encoded here.
/// </summary>
/// <param name="body">The mover.</param>
/// <param name="collisionNormalValid">Was a wall/creature collision normal recorded this resolve.</param>
/// <param name="collisionNormal">Outward collision normal (points away from the surface).</param>
/// <param name="prevContact">Whether the body had Contact BEFORE this resolve committed
/// (retail arg3 — reserved for environment-collision reporting; unused today).</param>
/// <param name="prevOnWalkable">Whether the body was OnWalkable before this resolve (retail arg4).</param>
/// <param name="nowOnWalkable">Whether the body is OnWalkable after this resolve.</param>
public static void HandleAllCollisions(
PhysicsBody body,
bool collisionNormalValid, Vector3 collisionNormal,
bool prevContact, bool prevOnWalkable, bool nowOnWalkable)
{
// var_10_1 (pc:282653-282657): reflect UNLESS the mover stays on walkable ground
// (and is not sledding). This restores retail's broader rule — the AD-25 airborne-only
// suppression is retired: the landing-snap fragility it guarded is gone (landing state
// is now owned by the SetPositionInternal-derived contact flags, not a Velocity.Z<=0
// gate). A grounded corridor wall-slide keeps its tangential velocity (should_reflect
// false), exactly as retail.
bool sledding = body.State.HasFlag(PhysicsStateFlags.Sledding);
bool shouldReflect = !(prevOnWalkable && nowOnWalkable && !sledding);
if (body.FramesStationaryFall <= 1)
{
if (shouldReflect && collisionNormalValid)
{
if (body.State.HasFlag(PhysicsStateFlags.Inelastic))
{
body.Velocity = Vector3.Zero; // pc:282720-282722
}
else
{
float dot = Vector3.Dot(body.Velocity, collisionNormal);
if (dot < 0f) // moving INTO the surface
{
float k = -(dot * (body.Elasticity + 1f)); // pc:282712
body.Velocity += collisionNormal * k;
}
}
}
}
else
{
body.Velocity = Vector3.Zero; // fsf>1 → THE BLEED (pc:282729)
}
_ = prevContact; // retail report_environment_collision(arg3) — weenie collision events, later.
}
}

View file

@ -0,0 +1,111 @@
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
/// <summary>
/// Conformance tests for <see cref="PhysicsObjUpdate.HandleAllCollisions"/> — the port of
/// retail <c>CPhysicsObj::handle_all_collisions</c> (0x00514780, pc:282647). This is the
/// velocity "bleed on block" decision: reflect (fsf≤1) vs zero (fsf&gt;1).
/// </summary>
public class HandleAllCollisionsTests
{
private static PhysicsBody Airborne(Vector3 v, int fsf = 0) => new PhysicsBody
{
State = PhysicsStateFlags.Gravity | PhysicsStateFlags.ReportCollisions,
TransientState = TransientStateFlags.None,
Velocity = v,
FramesStationaryFall = fsf,
};
[Fact]
public void Fsf0_AirborneWallHit_ReflectsIntoWallComponent()
{
var b = Airborne(new Vector3(3f, 0f, 0f)); // moving +X into a wall whose outward normal is -X
var n = new Vector3(-1f, 0f, 0f);
PhysicsObjUpdate.HandleAllCollisions(b,
collisionNormalValid: true, collisionNormal: n,
prevContact: false, prevOnWalkable: false, nowOnWalkable: false);
// dot = 3*-1 = -3 < 0 → k = -(-3*(0.05+1)) = 3.15 → v += (-1,0,0)*3.15 → x = 3 - 3.15 = -0.15
Assert.Equal(-0.15f, b.Velocity.X, precision: 3);
}
[Fact]
public void Fsf0_MovingAwayFromSurface_DoesNotReflect()
{
var b = Airborne(new Vector3(0f, 0f, 2f)); // moving up, normal also up (already separating)
var n = new Vector3(0f, 0f, 1f);
PhysicsObjUpdate.HandleAllCollisions(b,
collisionNormalValid: true, collisionNormal: n,
prevContact: false, prevOnWalkable: false, nowOnWalkable: false);
Assert.Equal(2f, b.Velocity.Z); // dot = +2 >= 0 → no reflection
}
[Fact]
public void Fsf2_ZeroesVelocity_TheAirborneStuckBleed()
{
// The #182 case: a straight-up jump blocked by a near-horizontal creature normal.
// At fsf>1 the whole velocity is zeroed so gravity resumes → the player falls/glides off.
var b = Airborne(new Vector3(0f, 0f, 18f), fsf: 2);
var n = new Vector3(-0.96f, -0.25f, -0.15f);
PhysicsObjUpdate.HandleAllCollisions(b,
collisionNormalValid: true, collisionNormal: n,
prevContact: false, prevOnWalkable: false, nowOnWalkable: false);
Assert.Equal(Vector3.Zero, b.Velocity);
}
[Fact]
public void Fsf3_ZeroesVelocity_EvenWithoutCollisionNormal()
{
var b = Airborne(new Vector3(1f, 2f, 18f), fsf: 3);
PhysicsObjUpdate.HandleAllCollisions(b,
collisionNormalValid: false, collisionNormal: default,
prevContact: false, prevOnWalkable: false, nowOnWalkable: false);
Assert.Equal(Vector3.Zero, b.Velocity);
}
[Fact]
public void StayingOnWalkable_DoesNotReflect_CorridorWallSlidePreserved()
{
// Grounded before AND after → should_reflect is false → the tangential wall-slide
// velocity is preserved (retail's rule; the corridor shuffle, not a sticky bounce).
var b = new PhysicsBody
{
Velocity = new Vector3(3f, 0f, 0f),
TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
FramesStationaryFall = 0,
};
var n = new Vector3(-1f, 0f, 0f);
PhysicsObjUpdate.HandleAllCollisions(b,
collisionNormalValid: true, collisionNormal: n,
prevContact: true, prevOnWalkable: true, nowOnWalkable: true);
Assert.Equal(3f, b.Velocity.X);
}
[Fact]
public void Inelastic_ZeroesInsteadOfReflecting()
{
var b = Airborne(new Vector3(3f, 0f, 0f));
b.State |= PhysicsStateFlags.Inelastic; // spell projectile / missile
var n = new Vector3(-1f, 0f, 0f);
PhysicsObjUpdate.HandleAllCollisions(b,
collisionNormalValid: true, collisionNormal: n,
prevContact: false, prevOnWalkable: false, nowOnWalkable: false);
Assert.Equal(Vector3.Zero, b.Velocity);
}
[Fact]
public void LandingReflects_RetailRuleRestored_NotSuppressed()
{
// prev airborne → now grounded (a landing). Retail reflects here too (AD-25 retired);
// at elasticity 0.05 the effect is a tiny, imperceptible deflection.
var b = Airborne(new Vector3(0f, 0f, -5f)); // falling
var n = new Vector3(0f, 0f, 1f); // floor normal up
PhysicsObjUpdate.HandleAllCollisions(b,
collisionNormalValid: true, collisionNormal: n,
prevContact: false, prevOnWalkable: false, nowOnWalkable: true);
// dot = -5 < 0 → k = -(-5*1.05) = 5.25 → v.z = -5 + 5.25 = 0.25 (tiny bounce)
Assert.Equal(0.25f, b.Velocity.Z, precision: 3);
}
}