diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 67a890a3..a5aff0ea 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -1220,6 +1220,27 @@ matching the door's open/closed state.) (get_object_info pc:279992 governs only the NEXT frame — #116 notes); audit who clears the body's sliding normal when no contact recurs. + **MECHANISM 2 FIXED 2026-07-06 (audit complete — full lifecycle in + `docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md`):** + retail's ONLY in-transition sliding-normal writer is + `validate_transition` (0x0050ac21); the BSP/sphere layer never writes it, + and the body persistence (`SetPositionInternal` 0x005154c2/0x005154e1) + is success-only. Our BSPQuery Contact-branch full-hit responses were + STUBS (`SetSlidingNormal + return Slid`) where retail dispatches the + real `slide_sphere` — the seam hit (a SUCCESSFUL full-advance resolve, + `ok=True` in the log, not a failed one) leaked the phantom wall's normal + into the body, and the seed absorbed every later forward push. Fix: + both stub sites now route through the real + `Transition.SlideSphereInternal` (`CSphere::slide_sphere` 0x00537440, + in-frame, no sliding write) and the body writeback is gated on + transition success. Pins: `Issue137SlidingNormalLifecycleTests` (2 site + pins + the persist/absorb/clear wall lifecycle). Register: TS-4 amended + (steep-tangent sites still write the normal — documented), TS-45 added + (`SphereCollision`'s write — same class, out of blast radius). The + absorbed exactly-anti-parallel frame at a REAL wall is retail-faithful + (the persisted normal is a "still pressed" cache); only the phantom + PROVENANCE was the bug. Corridor re-test rides the mechanism-1 session. + **Files:** `src/AcDream.Core/Physics/` (EnvCell collision, CellTransit, the door apparatus), `src/AcDream.Core/Physics/ShadowObjectRegistry.cs` (per-cell registration). See `claude-memory/project_physics_collision_digest.md` (the collision SSOT + DO-NOT-RETRY table). diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 09d3f274..2cadd2bd 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -189,7 +189,7 @@ accepted-divergence entries (#96, #49, #50). | TS-1 | PrecipiceSlide context missing — conservative stop-at-edge instead of retail's EdgeSlide → PrecipiceSlide / CliffSlide | `src/AcDream.Core/Physics/TransitionTypes.cs:1254` | Awaiting the next L.2c slice; a diagnostic records which ingredient (precipice context / steep plane / EdgeSlide flag) is missing | Player stops dead at precipice edges where retail slides along/over — visible mismatch at cliff and roof edges | retail EdgeSlide → PrecipiceSlide chain | | TS-2 | `BspOnlyDispatch` reduces retail's `(HAS_PHYSICS_BSP_PS && !pvpTargetPlayer && !missileIgnore)` to the flag test alone (M1.5 scope: no PK, no missiles) | `src/AcDream.Core/Physics/TransitionTypes.cs:660` | Both omitted terms are genuinely false pre-M2; comment directs wiring them with PK (M2+) and missiles (F.3) | If PK or missiles land without the terms, flagged entities get BSP-only where retail tests cyl+sphere — pass-through / wrong blocking in PvP/missile interactions | `FindObjCollisions` pc:276861; HAS_PHYSICS_BSP_PS acclient.h:2833 | | TS-3 | `FramesStationaryFall` accounting absent (`moved = true` unconditionally in the accepted-move branch) | `src/AcDream.Core/Physics/TransitionTypes.cs:3691` | Explicitly deferred to the full physics port | A body wedged falling-in-place never triggers retail's stuck-fall escalation — indefinite falling-animation wedges | CPhysicsObj frames_stationary_fall | -| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place | `src/AcDream.Core/Physics/BSPQuery.cs:2001` | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | +| TS-4 | Path-6 steep-poly slide-tangent shortcut: airborne hits on >FloorZ polys skip retail's SetCollide → Path-4 → ContactPlane landing chain, returning Slid in place. **Includes a `SetSlidingNormal` write at both sites** — retail's BSP layer never writes `collision_info.sliding_normal` (only `validate_transition` 0x0050ac21 does; the #137 mechanism-2 class), so on transition success the steep-face normal persists to the body and seeds the next frame | `src/AcDream.Core/Physics/BSPQuery.cs` (Path-6 steep branches, `worldNormal.Z < FloorZ`) | Deliberate deviation: our faithful port DID wedge (missing step_up_slide / cliff_slide details on grounded-steep); validated against the 2026-04-30 retail cdb trace (retail body didn't wedge). Filed L.5+ for retail-strict | Airborne steep contact never commits Contact / lands as retail — roof-bounce trajectories, landing events, grounded-steep transitions diverge; a persisted steep-face normal can absorb an exactly-anti-parallel next-frame push (#137 wedge class) until an oblique input clears it | `BSPTREE::find_collisions` SetCollide pc:323783-323821 | | TS-5 | `CanJump` always true — burden/stamina gating deferred (stat plumbing incomplete pre-M2). R3-W3 extends this row: `IWeenieObject.JumpStaminaCost`/`PlayerWeenie.JumpStaminaCost` are new (feeding `jump_is_allowed`'s verbatim stamina-refusal branch) and are ALSO always-affordable/cost-0 stubs for the same reason | `src/AcDream.Core/Physics/PlayerWeenie.cs:44` (`CanJump`), `:52` (`JumpStaminaCost`, R3-W3) | Marked deferred; harmless until stats matter | Client launches jumps retail refuses (exhausted/overburdened) — server rejection / rubber-band; divergent jump availability vs retail muscle memory | CMotionInterp jump path stamina/burden inquiry; `jump_is_allowed` 0x005282b0 `JumpStaminaCost` vtable +0x44 | | TS-6 | Weather particle emission suppressed — all weathery DayGroups map to Overcast (correct fog/cloud tone, no precipitation); retail's camera-attached weather subsystem not yet located in the decomp | `src/AcDream.Core/World/WeatherState.cs:200` | Decomp research verified the sky loop never reads `DefaultPesObjectId`; an earlier name-based rain spawn regressed (rained where retail didn't, 2026-04-23) — inventing a name→rain path is forbidden until the real subsystem is found | Rainy/snowy/stormy days never show retail's precipitation effects (permanent missing visuals until the subsystem is found and ported) | FUN_00508010 / FUN_0051bed0→FUN_0051bfb0 (negative findings) | | TS-7 | SkyObject `weather_enabled` gate not honored — weather-flagged sky objects (bit 0x04) always instantiate | `src/AcDream.Core/World/SkyDescLoader.cs:50` | No weather_enabled toggle exists yet; IsWeather flag parsed + documented as the gate to wire | Weather-only sky meshes (rain cylinders) appear where retail-with-weather-off suppresses them | `GameSky::MakeObject` 0x00506ee0, guard at decomp:268630 | @@ -225,6 +225,7 @@ accepted-divergence entries (#96, #49, #50). | TS-41 | Grounded remote NPCs WITHOUT an armed moveto are body-driven by UP-synthesized server velocity (`HasServerVelocity` → the SERVERVEL per-tick leg: `Body.Velocity = ServerVelocity`, `MovementManager.UseTime` (the R5-V5 facade relay, ex-loose `MoveToManager.UseTime`) SKIPPED, stale-decay stop via `ApplyServerControlledVelocityCycle(Zero)`); retail has no wire-velocity leg-driver anywhere — `MovementManager::UseTime` runs UNCONDITIONALLY per tick and between-UP translation comes from the motion state (`get_state_velocity`), UPs only hard-snap. **#170 residual fix narrowed this branch: an ARMED moveto (`MovementTypeState != Invalid`) now always takes the MOVETO leg** — the old arbitration starved the verbatim MoveToManager for exactly the duration of a server-side chase (UPs flowing → UseTime never ran → legs stayed Ready while the body glided = the #170 slide; live funnel 16 arms → 1 run install). **R5-V3 (#171) narrowed it again: a STUCK entity (`PositionManager.GetStickyObjectId() != 0`) also takes the MOVETO leg** — after the sticky arrival the moveto is cleaned (Invalid) but `StickyManager::adjust_offset` owns the between-snap translation; SERVERVEL would glide the body against the sticky steer (same starvation class) | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` grounded NPC branch, `moveToArmed`+`stickyArmed` gate) | ACE moves some entities by position updates alone (scripted paths, missiles) with no UM/moveto stream — without a velocity fallback they freeze between UPs; entities WITH a moveto now get the retail drive | An entity class that carries BOTH wire velocity and an armed moveto with conflicting truths follows the moveto; UP hard-snaps bound the drift. Non-moveto entities keep the non-retail stale-stop heuristics (AP-80 thresholds) | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 (`MovementManager::UseTime` call @0x00515998, unconditional); retire in R6 (full per-tick order) | | TS-42 | Per-tick DRAIN ORDER inverted vs retail: acdream's `TickAnimations` runs `HandleTargetting` → `Movement.UseTime` (the R5-V5 MovementManager relay, ex-loose `MoveTo.UseTime`) FIRST and the animation-completion drain (Sequencer.Advance → AnimDone hooks → `MotionTableManager.AnimationDone`/`UseTime` → `CMotionInterp.MotionDone` pops) LAST, so every motion-completion-gated decision (`BeginTurnToHeading`'s `motions_pending` wait) sees a queue that is one frame STALE — the unblock after a stop/swing lands one frame later than retail. Retail order (pinned from the named decomp this session): `UpdatePositionInternal` (CPartArray::Update + `process_hooks` @0x00512d3d — the drain) runs BEFORE `TargetManager::HandleTargetting` @0x00515989 → `MovementManager::UseTime` @0x00515998 → `CPartArray::HandleMovement` @0x005159a4 (zero-tick sweep) in `UpdateObjectInternal` | `src/AcDream.App/Rendering/GameWindow.cs` (`TickAnimations` per-entity phase order; the R2-Q4 comment already marks the placement "provisional until R6") | Bounded to exactly ONE frame (~16 ms) of extra latency per completion-gated event; every queue eventually drains identically (RemoteChaseEndToEndHarnessTests conformance) | Motion-completion-gated transitions (chase turn start, post-swing re-arm) systematically lag retail by one frame; under compound churn the lag can cost an extra retry cycle | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 + `UpdatePositionInternal` 0x00512c30 (`process_hooks` @0x00512d3d); retire in R6 (retail UpdateObjectInternal order) | | TS-44 | NPC UpdatePosition hard-snaps (position @`OnLivePositionUpdated` + orientation + velocity/cycle adoption) are SUPPRESSED while the entity is stuck (`PositionManager.GetStickyObjectId() != 0`) — an adaptation of retail's chain semantics to the legacy snap path: retail routes UP corrections through the InterpolationManager into the SAME per-tick `PositionManager::adjust_offset` chain where `StickyManager::adjust_offset` OVERWRITES them while armed (0x00555190 order, 0x00555430 assigns m_fOrigin), so a server correction can never fight an armed stick; the legacy NPC path snaps OUTSIDE the chain, producing snap-out/steer-back position flapping + stale-facing stomps (the 2026-07-04 #171 gate residuals). Bookkeeping (`LastServerPos/Time`, cell) still records; server truth reasserts on the first UP after unstick, bounded by the 1 s sticky lease | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` NPC section, `snapSuppressedByStick` gate) | Retail's mechanism (sticky-overwrites-interp) is unreachable until the NPC path gets the interp-queue architecture (the player-remote branch already has it — the R5-V3 combiner→sticky chain); the gate reproduces the retail-observable behavior on the snap architecture | A stick that stays armed while ACE moves the monster far (shouldn't happen — sticks follow the target by construction) would drift until unstick+next UP; worst case bounded by the 1 s lease + the next UM re-arm | `PositionManager::adjust_offset` 0x00555190; `InterpolationManager` UP routing (`CPhysicsObj::MoveOrTeleport`); retire when the NPC path unifies onto the interp queue (S6/R6) | +| TS-45 | `SphereCollision` (the shadow-object Sphere response) is a hand-rolled 3-D wall-slide that ALSO calls `SetSlidingNormal` — retail's `CSphere::intersects_sphere` (0x00537A80) dispatches `CSphere::slide_sphere` (0x00537440), which slides in-frame and never writes `collision_info.sliding_normal` (the only in-transition writer is `validate_transition` 0x0050ac21). Same leak class as the #137 mechanism-2 stubs fixed 2026-07-06 (BSPQuery Contact branch); left in place because the response's blocking semantics for sphere-shaped server objects are untested against the real slide and #171 sticky-melee behavior is freshly gated | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SphereCollision`, the `ci.SetSlidingNormal` tail) | The in-frame push-out already moves the check position; the extra sliding normal only persists on transition success, and pure-Sphere shadow shapes are rare (most creatures/statics are CylSphere, which routes through the real `SlideSphere` since #172) | A sphere-shaped object touch persists a normal retail would discard — an exactly-anti-parallel follow-up push absorbs to a zero offset (#137 wedge class) at that object until an oblique input clears it | `CSphere::intersects_sphere` 0x00537A80 → `slide_sphere` 0x00537440 (pc:321678+); fix = route the tail through `Transition.SlideSphere` like `CylSlideSphere` does | | TS-43 | Remote teleport has no `teleport_hook` equivalent: retail tears down the position managers on every teleport (`CPhysicsObj::teleport_hook` 0x00514ed0 — `CancelMoveTo(0x3c)` @0x00514edf, `PositionManager::UnStick` @0x00514eee, `StopInterpolating`/`UnConstrain`); acdream's remote teleport is a bare UP hard-snap, so a stuck/chasing remote that the server teleports keeps its stick/moveto for up to the 1 s sticky lease / next UM. The LOCAL player side IS wired (R5-V3: `PlayerMovementController.SetPosition` → `PositionManager.UnStick`; the moveto cancel was already there via `StopCompletely`; the teleport-arrival site also fires the hook's tail — `EntityPhysicsHost.NotifyTeleported` = `TargetManager::ClearTarget` + `NotifyVoyeurOfEvent(Teleported)` @0x00514f1b-0x00514f28, which is what makes mobs stuck to the player drop their sticks on a recall) | `src/AcDream.App/Rendering/GameWindow.cs` (`OnLivePositionUpdated` — no teleport-flag manager teardown for remotes) | Remote teleports are rare (recalls/summons); the sticky 1 s lease + UP hard-snaps self-correct within a second; wiring it properly wants the UP teleport-stamp plumbing (TS-26's stamp work) | A teleported-away attacker briefly steers toward its pre-teleport target from the new location (≤1 s) before the lease/next-UM corrects it | `CPhysicsObj::teleport_hook` 0x00514ed0; retire with the TS-26 UP-stamp port | --- diff --git a/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md b/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md new file mode 100644 index 00000000..b23773a3 --- /dev/null +++ b/docs/research/2026-07-06-137-sliding-normal-lifecycle-audit.md @@ -0,0 +1,82 @@ +# #137 mechanism 2 — the sliding-normal lifecycle audit (2026-07-06) + +The pickup prompt (`2026-07-06-137-corridor-phantom-pickup-prompt.md`) asked: +*who writes the body's persisted SlidingNormal, and where does retail CLEAR +it when contact does not recur?* This note is the complete decomp-verified +answer, the divergence map it produced, and the fix that shipped. + +## Retail lifecycle (every site, named-retail-verified) + +`collision_info.sliding_normal` (per-transition) and +`CPhysicsObj::sliding_normal` + `SLIDING_TS` (transient_state bit 4, +body-persisted) form a two-level cache: + +| Step | Function | Address / pc line | What it does | +|---|---|---|---| +| seed | `CPhysicsObj::get_object_info` | 0x00511cc0, seed @0x00511d44 | `if (transient_state & 4) CTransition::init_sliding_normal(&this->sliding_normal)` — last frame's persisted normal seeds the new transition | +| consume | `CTransition::adjust_offset` | 0x0050a370 | `dot(offset, sliding_normal) < 0` → project the per-step offset (crease `cross(contact_plane.N, sliding_normal)` when grounded, `offset −= n·dot` otherwise); `dot >= 0` (moving away) → `sliding_normal_valid = 0` | +| step gate | `CTransition::find_transitional_position` | 0x0050bdf0, small-offset @0x0050bf83-0x0050bfb7 | adjusted offset `|off|² < EPSILON²` at step 0 → transition FAILS; at step i>0 → succeed iff last validate state OK. **The absorbed frame is retail-faithful** — the persisted normal is a "still pressed against this wall" cache that suppresses re-testing | +| per-step clear | same | @0x0050c010 | `sliding_normal_valid = 0` (+ contact plane) BEFORE each step's `transitional_insert` — a step that runs and does not re-collide leaves the transition clean | +| in-transition write | `CTransition::validate_transition` | 0x0050aa70, write @0x0050ac21-ac30 | `if (collision_normal_valid) set_sliding_normal(collision_normal)` — **the ONLY in-transition writer**. Fires when a step needed collision handling | +| body writeback | `CPhysicsObj::SetPositionInternal` | copy @0x005154c2, bit sync @0x005154e1 | `sliding_normal = transition's; SLIDING_TS ⇔ sliding_normal_valid`. **Success-only** — a failed `find_valid_position` discards the transition whole; the body keeps its prior state | +| NOT writers | `CSphere::slide_sphere` 0x00537440, `CCylSphere::slide_sphere` 0x0053b2a0, `BSPTREE::slide_sphere`/`step_sphere_up`/`find_collisions` | pc:321400+, 323700+ | grep-verified: **zero** `sliding_normal` references in the whole sphere/BSP layer (nothing between pc 283518 and 1155326). The sphere-level slide is IN-FRAME (`add_offset_to_check_pos`) | + +So the answer to "where does retail clear it": **the success writeback** +(bit 4 syncs to the transition's final `sliding_normal_valid`, which the +per-step clear leaves false unless the last step's validate re-recorded a +collision) plus `adjust_offset`'s moving-away invalidation. On a FAILED +transition nothing clears it — and nothing needs to, because a persisted +normal can only have come from a validate write against real geometry +(pressed-at-a-wall is a correct absorbed state; any oblique input escapes +via the tangential projection remainder and the escape frame's writeback +clears the bit). + +ACE mirrors all of it: seed `PhysicsObj.cs:2611`, writeback +`PhysicsObj.cs:1249-1251`, validate write `Transition.cs:1027`, the only +`SetSlidingNormal` call sites in ACE's whole physics tree. + +## The wedge (live evidence, launch-175-verify2.log:42858) + +The seam-hit frame **succeeded with full advance** (`ok=True`, +`out == tgt` in XY, +8 mm step-up settle, crossing 0x8A02016E→0x8A02017A) +and still recorded `hit=yes n=(−1.00,0.03,−0.03)`. Retail ending that frame +would write back `sliding_normal_valid=0` (no blocked step at the end → the +per-step clear wins) and the bit would CLEAR. We persisted a normal anyway — +because our BSP Contact branch carried **stub** slide responses +(`SetCollisionNormal + SetSlidingNormal + return Slid`) at the sites where +retail dispatches the real `slide_sphere`. Every following forward resolve +then seeded the stale normal, `adjust_offset` projected the +exactly-anti-parallel corridor push to zero, and the step-0 abort returned +`ok=False hit=no` with zero advance — before any collision test could +refresh the state. An absorbing wedge; strafing escapes because an oblique +offset keeps a tangential remainder. + +## Divergence map → what shipped + +| Site | Was | Retail | Action | +|---|---|---|---| +| `BSPQuery` Contact foot full-hit, step-up unavailable (recursion guard / engine-null) | stub | blocked step-up funnels to `step_up_slide` → `CSphere::slide_sphere` | **FIXED** — routes through `Transition.SlideSphereInternal` (the real port, #116-verified thresholds) | +| `BSPQuery` Contact head full-hit | stub | `BSPTREE::slide_sphere` @0x0053a697 (ACE BSPTree.cs:202, 310-316 — slides GlobalSphere[0]) | **FIXED** — same routing; the dead private stub rewritten as the faithful `BSPTREE::slide_sphere` wrapper | +| `PhysicsEngine.ResolveWithTransition` sliding writeback | unconditional (ran on `ok=False`) | `SetPositionInternal` success-only | **FIXED** — gated on `ok` (behaviorally latent today: a failed transition's ci always still holds the seed, so gate-vs-rewrite is value-identical; the gate removes the class) | +| `BSPQuery` Path-6 steep slide-tangent (2 sites) | in-frame projection + `SetSlidingNormal` | no BSP-layer write | left (documented deviation TS-4 — row amended to name the sliding write); L.5+ retail-strict follow-up | +| `Transition.SphereCollision` (shadow Sphere objects) | hand-rolled slide + `SetSlidingNormal` | `CSphere::intersects_sphere` → `slide_sphere`, no write | left — **new register row TS-45**; fix = route the tail through `SlideSphere` like `CylSlideSphere` (#172) does | +| seed / step loop / `AdjustOffset` / validate write @TransitionTypes:4317 / real `SlideSphere` port | — | — | verified faithful, unchanged | + +Tests: `Issue137SlidingNormalLifecycleTests` — two site pins (Contact +foot-fallback + head full-hit must not write the sliding normal; face-on +grounded → `Collided` per the degenerate crease projection) + the +engine-level wall lifecycle pin (persist-on-block via validate → +absorbed exactly-anti-parallel frame → oblique escape CLEARS the body +state). Full solution suite green (Core 2545 / App 713 / UI 425 / Net 385). + +## What this does and does not fix in the corridor + +The wedge (mechanism 2) is fixed at its entry: a full-advance hit can no +longer persist a phantom normal, so the corridor dead-stop degrades to +whatever mechanism 1 actually produces — a momentary stutter if the portal +poly only face-hits in the crossing window, or an honest (re-tested, +strafe-escapable) wall if it keeps colliding. **Mechanism 1 — PortalSide +portal polygons being solid in our physics set — is still open**; the +pickup's step −1 cdb session (trace whether retail's BSP test chain ever +TESTS those polys, or membership order never reaches them from the passable +side) remains the next action, and needs the user to run retail. diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index 87c127dc..3538e497 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1399,26 +1399,35 @@ public static class BSPQuery // ------------------------------------------------------------------------- // slide_sphere — BSPTree level - // ACE: BSPTree.cs slide_sphere + // Retail: BSPTREE::slide_sphere (find_collisions Contact head-hit dispatch + // at 0x0053a697). ACE: BSPTree.cs:310-316. // ------------------------------------------------------------------------- /// - /// BSPTree.slide_sphere — apply sliding collision response. + /// BSPTree.slide_sphere — dispatch the real sphere-level slide + /// (CSphere::slide_sphere 0x00537440, ported as + /// ): slide IN-FRAME along the + /// crease between the collision normal and the contact plane, applied to + /// sphere 0's check position (ACE BSPTree.cs:315 — + /// GlobalSphere[0].SlideSphere(..., GlobalCurrCenter[0].Center)). /// /// - /// Sets the sliding normal on CollisionInfo so the outer transition loop - /// applies a wall-slide projection. + /// #137 mechanism 2 (2026-07-06): this was a stub that set + /// CollisionInfo.SlidingNormal and returned Slid. Retail's BSP layer + /// never writes the sliding normal — its only in-transition writer is + /// CTransition::validate_transition (0x0050ac21) — so the stub's + /// leaked normal survived to the body writeback and absorbed the next + /// frame's exactly-anti-parallel offset: the Facility Hub corridor + /// phantom's dead-stop half (ISSUES #137). /// - /// - /// ACE: BSPTree.cs slide_sphere — calls GlobalSphere[0].SlideSphere. /// + /// Collision normal already in world space (the + /// call sites apply L2W; ACE globalizes inside slide_sphere instead). private static TransitionState SlideSphere( Transition transition, - Vector3 collisionNormal) - { - transition.CollisionInfo.SetSlidingNormal(collisionNormal); - return TransitionState.Slid; - } + Vector3 worldNormal) + => transition.SlideSphereInternal( + worldNormal, transition.SpherePath.GlobalCurrCenter[0].Origin); // ------------------------------------------------------------------------- // collide_with_pt — BSPTree level @@ -1894,11 +1903,14 @@ public static class BSPQuery if (engine is not null && !path.StepUp && !path.StepDown) return StepSphereUp(transition, worldNormal, engine); - // No engine OR step-up/step-down already in progress — fall - // back to wall-slide. - collisions.SetCollisionNormal(worldNormal); - collisions.SetSlidingNormal(worldNormal); - return TransitionState.Slid; + // No engine OR step-up/step-down already in progress — the + // real slide response. Retail: a blocked step-up funnels to + // SPHEREPATH::step_up_slide → CSphere::slide_sphere (ACE + // SpherePath.cs:316); the slide records the collision normal + // itself and never writes the sliding normal (#137 mechanism + // 2 — the old SetSlidingNormal stub here leaked a normal that + // wedged the next frame's forward offset). + return SlideSphere(transition, worldNormal); } // Sphere 0 didn't fully hit. Per retail, the head-sphere test AND @@ -1937,9 +1949,10 @@ public static class BSPQuery PhysicsDiagnostics.LastBspHitPoly = hitPoly1; var worldNormal = L2W(hitPoly1!.Plane.Normal); - collisions.SetCollisionNormal(worldNormal); - collisions.SetSlidingNormal(worldNormal); - return TransitionState.Slid; + // Retail head-sphere full hit → BSPTREE::slide_sphere + // (0x0053a697; ACE BSPTree.cs:202) — the real in-frame + // slide, no sliding-normal write (#137 mechanism 2). + return SlideSphere(transition, worldNormal); } // Sphere 1 (head) near-miss → neg_poly_hit, neg_step_up = false → outer slide. diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index 1090559e..da52716b 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1085,16 +1085,27 @@ public sealed class PhysicsEngine body.WalkableVertices = null; } - if (ci.SlidingNormalValid - && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + // Retail persists sliding state to the body ONLY on transition + // SUCCESS: CPhysicsObj::SetPositionInternal copies the normal at + // 0x005154c2 and syncs SLIDING_TS (bit 4) from the transition's + // final sliding_normal_valid at 0x005154e1 — and SetPositionInternal + // is unreachable when find_valid_position fails (the transition is + // discarded whole; the body keeps its prior state). #137 mechanism + // 2: an unconditional writeback here could persist a normal retail + // would discard. + if (ok) { - body.SlidingNormal = ci.SlidingNormal; - body.TransientState |= TransientStateFlags.Sliding; - } - else - { - body.SlidingNormal = Vector3.Zero; - body.TransientState &= ~TransientStateFlags.Sliding; + if (ci.SlidingNormalValid + && ci.SlidingNormal.LengthSquared() > PhysicsGlobals.EpsilonSq) + { + body.SlidingNormal = ci.SlidingNormal; + body.TransientState |= TransientStateFlags.Sliding; + } + else + { + body.SlidingNormal = Vector3.Zero; + body.TransientState &= ~TransientStateFlags.Sliding; + } } // L.4 retail-strict (2026-04-30): apply OBJECTINFO::kill_velocity. diff --git a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs index c2474e4e..be9b279a 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPQueryTests.cs @@ -699,8 +699,13 @@ public class BSPQueryTests // Regression guard for the FULL-HIT case in the same Path 5 branch. // Sphere overlaps wall AND moves INTO it: moveDot < 0, cull does NOT // reject, pos_hits_sphere returns 1, Path 5 takes the `if (hit0)` - // branch. With engine=null we fall through to the slide fallback - // (SetCollisionNormal + SetSlidingNormal + return Slid). + // branch. With engine=null we fall through to the real slide + // (CSphere::slide_sphere via Transition.SlideSphereInternal). No + // contact plane is seeded on this bare Transition, so the slide takes + // the wall-only branch (project out the into-wall displacement, + // return Slid) — and per retail it must NOT write the sliding normal + // (#137 mechanism 2; validate_transition 0x0050ac21 is the only + // in-transition writer). var (root, resolved) = BuildSingleWallBsp(); var transition = new Transition(); @@ -731,6 +736,9 @@ public class BSPQueryTests Assert.Equal(TransitionState.Slid, state); Assert.True(transition.CollisionInfo.CollisionNormalValid, "Full hit should set the collision normal (slide fallback)."); + Assert.False(transition.CollisionInfo.SlidingNormalValid, + "find_collisions must not write the sliding normal — retail's " + + "only in-transition writer is validate_transition (#137)."); Assert.False(transition.SpherePath.NegPolyHit, "Full hit should NOT also fire NegPolyHit — that's the near-miss " + "path only. Retail at acclient_2013_pseudo_c.txt:0053a647 returns " + diff --git a/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs b/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs new file mode 100644 index 00000000..a5e2899b --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue137SlidingNormalLifecycleTests.cs @@ -0,0 +1,307 @@ +using System; +using System.Collections.Generic; +using System.Numerics; +using DatReaderWriter.Enums; +using DatReaderWriter.Types; +using AcDream.Core.Physics; +using Xunit; +using Plane = System.Numerics.Plane; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #137 mechanism 2 — the sliding-normal absorbing wedge (2026-07-06). +/// +/// +/// Retail's in-transition collision_info.sliding_normal has exactly ONE +/// writer besides the per-frame seed: CTransition::validate_transition +/// (0x0050ac21-ac30, "if collision_normal_valid → set_sliding_normal"). The +/// BSP collision layer NEVER writes it — BSPTREE::find_collisions' +/// Contact branch dispatches full hits to step_sphere_up (foot, +/// 0x0053a719) / BSPTREE::slide_sphere (head, 0x0053a697), and +/// CSphere::slide_sphere (0x00537440) slides IN-FRAME via +/// add_offset_to_check_pos without touching sliding_normal +/// (grep-verified: zero sliding_normal references between 0x005155 and +/// 0x00841f in acclient_2013_pseudo_c.txt). ACE mirrors this: the only +/// SetSlidingNormal call sites are CollisionInfo.cs:58 (the setter) and +/// Transition.cs:1027 (validate). The body-side persistence +/// (CPhysicsObj::SetPositionInternal 0x005154c2, SLIDING_TS bit-4 sync +/// at 0x005154e1) runs only on transition SUCCESS. +/// +/// +/// +/// acdream's BSPQuery Contact branch carried stub fallbacks +/// (SetCollisionNormal + SetSlidingNormal + return Slid) instead of the real +/// slide. The leaked sliding normal survived to the transition end, the +/// unconditional body writeback persisted it, and the next frame's seed +/// projected an exactly-anti-parallel push to zero — aborting at step 0 +/// BEFORE any collision test could refresh the state. Live shape: the +/// Facility Hub corridor phantom (launch-175-verify2.log:42858 — one wall +/// hit at the 0x8A02016E→0x8A02017A seam, then endless ok=False hit=no +/// zero-advance resolves; strafe escapes). +/// +/// +public class Issue137SlidingNormalLifecycleTests +{ + // ========================================================================= + // Site-level pins — BSPQuery.FindCollisions Contact branch must not write + // the transition's sliding normal (retail: only validate_transition does). + // ========================================================================= + + /// + /// Contact foot-sphere FULL HIT with the step-up recursion unavailable + /// (engine=null / step-up already in progress) must dispatch the real + /// sphere slide — never the SetSlidingNormal stub. + /// + /// + /// Retail: a blocked step-up funnels to SPHEREPATH::step_up_slide → + /// CSphere::slide_sphere (ACE SpherePath.cs:316 → Sphere.cs:558) — + /// in-frame slide, no sliding_normal write. Face-on into a vertical wall + /// while grounded: the crease projection (cross(wallN, floorN)) has no + /// component along the movement, the slide offset is degenerate + /// (< F_EPSILON), and slide_sphere returns COLLIDED_TS (0x00537735). + /// + /// + [Fact] + public void ContactFootFullHit_StepUpUnavailable_RealSlide_NoSlidingNormalWrite() + { + var (root, resolved) = BSPStepUpFixtures.TallWall(); + + // Grounded mover pushing face-on (+X) into the 5 m wall at x=0.5 + // (normal −X). Sphere center reach 0.35+0.2=0.55 penetrates the wall. + var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius); + var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius); + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); + + var localSphere = new DatReaderWriter.Types.Sphere + { + Origin = to, + Radius = BSPStepUpFixtures.SphereRadius, + }; + + var result = BSPQuery.FindCollisions( + root, resolved, t, localSphere, null, + from, Vector3.UnitZ, 1.0f); + + Assert.False(t.CollisionInfo.SlidingNormalValid, + "find_collisions must not write collision_info.sliding_normal — " + + "retail's only in-transition writer is validate_transition " + + "(0x0050ac21). A sliding normal leaked here survives to the body " + + "writeback and absorbs the next frame's forward offset (#137)."); + Assert.True(t.CollisionInfo.CollisionNormalValid, + "The real slide records the collision normal (CSphere::slide_sphere " + + "→ set_collision_normal)."); + Assert.Equal(TransitionState.Collided, result); + } + + /// + /// Contact HEAD-sphere FULL HIT must dispatch BSPTREE::slide_sphere + /// (retail 0x0053a697; ACE BSPTree.cs:202 → 310-316: the real + /// Sphere.SlideSphere on GlobalSphere[0]) — never the stub. + /// The corridor phantom's portal-side polys span head height; this is the + /// path that recorded the (−1,0,0) normal the wedge absorbed on. + /// + [Fact] + public void ContactHeadFullHit_RealSlide_NoSlidingNormalWrite() + { + // Raised wall: z ∈ [0.6, 5] at x=0.5, normal −X. The foot sphere + // (center z=0.2, r=0.2 → z-span [0, 0.4]) passes under it; the head + // sphere (center z=0.8 → z-span [0.6, 1.0]) fully hits it. + var resolved = new Dictionary(); + + var floorVerts = new[] + { + new Vector3(-2f, -1f, 0f), new Vector3(2f, -1f, 0f), + new Vector3(2f, 1f, 0f), new Vector3(-2f, 1f, 0f), + }; + resolved[1] = new ResolvedPolygon + { + Vertices = floorVerts, + Plane = new Plane(Vector3.UnitZ, 0f), + NumPoints = 4, + SidesType = CullMode.None, + }; + + var wallNormal = new Vector3(-1f, 0f, 0f); + var wallVerts = new[] + { + new Vector3(0.5f, -1f, 0.6f), + new Vector3(0.5f, -1f, 5f), + new Vector3(0.5f, 1f, 5f), + new Vector3(0.5f, 1f, 0.6f), + }; + resolved[2] = new ResolvedPolygon + { + Vertices = wallVerts, + Plane = new Plane(wallNormal, 0.5f), // n·p + d = 0 at x=0.5 + NumPoints = 4, + SidesType = CullMode.None, + }; + + var leaf = new PhysicsBSPNode + { + Type = BSPNodeType.Leaf, + BoundingSphere = new DatReaderWriter.Types.Sphere { Origin = new Vector3(0f, 0f, 2.5f), Radius = 10f }, + }; + leaf.Polygons.Add(1); + leaf.Polygons.Add(2); + + var from = new Vector3(0.10f, 0f, BSPStepUpFixtures.SphereRadius); + var to = new Vector3(0.35f, 0f, BSPStepUpFixtures.SphereRadius); + var t = BSPStepUpFixtures.MakeGroundedTransition(from, to); + + var footSphere = new DatReaderWriter.Types.Sphere + { + Origin = to, + Radius = BSPStepUpFixtures.SphereRadius, + }; + var headSphere = new DatReaderWriter.Types.Sphere + { + Origin = new Vector3(to.X, to.Y, 0.8f), + Radius = BSPStepUpFixtures.SphereRadius, + }; + + var result = BSPQuery.FindCollisions( + leaf, resolved, t, footSphere, headSphere, + from, Vector3.UnitZ, 1.0f); + + Assert.False(t.CollisionInfo.SlidingNormalValid, + "Head full hit must go through the real BSPTREE::slide_sphere — " + + "no sliding_normal write at the BSP layer (retail 0x0053a697)."); + Assert.True(t.CollisionInfo.CollisionNormalValid); + Assert.Equal(TransitionState.Collided, result); + } + + // ========================================================================= + // Engine-level lifecycle pin — the retail persist/absorb/clear cycle at a + // REAL wall. Guards the fix against regressing wall behavior, and + // documents where retail CLEARS the body's sliding state (the successful + // transition's writeback, when no step re-records a collision). + // ========================================================================= + + private const uint CellId = 0xA9B40157u; + + private static PhysicsEngine BuildWallEngine() + { + var (wallRoot, wallResolved) = BSPStepUpFixtures.TallWall(); + + var cell = new CellPhysics + { + BSP = new PhysicsBSPTree { Root = wallRoot }, + WorldTransform = Matrix4x4.Identity, + InverseWorldTransform = Matrix4x4.Identity, + Resolved = wallResolved, + CellBSP = new CellBSPTree + { + Root = new CellBSPNode { Type = BSPNodeType.Leaf }, + }, + }; + + var engine = new PhysicsEngine(); + engine.DataCache = new PhysicsDataCache(); + + // Flat terrain strip so the outdoor fall-through has something to + // sample if it ever fires (same shape as FindEnvCollisionsMultiCellTests). + var heights = new byte[81]; + Array.Fill(heights, (byte)0); + engine.AddLandblock(0xA9B4FFFFu, new TerrainSurface(heights, BuildHeightTable()), + Array.Empty(), Array.Empty(), + worldOffsetX: 0f, worldOffsetY: 0f); + + engine.DataCache.RegisterCellStructForTest(CellId, cell); + return engine; + } + + private static float[] BuildHeightTable() + { + var ht = new float[256]; + for (int i = 0; i < 256; i++) ht[i] = i * 1.0f; + return ht; + } + + private static PhysicsBody GroundedBody() + { + var body = new PhysicsBody(); + body.ContactPlaneValid = true; + body.ContactPlane = new Plane(Vector3.UnitZ, 0f); + body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; + return body; + } + + private ResolveResult ResolveForward(PhysicsEngine engine, PhysicsBody body, + Vector3 from, Vector3 to) + => engine.ResolveWithTransition( + currentPos: from, + targetPos: to, + cellId: CellId, + sphereRadius: BSPStepUpFixtures.SphereRadius, + sphereHeight: 0f, // single sphere — keeps the scenario deterministic + stepUpHeight: 0.04f, // cannot scale the 5 m wall + stepDownHeight: 0.04f, + isOnGround: true, + body: body); + + /// + /// The full retail lifecycle at a real wall: + /// (1) a blocked face-on push persists the validate-recorded sliding + /// normal via the SUCCESS writeback (SetPositionInternal bit-4 sync, + /// 0x005154e1); + /// (2) the next exactly-anti-parallel push is absorbed by the seed + /// (get_object_info 0x00511d44 → adjust_offset projects to zero → + /// find_transitional_position's step-0 small-offset abort) — the + /// retail cache semantics: "still pressed against this wall"; + /// (3) an oblique push escapes along the wall tangent, the step runs + /// without re-recording a collision, and the successful writeback + /// CLEARS the body's sliding state (sliding_normal_valid==0 → bit + /// 4 cleared). + /// + [Fact] + public void WallLifecycle_PersistOnBlock_AbsorbExactAntiParallel_ClearOnEscape() + { + var engine = BuildWallEngine(); + var body = GroundedBody(); + + // ── 1. Face-on +X into the wall at x=0.5 (normal −X) ───────────── + var r1 = ResolveForward(engine, body, + from: new Vector3(0.10f, 0f, 0f), + to: new Vector3(0.35f, 0f, 0f)); + + Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding), + "A blocked push must persist the validate-recorded sliding normal " + + "(retail SetPositionInternal 0x005154c2 on transition success)."); + Assert.True(body.SlidingNormal.X < -0.9f, + $"Persisted normal should face the mover (−X); got {body.SlidingNormal}."); + Assert.True(r1.Position.X + BSPStepUpFixtures.SphereRadius + <= 0.5f + PhysicsGlobals.EPSILON * 20f, + $"The 5 m wall must block the sphere; reach={r1.Position.X + BSPStepUpFixtures.SphereRadius:F4}."); + + // ── 2. Exactly-anti-parallel push again: absorbed frame ────────── + var r2 = ResolveForward(engine, body, + from: r1.Position, + to: r1.Position + new Vector3(0.15f, 0f, 0f)); + + Assert.False(r2.Ok, + "The seeded sliding normal projects the exactly-anti-parallel " + + "offset to zero → step-0 abort (retail find_transitional_position " + + "0050bfb7/0050c0ef). Faithful absorbed frame at a REAL wall."); + Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding), + "A failed transition leaves the body's sliding state untouched " + + "(retail: SetPositionInternal never runs on failure)."); + + // ── 3. Oblique push escapes and CLEARS the persisted state ─────── + var r3 = ResolveForward(engine, body, + from: r2.Position, + to: r2.Position + new Vector3(0.10f, 0.15f, 0f)); + + Assert.True(r3.Ok, "Oblique push must escape along the wall tangent."); + Assert.True(r3.Position.Y > r2.Position.Y + 0.05f, + $"Expected tangential advance along +Y; got Y={r3.Position.Y:F4} " + + $"(from {r2.Position.Y:F4})."); + Assert.False(body.TransientState.HasFlag(TransientStateFlags.Sliding), + "A successful transition whose steps re-record no collision must " + + "CLEAR the body's sliding state (retail SetPositionInternal " + + "0x005154e1: bit 4 synced from the transition's final " + + "sliding_normal_valid, which each step clears before its insert)."); + Assert.Equal(Vector3.Zero, body.SlidingNormal); + } +}