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:
parent
d6c3f8657a
commit
cc8d57a26e
7 changed files with 439 additions and 18 deletions
|
|
@ -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
|
## #262 — Run-on-the-spot at first login: no displacement until a recall reset
|
||||||
|
|
||||||
**Status:** OPEN
|
**Status:** OPEN
|
||||||
|
|
|
||||||
|
|
@ -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
|
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
|
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-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-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-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-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-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 |
|
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -99,10 +99,24 @@ public enum TransientStateFlags : uint
|
||||||
StationaryStop = 0x00000020, // bit 5 — fsf == 2
|
StationaryStop = 0x00000020, // bit 5 — fsf == 2
|
||||||
StationaryStuck = 0x00000040, // bit 6 — fsf == 3
|
StationaryStuck = 0x00000040, // bit 6 — fsf == 3
|
||||||
Active = 0x00000080, // bit 7 — object needs per-frame update
|
Active = 0x00000080, // bit 7 — object needs per-frame update
|
||||||
// Declared to complete retail's TransientState (acclient.h:3688). Neither bit is
|
/// <summary>
|
||||||
// produced or consumed by acdream's transition yet; they are here so the two free
|
/// AP-10 (Campaign P Slice P4, 2026-07-30): retail <c>WATER_CONTACT_TS</c>
|
||||||
// slots cannot be reused for something else and quietly collide with the wire.
|
/// (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
|
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
|
CheckEthereal = 0x00000100, // bit 8 — CHECK_ETHEREAL_TS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -431,6 +445,15 @@ public sealed class PhysicsBody
|
||||||
public bool OnWalkable => (TransientState & TransientStateFlags.OnWalkable) != 0;
|
public bool OnWalkable => (TransientState & TransientStateFlags.OnWalkable) != 0;
|
||||||
public bool IsActive => (TransientState & TransientStateFlags.Active) != 0;
|
public bool IsActive => (TransientState & TransientStateFlags.Active) != 0;
|
||||||
public bool InContact => (TransientState & TransientStateFlags.Contact) != 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 ───────────────────────────────────────────────────────
|
// ── FUN_00511420 ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1298,6 +1298,17 @@ public sealed class PhysicsEngine
|
||||||
body.ContactPlaneValid = false;
|
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*
|
// 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);
|
// 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,
|
// acdream co-locates the encode with the fsf writeback here (STRUCTURAL ADAPTATION,
|
||||||
|
|
|
||||||
|
|
@ -42,6 +42,16 @@ public static class PhysicsObjUpdate
|
||||||
else
|
else
|
||||||
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
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();
|
body.calc_acceleration();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -96,6 +106,15 @@ public static class PhysicsObjUpdate
|
||||||
else
|
else
|
||||||
body.TransientState &= ~TransientStateFlags.OnWalkable;
|
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)
|
if (!previousOnWalkable && finalOnWalkable)
|
||||||
{
|
{
|
||||||
hitGround?.Invoke();
|
hitGround?.Invoke();
|
||||||
|
|
|
||||||
|
|
@ -517,22 +517,33 @@ public sealed class TerrainSurface
|
||||||
/// character while dry terrain keeps feet exactly on the plane.
|
/// character while dry terrain keeps feet exactly on the plane.
|
||||||
///
|
///
|
||||||
/// <para>
|
/// <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 > 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
|
/// Ported from ACE <c>ObjCell.get_water_depth</c> and
|
||||||
/// <c>LandblockStruct.calc_water_depth</c>, except that the retail
|
/// <c>LandblockStruct.calc_water_depth</c>.
|
||||||
/// 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.
|
|
||||||
/// </para>
|
/// </para>
|
||||||
/// <list type="bullet">
|
/// <list type="bullet">
|
||||||
/// <item>NotWater cell → <c>0.0f</c></item>
|
/// <item>NotWater cell → <c>0.0f</c></item>
|
||||||
/// <item>EntirelyWater cell → <c>0.9f</c> (fully submerged)</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 water type → <c>0.45f</c></item>
|
||||||
/// <item>PartiallyWater cell, nearest-corner non-water → <c>0.0f</c>
|
/// <item>PartiallyWater cell, nearest-corner non-water → <c>0.1f</c>
|
||||||
/// (retail uses 0.1, we use 0 to avoid the above-EPSILON dist bug)</item>
|
/// (retail's dry-corner sink-in)</item>
|
||||||
/// </list>
|
/// </list>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public float SampleWaterDepth(float localX, float localY)
|
public float SampleWaterDepth(float localX, float localY)
|
||||||
|
|
@ -548,14 +559,14 @@ public sealed class TerrainSurface
|
||||||
|
|
||||||
// PartiallyWater — resolve to nearest corner (retail picks the
|
// PartiallyWater — resolve to nearest corner (retail picks the
|
||||||
// terrain-vertex at the "+12" half of each axis, i.e. >= 12m into
|
// 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
|
// a 24m cell rounds up). 0.45 for a water corner, 0.1 for a dry
|
||||||
// (retail uses 0.1 for dry corners; see note above).
|
// corner (retail's allowed dry-corner sink-in; see note above).
|
||||||
float tx = fx - cx;
|
float tx = fx - cx;
|
||||||
float ty = fy - cy;
|
float ty = fy - cy;
|
||||||
int vx = cx + (tx >= 0.5f ? 1 : 0);
|
int vx = cx + (tx >= 0.5f ? 1 : 0);
|
||||||
int vy = cy + (ty >= 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>
|
/// <summary>
|
||||||
|
|
|
||||||
309
tests/AcDream.Core.Tests/Physics/Ap10WaterSemanticsTests.cs
Normal file
309
tests/AcDream.Core.Tests/Physics/Ap10WaterSemanticsTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Conformance tests for AP-10 (Campaign P Slice P4, 2026-07-30) — two
|
||||||
|
/// water-semantics gaps identified in
|
||||||
|
/// <c>docs/research/2026-07-29-remote-and-world-specials-pseudocode.md</c> §5:
|
||||||
|
///
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>
|
||||||
|
/// Retail's 0.1 m dry-corner water sink-in
|
||||||
|
/// (<see cref="TerrainSurface.SampleWaterDepth"/>) was collapsed to 0 —
|
||||||
|
/// now restored. <c>PhysicsEngine.SampleTerrainWalkable</c>'s
|
||||||
|
/// <c>isWater = waterDepth >= 0.45f</c> 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.
|
||||||
|
/// </item>
|
||||||
|
/// <item>
|
||||||
|
/// <c>WATER_CONTACT_TS</c> (<see cref="TransientStateFlags.WaterContact"/>)
|
||||||
|
/// was declared but never written — now mirrored alongside
|
||||||
|
/// <see cref="TransientStateFlags.Contact"/> at every commit point:
|
||||||
|
/// <see cref="PhysicsObjUpdate.ApplySetPositionContact"/>,
|
||||||
|
/// <see cref="PhysicsObjUpdate.CommitSetPositionTransition"/>, and
|
||||||
|
/// <c>PhysicsEngine</c>'s per-resolve body-state commit.
|
||||||
|
/// </item>
|
||||||
|
/// </list>
|
||||||
|
/// </summary>
|
||||||
|
public class Ap10WaterSemanticsTests
|
||||||
|
{
|
||||||
|
// ── §1: TerrainSurface.SampleWaterDepth golden values ──────────────────
|
||||||
|
|
||||||
|
/// <summary>terrainTypes byte whose (byte>>2)&0x1F == 0x10 (WaterRunning, the lowest water type).</summary>
|
||||||
|
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<CellSurface>(),
|
||||||
|
portals: Array.Empty<PortalPlane>(),
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lets a body sink/settle onto terrain via repeated resolves, exactly the
|
||||||
|
/// multi-tick pattern <c>CylSphereFamilyTests.Grounded_WalkIntoWideLowCylinder
|
||||||
|
/// _StepsUpOntoTop</c> 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.
|
||||||
|
/// </summary>
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue