From cc8d57a26ea0ead30901dd437d59d70de80f8467 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Jul 2026 10:52:39 +0200 Subject: [PATCH] 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 --- docs/ISSUES.md | 48 +++ .../retail-divergence-register.md | 4 +- src/AcDream.Core/Physics/PhysicsBody.cs | 29 +- src/AcDream.Core/Physics/PhysicsEngine.cs | 11 + src/AcDream.Core/Physics/PhysicsObjUpdate.cs | 19 ++ src/AcDream.Core/Physics/TerrainSurface.cs | 37 ++- .../Physics/Ap10WaterSemanticsTests.cs | 309 ++++++++++++++++++ 7 files changed, 439 insertions(+), 18 deletions(-) create mode 100644 tests/AcDream.Core.Tests/Physics/Ap10WaterSemanticsTests.cs 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"); + } +}