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

@ -99,10 +99,24 @@ public enum TransientStateFlags : uint
StationaryStop = 0x00000020, // bit 5 — fsf == 2
StationaryStuck = 0x00000040, // bit 6 — fsf == 3
Active = 0x00000080, // bit 7 — object needs per-frame update
// Declared to complete retail's TransientState (acclient.h:3688). Neither bit is
// produced or consumed by acdream's transition yet; they are here so the two free
// slots cannot be reused for something else and quietly collide with the wire.
/// <summary>
/// AP-10 (Campaign P Slice P4, 2026-07-30): retail <c>WATER_CONTACT_TS</c>
/// (acclient.h:3688). Retail writes it in <c>CPhysicsObj::SetPositionInternal</c>
/// (0x005153e5-0051545f) in the same statement block as <see cref="Contact"/>,
/// immediately after, from the transition's local <c>contact_plane_is_water</c>
/// (acdream: <see cref="PhysicsBody.ContactPlaneIsWater"/>). Produced (mirrored
/// alongside <see cref="Contact"/> by <c>PhysicsObjUpdate.ApplySetPositionContact</c>,
/// <c>PhysicsObjUpdate.CommitSetPositionTransition</c>, and
/// <c>PhysicsEngine</c>'s per-resolve body-state commit) since 2026-07-30; no
/// confirmed retail CONSUMER of the bit was found in this pass (a full
/// bit-0x8-read xref scan of the 1.4M-line pseudo-C dump was not attempted —
/// bitmask reads are not text-greppable without high false-positive noise
/// across unrelated 0x8 masks) — see the AP-10 register row.
/// </summary>
WaterContact = 0x00000008, // bit 3 — WATER_CONTACT_TS
// Declared to complete retail's TransientState (acclient.h:3688). Not
// produced or consumed by acdream's transition yet; here so the free
// slot cannot be reused for something else and quietly collide with the wire.
CheckEthereal = 0x00000100, // bit 8 — CHECK_ETHEREAL_TS
}
@ -431,6 +445,15 @@ public sealed class PhysicsBody
public bool OnWalkable => (TransientState & TransientStateFlags.OnWalkable) != 0;
public bool IsActive => (TransientState & TransientStateFlags.Active) != 0;
public bool InContact => (TransientState & TransientStateFlags.Contact) != 0;
/// <summary>
/// AP-10 (Campaign P Slice P4, 2026-07-30): retail <c>WATER_CONTACT_TS</c>
/// mirror of <see cref="ContactPlaneIsWater"/> onto <see cref="TransientState"/>.
/// Written in lockstep with <see cref="InContact"/> by
/// <see cref="PhysicsObjUpdate.ApplySetPositionContact"/>,
/// <see cref="PhysicsObjUpdate.CommitSetPositionTransition"/>, and
/// <see cref="PhysicsEngine"/>'s per-resolve body-state commit.
/// </summary>
public bool IsWaterContact => (TransientState & TransientStateFlags.WaterContact) != 0;
// ── FUN_00511420 ───────────────────────────────────────────────────────

View file

@ -1298,6 +1298,17 @@ public sealed class PhysicsEngine
body.ContactPlaneValid = false;
}
// 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 — this is acdream's equivalent
// per-resolve commit point. Mirrors whatever body.ContactPlaneIsWater was
// just set to above (unchanged/stale in the no-valid-contact branch,
// matching that branch's existing ContactPlaneIsWater behavior).
if (body.ContactPlaneIsWater)
body.TransientState |= TransientStateFlags.WaterContact;
else
body.TransientState &= ~TransientStateFlags.WaterContact;
// Publish frames_stationary_fall + carry it to the next frame via the Stationary*
// transient bits. Retail encodes these bits in handle_all_collisions (pc:282737-758);
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,

View file

@ -42,6 +42,16 @@ public static class PhysicsObjUpdate
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();
}
@ -96,6 +106,15 @@ public static class PhysicsObjUpdate
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();

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>