Campaign P Slice P4 item 2. TerrainSurface.SampleWaterDepth now returns 0.1
(was collapsed to 0) for a partially-water cell's dry corner, matching
retail's ObjCell.get_water_depth / calc_water_depth (via ACE's unambiguous
C# port). ValidateWalkable's formula was already byte-for-byte verbatim
(ACE ObjectInfo.ValidateWalkable line 124); only the constant was collapsed.
The old collapse's justification ("0.1 destabilizes the feet-exactly-on-plane
contact-touch check because dist > EPSILON skips SetContactPlane that tick")
is structurally true of retail too - traced and confirmed this slice: in ALL
THREE implementations (retail, ACE, acdream) a skipped touch-reassertion is
NOT a fall, because Contact/OnWalkable are STICKY -
PhysicsEngine.ResolveWithTransition's onGround computation ORs the fresh
per-call ContactPlaneValid with the seeded, persistent
PhysicsBody.TransientState.OnWalkable bit (itself written back by the
caller's own sticky TransientState). PhysicsEngine.SampleTerrainWalkable's
isWater = waterDepth >= 0.45f threshold means the restore does not flip the
dry corner's water classification (0.1 still < 0.45) - only the sink-in
depth changes. Full Core.Tests suite green (4038/2 skips, up from 4026)
proves the sticky-bit argument held in practice.
WATER_CONTACT_TS (TransientStateFlags.WaterContact, declared but never
written) is now mirrored alongside CONTACT_TS/ON_WALKABLE_TS at every commit
point that writes them: PhysicsObjUpdate.ApplySetPositionContact (projectiles
+ remote teleport), PhysicsObjUpdate.CommitSetPositionTransition (remote
teleport placement), and PhysicsEngine's per-resolve body-state commit (local
player + remote dead-reckoning + ordinary movers via ResolveWithTransition -
the actual SetPositionInternal-equivalent path). No signature changes needed:
body.ContactPlaneIsWater is already fresh by the time each function runs.
CollisionShadowVerifier audit: no change needed. It diffs graph-vs-flat BSP
traversal outcomes (ObjectInfo/CollisionInfo/SpherePath fields already
including ContactPlaneIsWater); it never touches PhysicsBody.TransientState,
and the water-depth constant is computed identically upstream of both
traversal modes, so it cannot introduce a new graph/flat divergence.
Filed #264 for the three items research explicitly left open (none block
this port): no confirmed retail consumer of WATER_CONTACT_TS was found (an
xref scan wasn't attempted - bitmask reads aren't text-greppable); the
CLandCell ENTIRELY_WATER ethereal/swim exemption from terrain collision was
not cross-checked; jump-in-water/swim-animation effects were not
investigated (out of physics/collision scope).
Conformance: Ap10WaterSemanticsTests covers SampleWaterDepth golden values
(NotWater/EntirelyWater/PartiallyWater wet+dry corners), the isWater
threshold non-flip, WaterContact mirroring in both PhysicsObjUpdate
functions, and two settle-to-rest end-to-end PhysicsEngine.ResolveWithTransition
scenarios (water: sinks exactly waterDepth below the plane and sets
WaterContact; dry: rests exactly on the plane and clears any stale
WaterContact bit).
Register: retired AP-10 (92 active AP rows, down from 93).
AcDream.Core.Tests: 4038 passed, 2 skipped, 0 failed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
211 lines
9.5 KiB
C#
211 lines
9.5 KiB
C#
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>SetPositionInternal</c> walkability comparison at
|
|
/// <c>0x00515465-0x0051548E</c>. Equality with FloorZ is walkable.
|
|
/// </summary>
|
|
public static bool IsWalkableContact(bool inContact, Vector3 contactNormal)
|
|
=> inContact && contactNormal.Z >= PhysicsGlobals.FloorZ;
|
|
|
|
/// <summary>
|
|
/// Applies the two independent transient facts written by retail
|
|
/// <c>CPhysicsObj::SetPositionInternal</c> at <c>0x00515430-0x0051549F</c>.
|
|
/// Contact comes from contact-plane validity; OnWalkable additionally
|
|
/// requires a walkable plane normal. A steep contact therefore remains in
|
|
/// contact without becoming grounded.
|
|
/// </summary>
|
|
public static void ApplySetPositionContact(
|
|
PhysicsBody body,
|
|
bool inContact,
|
|
bool onWalkable)
|
|
{
|
|
if (inContact)
|
|
body.TransientState |= TransientStateFlags.Contact;
|
|
else
|
|
body.TransientState &= ~TransientStateFlags.Contact;
|
|
|
|
if (inContact && onWalkable)
|
|
body.TransientState |= TransientStateFlags.OnWalkable;
|
|
else
|
|
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
|
|
|
// AP-10 (Campaign P Slice P4, 2026-07-30): retail SetPositionInternal
|
|
// (0x005153e5-0051545f) writes WATER_CONTACT_TS in the same statement
|
|
// block as CONTACT_TS, immediately after. body.ContactPlaneIsWater is
|
|
// already current by the time this runs (every caller sets it, or it
|
|
// carries over from the prior tick, before calling here).
|
|
if (body.ContactPlaneIsWater)
|
|
body.TransientState |= TransientStateFlags.WaterContact;
|
|
else
|
|
body.TransientState &= ~TransientStateFlags.WaterContact;
|
|
|
|
body.calc_acceleration();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Commits the contact/walkable/collision-response tail of retail
|
|
/// <c>CPhysicsObj::SetPositionInternal</c> (<c>0x00515330</c>) in its
|
|
/// original order. The pre-transition flags are explicit because a
|
|
/// deferred placement may temporarily park the body without contact while
|
|
/// still needing the source edge when its destination cell becomes ready.
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Retail writes Contact and recalculates acceleration, calls
|
|
/// <c>set_on_walkable</c> (which invokes HitGround/LeaveGround), then calls
|
|
/// <c>handle_all_collisions</c> at <c>0x005154FE</c>. Collision response
|
|
/// must therefore observe any velocity change made by the movement
|
|
/// callback; moving that callback after reflection changes the result.
|
|
/// </remarks>
|
|
public static bool CommitSetPositionTransition(
|
|
PhysicsBody body,
|
|
bool inContact,
|
|
bool onWalkable,
|
|
bool collisionNormalValid,
|
|
Vector3 collisionNormal,
|
|
bool previousContact,
|
|
bool previousOnWalkable,
|
|
Action? hitGround = null,
|
|
Action? leaveGround = null,
|
|
Func<bool>? isCurrent = null,
|
|
Func<bool>? isVelocityCurrent = null)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(body);
|
|
|
|
// SetPositionInternal replaces Contact first but retains the source
|
|
// OnWalkable bit through its first calc_acceleration call. A deferred
|
|
// teleport may have parked the live body with both bits cleared, so
|
|
// restore the captured source bit explicitly before reproducing that
|
|
// ordering.
|
|
if (previousOnWalkable)
|
|
body.TransientState |= TransientStateFlags.OnWalkable;
|
|
else
|
|
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
|
|
|
if (inContact)
|
|
body.TransientState |= TransientStateFlags.Contact;
|
|
else
|
|
body.TransientState &= ~TransientStateFlags.Contact;
|
|
body.calc_acceleration();
|
|
|
|
bool finalOnWalkable = inContact && onWalkable;
|
|
if (finalOnWalkable)
|
|
body.TransientState |= TransientStateFlags.OnWalkable;
|
|
else
|
|
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
|
|
|
// AP-10 (Campaign P Slice P4, 2026-07-30): mirror WATER_CONTACT_TS
|
|
// alongside CONTACT_TS/ON_WALKABLE_TS, same as ApplySetPositionContact.
|
|
// Callers (e.g. RemoteTeleportPlacement) already set body.ContactPlaneIsWater
|
|
// before invoking this commit.
|
|
if (body.ContactPlaneIsWater)
|
|
body.TransientState |= TransientStateFlags.WaterContact;
|
|
else
|
|
body.TransientState &= ~TransientStateFlags.WaterContact;
|
|
|
|
if (!previousOnWalkable && finalOnWalkable)
|
|
{
|
|
hitGround?.Invoke();
|
|
if (isCurrent?.Invoke() == false)
|
|
return false;
|
|
}
|
|
else if (previousOnWalkable && !finalOnWalkable)
|
|
{
|
|
leaveGround?.Invoke();
|
|
if (isCurrent?.Invoke() == false)
|
|
return false;
|
|
}
|
|
body.calc_acceleration();
|
|
|
|
// Position, Vector, and Movement are independently timestamped but
|
|
// can all install m_velocityVector. If a later one arrived from a
|
|
// callback above, retain its vector and finish the non-overlapping
|
|
// contact/pose commit without applying this older collision response.
|
|
if (isVelocityCurrent?.Invoke() == false)
|
|
return isCurrent?.Invoke() ?? true;
|
|
|
|
HandleAllCollisions(
|
|
body,
|
|
collisionNormalValid,
|
|
collisionNormal,
|
|
previousContact,
|
|
previousOnWalkable,
|
|
finalOnWalkable);
|
|
return isCurrent?.Invoke() ?? true;
|
|
}
|
|
|
|
/// <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 > 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.
|
|
}
|
|
}
|