fix(physics): AP-10 - restore retail's 0.1m dry-corner water sink-in; wire WATER_CONTACT_TS

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>
This commit is contained in:
Erik 2026-07-30 10:52:39 +02:00
parent d6c3f8657a
commit cc8d57a26e
7 changed files with 439 additions and 18 deletions

View file

@ -517,22 +517,33 @@ public sealed class TerrainSurface
/// character while dry terrain keeps feet exactly on the plane.
///
/// <para>
/// AP-10 (Campaign P Slice P4, 2026-07-30): the retail 0.1 m dry-corner
/// sink-in is RESTORED (was collapsed to 0 — see the retired register
/// row's history). The collapse's justification ("0.1 destabilizes the
/// feet-exactly-on-plane contact-touch check because <c>dist &gt; EPSILON</c>
/// skips <c>SetContactPlane</c> that tick") is structurally true of retail
/// too — in ALL THREE implementations (retail, ACE, acdream) a skipped
/// touch-reassertion is <b>not</b> a fall, because <see cref="ObjectInfoState.Contact"/>/
/// <see cref="ObjectInfoState.OnWalkable"/> are STICKY: <c>PhysicsEngine
/// .ResolveWithTransition</c>'s <c>onGround</c> computation ORs the fresh
/// per-call <c>ContactPlaneValid</c> with the SEEDED (caller-supplied,
/// itself sourced from <see cref="PhysicsBody.TransientState"/>'s persistent
/// <see cref="TransientStateFlags.OnWalkable"/> bit) <c>ObjectInfoState
/// .OnWalkable</c>, so a single skipped tick does not clear grounded state
/// — the same sticky-bit model retail relies on. See
/// <c>docs/research/2026-07-29-remote-and-world-specials-pseudocode.md</c>
/// §5.2 for the full argument.
/// </para>
/// <para>
/// Ported from ACE <c>ObjCell.get_water_depth</c> and
/// <c>LandblockStruct.calc_water_depth</c>, except that the retail
/// 0.1 fallback for "dry corner of a partially-water cell" is
/// collapsed to 0. The 0.1 offset destabilizes the "feet exactly on
/// plane" contact-touch check (dist > EPSILON, so SetContactPlane
/// doesn't fire, ValidateTransition clears OnWalkable, gravity
/// applies, character floats/falls each frame). The visible effect
/// in retail is subtle (~10 cm natural sink-in) so dropping it to 0
/// is indistinguishable.
/// <c>LandblockStruct.calc_water_depth</c>.
/// </para>
/// <list type="bullet">
/// <item>NotWater cell → <c>0.0f</c></item>
/// <item>EntirelyWater cell → <c>0.9f</c> (fully submerged)</item>
/// <item>PartiallyWater cell, nearest-corner water type → <c>0.45f</c></item>
/// <item>PartiallyWater cell, nearest-corner non-water → <c>0.0f</c>
/// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug)</item>
/// <item>PartiallyWater cell, nearest-corner non-water → <c>0.1f</c>
/// (retail's dry-corner sink-in)</item>
/// </list>
/// </summary>
public float SampleWaterDepth(float localX, float localY)
@ -548,14 +559,14 @@ public sealed class TerrainSurface
// PartiallyWater — resolve to nearest corner (retail picks the
// terrain-vertex at the "+12" half of each axis, i.e. >= 12m into
// a 24m cell rounds up). Return 0.45 for water corner, 0 for dry
// (retail uses 0.1 for dry corners; see note above).
// a 24m cell rounds up). 0.45 for a water corner, 0.1 for a dry
// corner (retail's allowed dry-corner sink-in; see note above).
float tx = fx - cx;
float ty = fy - cy;
int vx = cx + (tx >= 0.5f ? 1 : 0);
int vy = cy + (ty >= 0.5f ? 1 : 0);
return _cornerIsWater[vx, vy] ? 0.45f : 0f;
return _cornerIsWater[vx, vy] ? 0.45f : 0.1f;
}
/// <summary>