diff --git a/docs/ISSUES.md b/docs/ISSUES.md
index 85b307d5..5a4893d3 100644
--- a/docs/ISSUES.md
+++ b/docs/ISSUES.md
@@ -147,6 +147,54 @@ regressing the #225 lifestone/candle compositing.
---
+## #264 — Water semantics: WATER_CONTACT_TS consumer + two unverified swim behaviors
+
+**Status:** OPEN (filed 2026-07-30, Campaign P Slice P4 AP-10 closeout)
+**Severity:** LOW (no confirmed divergence; research/verification follow-up)
+**Component:** physics / terrain / water
+
+**Context:** AP-10 (dry-corner water sink-in) is retired and `WATER_CONTACT_TS`
+(`TransientStateFlags.WaterContact`) is now produced (mirrored alongside
+`Contact` by `PhysicsObjUpdate.ApplySetPositionContact`,
+`CommitSetPositionTransition`, and `PhysicsEngine`'s per-resolve body-state
+commit). Three items from
+`docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.3-5.4
+remain genuinely unresolved — none block the AP-10 port, but none are
+silently absorbed either:
+
+1. **No confirmed retail CONSUMER of `WATER_CONTACT_TS` was found.** The
+ write site (`CPhysicsObj::SetPositionInternal`, pc:283459-283483) is
+ confirmed; a full xref scan for READS of bit 0x8 on `transient_state`
+ was not attempted (bitmask reads are not text-greppable across the
+ 1.4M-line pseudo-C dump without high false-positive noise against
+ unrelated 0x8 masks). Is it a pure reporting/query bit (e.g. an "is
+ swimming" query for animation/sound/UI with no gameplay-feel
+ consequence), or does something in the movement/friction/step chain
+ branch on it? Next step: Ghidra MCP `/function_xrefs?name=CPhysicsObj::
+ SetPositionInternal` once reachable, or a live cdb capture.
+2. **`CLandCell::find_env_collisions`'s ENTIRELY_WATER early-exit** (pc:317091:
+ `if (block_water_type == ENTIRELY_WATER && !ethereal && !(state&0x40)) return;`
+ — a swimming/ethereal exemption from terrain collision entirely) was
+ **not cross-checked** against acdream's handling of this exact condition.
+ Flagged as unverified, not asserted-divergent or asserted-matching.
+3. **Jump-in-water and movement-effects-in-water** (reduced jump height, swim
+ animation triggers) were **not investigated** — out of the P4 physics/
+ collision scope; would need a `MovementSystem`/animation-side read.
+
+**Files:** `src/AcDream.Core/Physics/PhysicsBody.cs` (`TransientStateFlags
+.WaterContact`, `IsWaterContact`); `src/AcDream.Core/Physics/PhysicsObjUpdate.cs`;
+`src/AcDream.Core/Physics/TransitionTypes.cs` (outdoor `FindEnvCollisions`
+terrain branch — the ENTIRELY_WATER exemption's acdream-side home, if it
+exists at all).
+
+**Acceptance:** either (a) a confirmed consumer of `WATER_CONTACT_TS` is
+found and ported (or confirmed absent, closing this cleanly), and (b) the
+ENTIRELY_WATER early-exit is cross-checked and either confirmed matching or
+filed as its own register row; or (c) this issue is re-scoped/split once one
+sub-item resolves.
+
+---
+
## #262 — Run-on-the-spot at first login: no displacement until a recall reset
**Status:** OPEN
diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md
index 3a047c5f..a86579d0 100644
--- a/docs/architecture/retail-divergence-register.md
+++ b/docs/architecture/retail-divergence-register.md
@@ -116,7 +116,7 @@ accepted-divergence entries (#96, #49, #50).
---
-## 3. Documented approximation (AP) — 93 active rows (AP-71 retired 2026-07-30 at Campaign P Slice P4 — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-129 filed same slice for the narrower remaining gap — `CanMoveInto`'s owner/guest-list decode is unmodeled, so a genuinely restricted cell fails closed for everyone, not just intruders; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
+## 3. Documented approximation (AP) — 92 active rows (AP-10 retired 2026-07-30 at Campaign P Slice P4 — restored retail's 0.1 m dry-corner water sink-in, full suite green proving the sticky-bit no-regression argument; AP-71 retired same slice — `check_entry_restrictions` ported at the head of the indoor `FindEnvCollisions` branch, `CellPhysics.RestrictionObj` wired from the DAT-baked `EnvCell` field in both the dev and production caching paths; AP-129 filed same slice for the narrower remaining gap — `CanMoveInto`'s owner/guest-list decode is unmodeled, so a genuinely restricted cell fails closed for everyone, not just intruders; AP-128 filed 2026-07-30 at the P3 Opus review — PK-timer clock basis; AP-25 retired 2026-07-30 at Campaign P Slice P1 — the vitae/enchantment-aware run/jump skill chain; AP-127 filed same slice for the two minor unmodeled bonus properties; AP-7 retired 2026-07-30 at Campaign P Slice P2 — `calc_friction`'s threshold ported to retail's confirmed 0.25f; its still-open cos(10°)-vs-0.99999536f Sledding constant question moved to AD-55)
Wave-0 UI ledger repair (2026-07-10) retired stale AP-38, resolved the AP-84
collision, restored overwritten paperdoll rows as AP-92/AP-93, and registered
@@ -130,7 +130,7 @@ AP-94..AP-112 for the confirmed retail-UI completion gaps.
| AP-4 | CliffSlide check moved BEFORE retail's Branch-1 (`!OnWalkable` → restore+OK) gate, compensating our L.2.3i FloorZ OnWalkable bookkeeping | `src/AcDream.Core/Physics/TransitionTypes.cs:1316` | Retail's order with our incomplete OnWalkable stops the player dead every frame on steep slopes ("stay on the roof"); reorder restores downhill drift | CliffSlide fires in states where retail's Branch 1 would restore-and-OK — body slides where retail holds, e.g. contact-plane-bearing steep geometry near edges | retail EdgeSlide dispatch order (transitional_insert step-down failure) |
| AP-5 | Step-down skips Placement validation for the contact-maintenance call (`runPlacement=false`); ACE/retail run it unconditionally (kept for DoStepUp) | `src/AcDream.Core/Physics/TransitionTypes.cs:3393` | Residual wall-slide artifacts made Placement misfire, leaving players stuck near walls; the skip was the targeted L.2.3h fix | Step-down can settle into positions Placement would reject — slight wall embedding, or accepting a step-down through overlap geometry retail catches | `CTransition::step_down` pc:272952; ACE Transition.cs:731-741 |
| ~~AP-7~~ | **RETIRED 2026-07-30 (Campaign P Slice P2) — the "state gate" was a BN decompiler artifact, not a locomotion exemption.** `calc_friction` now ports retail's confirmed 0.25f threshold (`if (angle >= 0.25f) return;`) unconditionally, no special-cased gate. The "state check at pc:276702" the old row cited is `PhysicsState.Sledding` (confirmed via ACE's `PhysicsObj.calc_friction`, references/ACE/Source/ACE.Server/Physics/PhysicsObj.cs:2120-2141, and `SLEDDING_PS=0x800000` in acclient.h:2838) — it gates the 1.5625/6.25/near-flat friction-value OVERRIDE, not the threshold return itself; acdream had no live Sledding setter then or now (see #166 research, docs/research/2026-07-30-response-layer-edge-family-pseudocode.md §3), so the branch was simply unreachable dead code, not an exemption for ordinary walking. The reverted 2026-04-30 L.3c attempt (naive 0.0→0.25 bump, forward locomotion 3→0.16 m/s in `PlayerMovementControllerTests`) does not reproduce on the production graphical local-player path post-R6: `PlayerMovementController` zeroes `Velocity.X/Y` to exactly zero every tick before `calc_friction` runs whenever animation root motion drives the walk, so friction has no horizontal velocity left to hammer (pinned at the PhysicsBody level by `GroundedRootMotion_FrictionThreshold_DoesNotHammerLocomotionTests`). The headless/`get_state_velocity` movement-controller path and remote/NPC movers still feed real velocity into this function and remain the ones to watch if a similar regression resurfaces there. | `src/AcDream.Core/Physics/PhysicsBody.cs` (`calc_friction`); `tests/AcDream.Core.Tests/Physics/PhysicsBodyTests.cs` (AP-7 test block) | — | — | `CPhysicsObj::calc_friction` pc:276694-276822 (0050ee70); ACE `PhysicsObj.calc_friction` PhysicsObj.cs:2120-2141; `docs/research/2026-07-30-response-layer-edge-family-pseudocode.md` §1 |
-| AP-10 | Dry-corner water depth: retail's 0.1 m allowed sink-in collapsed to 0 | `src/AcDream.Core/Physics/TerrainSurface.cs:481` | The 0.1 offset destabilizes the feet-exactly-on-plane contact-touch check (dist > EPSILON → SetContactPlane never fires → float/fall); retail's ~10 cm sink-in is visually indistinguishable | Masks a contact-touch epsilon fragility — other water-depth values exercising the same instability could oscillate shoreline walkable validation; retail's wet/dry corner sink-in visual absent | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port) |
+| ~~AP-10~~ | **RETIRED 2026-07-30 (Campaign P Slice P4) — the retail 0.1 m dry-corner water sink-in is restored.** `TerrainSurface.SampleWaterDepth` (`src/AcDream.Core/Physics/TerrainSurface.cs`) now returns 0.1 for a partially-water cell's dry corner instead of the collapsed 0. The row's own "destabilizes the touch check" justification turned out to be structurally true of retail too (a skipped `SetContactPlane` reassertion is not a fall in ANY of retail/ACE/acdream, because `Contact`/`OnWalkable` are STICKY — `PhysicsEngine.ResolveWithTransition`'s `onGround` computation ORs the fresh per-call `ContactPlaneValid` with the seeded, persistent `PhysicsBody.TransientState.OnWalkable` bit) — traced and confirmed in this slice; see `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.2. `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 Release suite green (no regression) proves the sticky-bit argument held in practice, not just in theory. | `src/AcDream.Core/Physics/TerrainSurface.cs` (`SampleWaterDepth`) | — | — | `ObjCell.get_water_depth` / `calc_water_depth` (via ACE port); `docs/research/2026-07-29-remote-and-world-specials-pseudocode.md` §5.1-5.2 |
| AP-11 | Hand-authored 4-keyframe fallback sky set (sunrise/noon/sunset, fog ~80–350 m) when the Region dat isn't loaded yet | `src/AcDream.Core/World/SkyState.cs:167` | A renderable sky is needed during boot before the Region dat parses; safety net on region-load failure | Any window where the fallback is active shows sky/fog lighting only roughly resembling retail's dat-driven values | SkyTimeOfDay keyframes, Region dat 0x13000000 |
| AP-12 | Enchantment family-stacking tiebreak by largest SpellId; retail picks highest Generation, tie-broken by latest cast | `src/AcDream.Core/Spells/EnchantmentMath.cs:89` | `ActiveEnchantmentRecord` doesn't carry Generation; SpellId correlates with generation level in practice | Where spell ids don't track power within a family (or same-generation re-cast), the wrong buff wins — vital-max / stat values diverge from retail | `CEnchantmentRegistry::EnchantAttribute` 0x00594570 (pc:416110) |
| AP-13 | `ComputeDamage` is a simplified retail damage formula (no augmentations/ratings) — verified DEAD CODE as of 2026-06-04, M2 scaffolding | `src/AcDream.Core/Combat/CombatModel.cs:184` | Not on the critical path; stubbed from r02 §5 + ACE CombatManager for the future M2 predictive display | If wired into the M2 attack-bar estimate as-is, predicted numbers diverge whenever augs/ratings apply | r02 §5; ACE CombatManager |
diff --git a/src/AcDream.Core/Physics/PhysicsBody.cs b/src/AcDream.Core/Physics/PhysicsBody.cs
index da03c10b..d83cc5ff 100644
--- a/src/AcDream.Core/Physics/PhysicsBody.cs
+++ b/src/AcDream.Core/Physics/PhysicsBody.cs
@@ -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.
+ ///
+ /// AP-10 (Campaign P Slice P4, 2026-07-30): retail WATER_CONTACT_TS
+ /// (acclient.h:3688). Retail writes it in CPhysicsObj::SetPositionInternal
+ /// (0x005153e5-0051545f) in the same statement block as ,
+ /// immediately after, from the transition's local contact_plane_is_water
+ /// (acdream: ). Produced (mirrored
+ /// alongside by PhysicsObjUpdate.ApplySetPositionContact,
+ /// PhysicsObjUpdate.CommitSetPositionTransition, and
+ /// PhysicsEngine'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.
+ ///
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;
+ ///
+ /// AP-10 (Campaign P Slice P4, 2026-07-30): retail WATER_CONTACT_TS
+ /// mirror of onto .
+ /// Written in lockstep with by
+ /// ,
+ /// , and
+ /// 's per-resolve body-state commit.
+ ///
+ public bool IsWaterContact => (TransientState & TransientStateFlags.WaterContact) != 0;
// ── FUN_00511420 ───────────────────────────────────────────────────────
diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs
index 90d3977f..09d1b1ad 100644
--- a/src/AcDream.Core/Physics/PhysicsEngine.cs
+++ b/src/AcDream.Core/Physics/PhysicsEngine.cs
@@ -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,
diff --git a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs
index 5e73ecd2..fd51c73e 100644
--- a/src/AcDream.Core/Physics/PhysicsObjUpdate.cs
+++ b/src/AcDream.Core/Physics/PhysicsObjUpdate.cs
@@ -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();
diff --git a/src/AcDream.Core/Physics/TerrainSurface.cs b/src/AcDream.Core/Physics/TerrainSurface.cs
index d6a528b9..6228781e 100644
--- a/src/AcDream.Core/Physics/TerrainSurface.cs
+++ b/src/AcDream.Core/Physics/TerrainSurface.cs
@@ -517,22 +517,33 @@ public sealed class TerrainSurface
/// character while dry terrain keeps feet exactly on the plane.
///
///
+ /// 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 dist > EPSILON
+ /// skips SetContactPlane that tick") is structurally true of retail
+ /// too — in ALL THREE implementations (retail, ACE, acdream) a skipped
+ /// touch-reassertion is not a fall, because /
+ /// are STICKY: PhysicsEngine
+ /// .ResolveWithTransition's onGround computation ORs the fresh
+ /// per-call ContactPlaneValid with the SEEDED (caller-supplied,
+ /// itself sourced from 's persistent
+ /// bit) ObjectInfoState
+ /// .OnWalkable, so a single skipped tick does not clear grounded state
+ /// — the same sticky-bit model retail relies on. See
+ /// docs/research/2026-07-29-remote-and-world-specials-pseudocode.md
+ /// §5.2 for the full argument.
+ ///
+ ///
/// Ported from ACE ObjCell.get_water_depth and
- /// LandblockStruct.calc_water_depth, 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.
+ /// LandblockStruct.calc_water_depth.
///
///
/// - NotWater cell → 0.0f
/// - EntirelyWater cell → 0.9f (fully submerged)
/// - PartiallyWater cell, nearest-corner water type → 0.45f
- /// - PartiallyWater cell, nearest-corner non-water → 0.0f
- /// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug)
+ /// - PartiallyWater cell, nearest-corner non-water → 0.1f
+ /// (retail's dry-corner sink-in)
///
///
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;
}
///
diff --git a/tests/AcDream.Core.Tests/Physics/Ap10WaterSemanticsTests.cs b/tests/AcDream.Core.Tests/Physics/Ap10WaterSemanticsTests.cs
new file mode 100644
index 00000000..6ed9ea69
--- /dev/null
+++ b/tests/AcDream.Core.Tests/Physics/Ap10WaterSemanticsTests.cs
@@ -0,0 +1,309 @@
+using System;
+using System.Numerics;
+using AcDream.Core.Physics;
+using Xunit;
+using Plane = System.Numerics.Plane;
+
+namespace AcDream.Core.Tests.Physics;
+
+///
+/// Conformance tests for AP-10 (Campaign P Slice P4, 2026-07-30) — two
+/// water-semantics gaps identified in
+/// docs/research/2026-07-29-remote-and-world-specials-pseudocode.md §5:
+///
+///
+/// -
+/// Retail's 0.1 m dry-corner water sink-in
+/// () was collapsed to 0 —
+/// now restored. PhysicsEngine.SampleTerrainWalkable's
+/// isWater = waterDepth >= 0.45f threshold means the dry-corner
+/// restore does NOT flip a dry corner's water classification (0.1 <
+/// 0.45, same as the old 0 < 0.45) — only the sink-in depth changes.
+///
+/// -
+/// WATER_CONTACT_TS ()
+/// was declared but never written — now mirrored alongside
+/// at every commit point:
+/// ,
+/// , and
+/// PhysicsEngine's per-resolve body-state commit.
+///
+///
+///
+public class Ap10WaterSemanticsTests
+{
+ // ── §1: TerrainSurface.SampleWaterDepth golden values ──────────────────
+
+ /// terrainTypes byte whose (byte>>2)&0x1F == 0x10 (WaterRunning, the lowest water type).
+ private const byte WaterTerrainByte = 0x10 << 2; // 0x40
+ private const byte DryTerrainByte = 0x00;
+
+ private static byte[] AllVertices(byte value)
+ {
+ var arr = new byte[81];
+ Array.Fill(arr, value);
+ return arr;
+ }
+
+ [Fact]
+ public void SampleWaterDepth_NotWaterCell_ReturnsZero()
+ {
+ var surface = new TerrainSurface(
+ new byte[81], new float[256],
+ terrainTypes: AllVertices(DryTerrainByte));
+
+ Assert.Equal(0f, surface.SampleWaterDepth(12f, 12f));
+ }
+
+ [Fact]
+ public void SampleWaterDepth_EntirelyWaterCell_ReturnsPoint9()
+ {
+ var surface = new TerrainSurface(
+ new byte[81], new float[256],
+ terrainTypes: AllVertices(WaterTerrainByte));
+
+ Assert.Equal(0.9f, surface.SampleWaterDepth(12f, 12f));
+ }
+
+ [Fact]
+ public void SampleWaterDepth_PartiallyWaterCell_WaterCorner_ReturnsPoint45()
+ {
+ // Cell (0,0): corners are vertices (0,0),(1,0),(1,1),(0,1). Make only
+ // (1,1) water so cell (0,0) is PartiallyWater (1 of 4 corners).
+ // Sampling near local (18,18) (>= 12 into the 24m cell on both axes)
+ // rounds to vertex (1,1) — the water corner.
+ var types = new byte[81];
+ types[1 * 9 + 1] = WaterTerrainByte; // vertex (x=1, y=1)
+
+ var surface = new TerrainSurface(new byte[81], new float[256], terrainTypes: types);
+
+ Assert.Equal(0.45f, surface.SampleWaterDepth(18f, 18f));
+ }
+
+ [Fact]
+ public void SampleWaterDepth_PartiallyWaterCell_DryCorner_ReturnsPoint1_RestoredRetailConstant()
+ {
+ // Same PartiallyWater cell as above, but sample near local (4,4)
+ // (< 12 into the cell) which rounds to vertex (0,0) — the DRY corner.
+ // AP-10: this is the value that was collapsed to 0 and is now restored.
+ var types = new byte[81];
+ types[1 * 9 + 1] = WaterTerrainByte; // vertex (x=1, y=1) is the only water corner
+
+ var surface = new TerrainSurface(new byte[81], new float[256], terrainTypes: types);
+
+ Assert.Equal(0.1f, surface.SampleWaterDepth(4f, 4f));
+ }
+
+ [Fact]
+ public void SampleWaterDepth_DryCorner_StaysBelowTheIsWaterClassificationThreshold()
+ {
+ // PhysicsEngine.SampleTerrainWalkable classifies isWater as
+ // waterDepth >= 0.45f. The restored 0.1f dry-corner value must NOT
+ // cross that threshold — only the sink-in depth changes, not whether
+ // the point is treated as "water" for contact-plane/animation purposes.
+ var types = new byte[81];
+ types[1 * 9 + 1] = WaterTerrainByte;
+ var surface = new TerrainSurface(new byte[81], new float[256], terrainTypes: types);
+
+ float dryDepth = surface.SampleWaterDepth(4f, 4f);
+ Assert.True(dryDepth < 0.45f, $"Dry-corner depth {dryDepth} must stay below the isWater threshold");
+ }
+
+ // ── §2: WATER_CONTACT_TS mirroring in PhysicsObjUpdate ─────────────────
+
+ private static PhysicsBody MakeBody(bool contactPlaneIsWater) => new()
+ {
+ TransientState = TransientStateFlags.None,
+ ContactPlaneIsWater = contactPlaneIsWater,
+ };
+
+ [Fact]
+ public void ApplySetPositionContact_WaterContactPlane_SetsWaterContactBit()
+ {
+ var body = MakeBody(contactPlaneIsWater: true);
+
+ PhysicsObjUpdate.ApplySetPositionContact(body, inContact: true, onWalkable: true);
+
+ Assert.True(body.IsWaterContact);
+ }
+
+ [Fact]
+ public void ApplySetPositionContact_DryContactPlane_ClearsWaterContactBit()
+ {
+ var body = MakeBody(contactPlaneIsWater: false);
+ body.TransientState |= TransientStateFlags.WaterContact; // pre-seed stale bit
+
+ PhysicsObjUpdate.ApplySetPositionContact(body, inContact: true, onWalkable: true);
+
+ Assert.False(body.IsWaterContact);
+ }
+
+ [Fact]
+ public void ApplySetPositionContact_WaterContactMirrorsIndependentlyOfContactBit()
+ {
+ // WaterContact tracks ContactPlaneIsWater, not the inContact argument
+ // itself — matches retail writing the two bits from two different
+ // per-call locals in the same statement block.
+ var body = MakeBody(contactPlaneIsWater: true);
+
+ PhysicsObjUpdate.ApplySetPositionContact(body, inContact: false, onWalkable: false);
+
+ Assert.False(body.InContact);
+ Assert.True(body.IsWaterContact);
+ }
+
+ [Fact]
+ public void CommitSetPositionTransition_WaterContactPlane_SetsWaterContactBit()
+ {
+ var body = MakeBody(contactPlaneIsWater: true);
+
+ PhysicsObjUpdate.CommitSetPositionTransition(
+ body,
+ inContact: true,
+ onWalkable: true,
+ collisionNormalValid: false,
+ collisionNormal: Vector3.Zero,
+ previousContact: false,
+ previousOnWalkable: false);
+
+ Assert.True(body.IsWaterContact);
+ }
+
+ [Fact]
+ public void CommitSetPositionTransition_DryContactPlane_ClearsWaterContactBit()
+ {
+ var body = MakeBody(contactPlaneIsWater: false);
+ body.TransientState |= TransientStateFlags.WaterContact; // pre-seed stale bit
+
+ PhysicsObjUpdate.CommitSetPositionTransition(
+ body,
+ inContact: true,
+ onWalkable: true,
+ collisionNormalValid: false,
+ collisionNormal: Vector3.Zero,
+ previousContact: true,
+ previousOnWalkable: true);
+
+ Assert.False(body.IsWaterContact);
+ }
+
+ // ── §3: end-to-end through PhysicsEngine.ResolveWithTransition ─────────
+ // (retail's actual SetPositionInternal-equivalent per-resolve commit)
+
+ private const uint TestLandblockId = 0xA9B40000u;
+ private const uint TestCellId = TestLandblockId | 0x0001u;
+ private const float SphereRadius = 0.4f;
+ private const float SphereHeight = 1.2f;
+
+ private static PhysicsEngine BuildEngineWithFlatWaterTerrain(bool water)
+ {
+ var cache = new PhysicsDataCache();
+ var engine = new PhysicsEngine { DataCache = cache };
+
+ var heights = new byte[81]; // all zero -> terrain Z = 0 everywhere
+ var heightTable = new float[256];
+ var types = water ? AllVertices(WaterTerrainByte) : AllVertices(DryTerrainByte);
+
+ engine.AddLandblock(
+ landblockId: TestLandblockId,
+ terrain: new TerrainSurface(heights, heightTable, terrainTypes: types),
+ cells: Array.Empty(),
+ portals: Array.Empty(),
+ worldOffsetX: 0f,
+ worldOffsetY: 0f);
+
+ return engine;
+ }
+
+ private static PhysicsBody MakeGroundedBody(Vector3 position)
+ {
+ var floorPlane = new Plane(Vector3.UnitZ, 0f);
+ var floorVerts = new[]
+ {
+ new Vector3(-100f, -100f, 0f),
+ new Vector3(100f, -100f, 0f),
+ new Vector3(100f, 100f, 0f),
+ new Vector3(-100f, 100f, 0f),
+ };
+
+ return new PhysicsBody
+ {
+ Position = position,
+ Orientation = Quaternion.Identity,
+ ContactPlaneValid = true,
+ ContactPlane = floorPlane,
+ ContactPlaneCellId = TestCellId,
+ WalkablePolygonValid = true,
+ WalkablePlane = floorPlane,
+ WalkableVertices = floorVerts,
+ WalkableUp = Vector3.UnitZ,
+ TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
+ };
+ }
+
+ ///
+ /// Lets a body sink/settle onto terrain via repeated resolves, exactly the
+ /// multi-tick pattern CylSphereFamilyTests.Grounded_WalkIntoWideLowCylinder
+ /// _StepsUpOntoTop uses. A single resolve's step-down is bounded
+ /// (WalkInterp), so a body starting well above a water-shifted resting
+ /// depth needs several ticks to converge — this mirrors retail's own
+ /// gradual settle, not an instant snap.
+ ///
+ private static void SettleOntoTerrain(PhysicsEngine engine, PhysicsBody body, int ticks = 60)
+ {
+ Vector3 pos = body.Position;
+ uint cellId = TestCellId;
+ bool grounded = true;
+ for (int tick = 0; tick < ticks; tick++)
+ {
+ var target = pos + new Vector3(0f, 0.001f, -0.05f);
+ var result = engine.ResolveWithTransition(
+ pos, target, cellId,
+ SphereRadius, SphereHeight,
+ stepUpHeight: 0.04f, stepDownHeight: 0.04f,
+ isOnGround: grounded,
+ body: body,
+ moverFlags: ObjectInfoState.IsPlayer,
+ movingEntityId: 0);
+ body.Position = result.Position;
+ pos = result.Position;
+ cellId = result.CellId;
+ grounded = result.IsOnGround;
+ }
+ }
+
+ [Fact]
+ public void EndToEnd_SettleOntoEntirelyWaterTerrain_SetsBodyWaterContact()
+ {
+ var engine = BuildEngineWithFlatWaterTerrain(water: true);
+ var body = MakeGroundedBody(new Vector3(12f, 12f, 1.0f));
+
+ SettleOntoTerrain(engine, body);
+
+ // Settles exactly waterDepth (0.9 m) below the nominal Z=0 terrain plane —
+ // the "submerged" visual retail produces (no separate water surface mesh;
+ // the character just sits lower than terrain by the allowed sink-in).
+ Assert.True(MathF.Abs(body.Position.Z - (-0.9f)) < 0.05f,
+ $"Body should settle 0.9m below the nominal terrain plane in an EntirelyWater cell; got Z={body.Position.Z:F3}");
+ Assert.True(body.ContactPlaneIsWater, "Body must record the water contact plane");
+ Assert.True(body.IsWaterContact, "WATER_CONTACT_TS must mirror ContactPlaneIsWater");
+ }
+
+ [Fact]
+ public void EndToEnd_SettleOntoDryTerrain_LeavesBodyWaterContactClear()
+ {
+ // Dry-land behavior unchanged: an ordinary flat dry landblock must
+ // never set WaterContact, exactly as before AP-10.
+ var engine = BuildEngineWithFlatWaterTerrain(water: false);
+ var body = MakeGroundedBody(new Vector3(12f, 12f, 1.0f));
+ body.TransientState |= TransientStateFlags.WaterContact; // pre-seed stale bit
+
+ SettleOntoTerrain(engine, body);
+
+ Assert.True(MathF.Abs(body.Position.Z) < 0.05f,
+ $"Body should settle exactly on the dry Z=0 terrain plane (no sink-in); got Z={body.Position.Z:F3}");
+ Assert.False(body.ContactPlaneIsWater);
+ Assert.False(body.IsWaterContact,
+ "Dry-land resolves must clear any stale WaterContact bit, not just leave it unset");
+ }
+}