From 52e257d8d749bbf8eafe3145208f47cb131b50ce Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 10:24:24 +0200 Subject: [PATCH 01/10] =?UTF-8?q?fix(physics):=20L.4-cliffslide-gate=20?= =?UTF-8?q?=E2=80=94=20fire=20CliffSlide=20on=20steep=20ContactPlane,=20no?= =?UTF-8?q?t=20just=20on=20invalid?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surprise discovery: CliffSlide, PrecipiceSlide, and EdgeSlideAfterStepDownFailed are ALREADY in the codebase (landed yesterday in the L.2c Codex commits — 1ec40f2). The trigger gate was the missing piece for "sliding down steep roofs / steep terrain." The step-down branch in TransitionalInsert (where EdgeSlideAfterStepDownFailed gets called) was gated on `!ci.ContactPlaneValid` only. That covers "player walked off a ledge, no ground beneath them anymore" — but NOT "player standing on a surface that's too steep to walk on." For the latter case, Phase 1 of the resolver sets ContactPlane to the slope's plane (geometric touch is enough to set it; no walkability gate at that stage). So `ci.ContactPlaneValid` is true, just steep. Old gate skipped → step-down never ran → EdgeSlide never fired → CliffSlide never deflected the player. New gate fires when ContactPlane is invalid OR Normal.Z < FloorZ. The latter case lets step-down attempt to find a walkable surface below; it fails (the slope is steeper than FloorZ all the way down); EdgeSlideAfterStepDownFailed runs; Branch 2 (steep ContactPlane) fires CliffSlide; player gets deflected horizontally. Gravity continues to pull Z down — the combination produces the visible "slide down the slope" behavior. Mirrors retail's `transitional_insert` OK-path which (per agent reports of acclient_2013_pseudo_c.txt:273191) ALWAYS runs the step-down chain after a successful tentative move, regardless of ContactPlane validity. Our two-condition gate approximates that. Tests: 1491 still pass. Live verification: walking onto a 60° slope or jumping onto a steep roof should now slide the player downhill rather than letting them stand there indefinitely. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/TransitionTypes.cs | 124 ++++++++++++++++++-- 1 file changed, 115 insertions(+), 9 deletions(-) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index d48a0a01..afc7607f 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -193,6 +193,12 @@ public sealed class SpherePath public float WalkableAllowance = PhysicsGlobals.FloorZ; public bool HasWalkablePolygon => WalkableValid && WalkableVertices is { Length: >= 3 }; + public bool LastWalkableValid; + public Plane LastWalkablePlane; + public Vector3[]? LastWalkableVertices; + public Vector3 LastWalkableUp = Vector3.UnitZ; + public bool HasLastWalkablePolygon => LastWalkableValid && LastWalkableVertices is { Length: >= 3 }; + // Backup for restore public Vector3 BackupCheckPos; public uint BackupCheckCellId; @@ -256,6 +262,11 @@ public sealed class SpherePath WalkableVertices = (Vector3[])vertices.Clone(); WalkableUp = up; WalkableAllowance = PhysicsGlobals.FloorZ; + + LastWalkableValid = true; + LastWalkablePlane = plane; + LastWalkableVertices = (Vector3[])vertices.Clone(); + LastWalkableUp = up; } public void ClearWalkable() @@ -264,6 +275,18 @@ public sealed class SpherePath WalkableVertices = null; } + public bool RestoreLastWalkable() + { + if (!HasLastWalkablePolygon || LastWalkableVertices is null) + return false; + + WalkableValid = true; + WalkablePlane = LastWalkablePlane; + WalkableVertices = (Vector3[])LastWalkableVertices.Clone(); + WalkableUp = LastWalkableUp; + return true; + } + /// /// Slide fallback when step-up fails. Clears the contact-plane state that /// caused the step-up attempt and runs the full sphere-slide computation @@ -675,7 +698,25 @@ public sealed class Transition // as in contact with the ground, but the current CheckPos has no // terrain contact (walked off an edge). Attempt a step-down to // maintain ground contact. - if (!ci.ContactPlaneValid && oi.Contact && !sp.StepDown + // + // L.4-cliffslide-gate (2026-04-30): also fire when ContactPlane + // IS valid but the surface is too steep to walk on. This is the + // "player standing on a steep roof / steep terrain" case. Phase 1 + // sets ContactPlane on the slope (geometric touch is enough — no + // walkable check), so without this clause the step-down branch + // skips and EdgeSlideAfterStepDownFailed never gets the chance to + // call CliffSlide. With this clause: step-down probes for a + // walkable surface, fails (the slope is the only thing here and + // it's steeper than FloorZ), EdgeSlide fires, CliffSlide deflects + // motion. Then gravity does the rest of the downhill drift. + // + // Retail's transitional_insert OK-path always runs the step-down + // chain (per agent reports of acclient_2013_pseudo_c.txt:273191). + // We approximate that by triggering it whenever the current contact + // is invalid OR steeper than walkable. + bool contactInvalidOrSteep = !ci.ContactPlaneValid + || ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ; + if (contactInvalidOrSteep && oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown) { // L.2.3i (2026-04-29): retail uses FloorZ when OnWalkable, @@ -733,7 +774,23 @@ public sealed class Transition // we are missing precipice context, a steep contact plane, or // merely the EdgeSlide flag. DumpEdgeSlideStepDownFailed(stepDownHeight, zVal); - return EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal); + + var edgeState = EdgeSlideAfterStepDownFailed(engine, stepDownHeight, zVal); + if (edgeState == TransitionState.Slid) + { + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + sp.NegPolyHit = false; + continue; + } + + if (edgeState == TransitionState.Adjusted) + { + sp.NegPolyHit = false; + continue; + } + + return edgeState; } return TransitionState.OK; @@ -774,6 +831,9 @@ public sealed class Transition return CliffSlide(cliffPlane); } + if (!sp.HasWalkablePolygon) + sp.RestoreLastWalkable(); + if (sp.HasWalkablePolygon) { ci.ContactPlaneValid = false; @@ -802,6 +862,9 @@ public sealed class Transition ci.ContactPlaneIsWater = false; sp.RestoreCheckPos(); + if (!sp.HasWalkablePolygon) + sp.RestoreLastWalkable(); + if (sp.HasWalkablePolygon) return sp.PrecipiceSlide(this); @@ -853,7 +916,7 @@ public sealed class Transition Console.WriteLine( System.FormattableString.Invariant( - $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}")); + $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} lastWalkablePoly={sp.HasLastWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}")); } private static string Fmt(Vector3 value) => @@ -1343,7 +1406,18 @@ public sealed class Transition else if (ci.LastKnownContactPlaneValid) contactPlane = ci.LastKnownContactPlane; else - contactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f); + { + // Airborne wall-only hit: retail normally reaches this with a + // LastKnownContactPlane from CPhysicsObj::get_object_info when the + // object is still in Contact. Our local jump path clears Contact + // once airborne, so there is no ground/last plane to form a crease. + // Do not invent UnitZ here: wall x UnitZ projects the displacement + // onto a horizontal wall tangent and erases falling/upward motion. + float diff = Vector3.Dot(collisionNormal, gDelta); + Vector3 offset = -collisionNormal * diff; + sp.AddOffsetToCheckPos(offset); + return TransitionState.Slid; + } // Crease direction = cross(collisionNormal, contactPlane.Normal). Vector3 direction = Vector3.Cross(collisionNormal, contactPlane.Normal); @@ -1850,12 +1924,44 @@ public sealed class Transition // contact is still valid — keep the mover grounded via the // last-known plane. Without this, every wall bump dropped the // player into the falling animation for one frame. - oi.State |= ObjectInfoState.Contact; - // L.2.3i: same FloorZ correction as the live-contact branch. - if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ) - oi.State |= ObjectInfoState.OnWalkable; + // + // L.2.4 (2026-04-30): PROXIMITY GUARD. Only trust the + // last-known plane if the sphere is still actually near it. + // Geometrically: `angle` is the signed distance from the + // sphere center to the plane. If |angle| exceeds the sphere + // radius (plus a tiny epsilon), the sphere has SEPARATED + // from the plane — typically because we fell off an edge or + // the body dropped vertically while the resolver bounced + // through edge-slide attempts. Without this guard the player + // gets stuck mid-fall in a falling animation forever (live + // bug 2026-04-30: cur.Z=96.6, check.Z=95.1 — 1.5 m below the + // remembered floor, but still being marked Contact + OnWalkable). + // + // Matches ACE PhysicsObj's pre-reuse check on the last-known + // plane and retail's CPhysicsObj::get_object_info logic. + var sphereCenter = sp.GlobalSphere[0].Origin; + var radius = sp.GlobalSphere[0].Radius; + float angle = Vector3.Dot(ci.LastKnownContactPlane.Normal, sphereCenter) + + ci.LastKnownContactPlane.D; + + if (radius + PhysicsGlobals.EPSILON > MathF.Abs(angle)) + { + // Still close enough to the last-known plane — preserve + // grounded state. L.2.3i FloorZ test for OnWalkable. + oi.State |= ObjectInfoState.Contact; + if (ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ) + oi.State |= ObjectInfoState.OnWalkable; + else + oi.State &= ~ObjectInfoState.OnWalkable; + } else - oi.State &= ~ObjectInfoState.OnWalkable; + { + // Sphere has separated from the last-known plane. + // Drop the memory and let the body resolve normally + // (gravity → next-frame terrain probe → real contact). + ci.LastKnownContactPlaneValid = false; + oi.State &= ~(ObjectInfoState.Contact | ObjectInfoState.OnWalkable); + } } else { From a48883af2d597da36669ada8054841991374df7c Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 10:29:30 +0200 Subject: [PATCH 02/10] =?UTF-8?q?fix(physics):=20L.4-cliffslide-priority?= =?UTF-8?q?=20=E2=80=94=20steep=20ContactPlane=20check=20before=20OnWalkab?= =?UTF-8?q?le=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-reported: "still don't slide down steep roofs" after the previous trigger-gate fix (52e257d). Traced through the EdgeSlide dispatcher: the gate IS firing now, but ValidateTransition's L.2.3i FloorZ test clears OnWalkable as soon as the player is on a steep surface. So EdgeSlideAfterStepDownFailed enters Branch 1 (`!OnWalkable → restore + OK`) and stops the player BEFORE Branch 2's steep-ContactPlane CliffSlide can fire. Re-order: check the steep-ContactPlane condition FIRST, before the Branch 1 OnWalkable gate. If the surface is too steep AND we have a contact plane on it AND the EdgeSlide flag is set, run CliffSlide regardless of OnWalkable state. The cross-product deflection plus gravity produces continuous downhill drift, frame after frame. Branch 1's "stop at edge" still fires for the original case it was meant for: walked off into thin air with no contact plane at all. That should still stop (or fall normally) rather than CliffSlide against nothing. Tests: 1491 still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.Core/Physics/TransitionTypes.cs | 29 +++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index afc7607f..ce4bb079 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -810,6 +810,35 @@ public sealed class Transition var ci = CollisionInfo; var oi = ObjectInfo; + // L.4-cliffslide-priority (2026-04-30): the steep-ContactPlane check + // moved BEFORE the OnWalkable/EdgeSlide gate. + // + // Why: by the time this dispatch runs on subsequent frames (player + // standing on a steep slope), ValidateTransition's L.2.3i FloorZ + // test has already CLEARED OnWalkable (steep slope → not a walkable + // surface). The original Branch 1 (`!OnWalkable → restore + OK`) + // therefore fires every frame, stopping the player dead — exactly + // the "stay on the roof" symptom the user reported. + // + // Re-ordering: if the surface is too steep AND we have a contact + // plane on it, run CliffSlide regardless of OnWalkable. The + // cross(currentNormal, lastKnownNormal) deflection plus gravity + // produces visible downhill drift each frame. + // + // Branch 1 (the !OnWalkable stop) still fires when we DON'T have + // a contact plane — the original "walked off into thin air" + // case, which should still stop or fall normally rather than + // CliffSlide on nothing. + if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal && oi.EdgeSlide) + { + var cliffPlane = ci.ContactPlane; + sp.ClearWalkable(); + sp.RestoreCheckPos(); + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + return CliffSlide(cliffPlane); + } + // Retail lets non-EdgeSlide movers continue over the boundary. Player // movement carries EdgeSlide, so the local avatar takes the slide path. if (!oi.OnWalkable || !oi.EdgeSlide) From 5210bd3d5503ac4eec3c98c39681b2fab4523157 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 13:21:21 +0200 Subject: [PATCH 03/10] =?UTF-8?q?docs(claude):=20communication=20style=20?= =?UTF-8?q?=E2=80=94=20plain=20language=20for=203D=20/=20physics=20/=20gra?= =?UTF-8?q?phics?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a "Communication style" section after "How to operate" that documents how to discuss spatial / physics / animation / dat-format / protocol topics with the user. The user has asked repeatedly for plain-language framing of new concepts: name the idea in English first, then introduce the term of art; give units (degrees, meters) instead of raw floats; use analogies for spatial concepts (BSP = nested rooms, contact plane = imaginary floor, sphere sweep = rolling a ball, dot/cross products); walk through control flow frame-by-frame; flag terms of art the first time they appear. The goal is collaborative learning, not dumbed-down content. --- CLAUDE.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 2ec702dc..328264c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,6 +112,51 @@ a phase just landed, and move to the next todo item. always yes — keep going.** The single exception is visual verification; otherwise, act. +## Communication style + +The user is a strong systems / C# / network programmer but **less +practiced at 3D math, physics, graphics, and animation**. They want +to learn — they're not asking for dumbed-down content, but for +explanations that build understanding alongside the work. + +When discussing 3D / physics / graphics / animation / dat-format / +protocol-internals topics: + +- **Name the concept in plain language first**, then introduce the + term of art. "The angle of a slope (we call its straight-up + component `Normal.Z`)" rather than dropping `normal.Z = cos θ` + with no anchor. +- **Give units**: degrees, meters, cm — NOT raw floats. "FloorZ ≈ + 0.66 means slopes up to about 49° are walkable" rather than + "FloorZ = 0.66417414f". Floats are for the code; English is for + the conversation. +- **Use analogies for spatial concepts** when they fit. A BSP tree + is "a way of slicing space into nested rooms"; a contact plane is + "the imaginary floor under the player's feet"; a sphere sweep is + "rolling a ball forward through space and stopping it on contact"; + a cross product is "the direction perpendicular to two arrows"; + a dot product is "how aligned two arrows are (1 = same, 0 = + perpendicular, -1 = opposite)". +- **Don't pile on multiple new concepts in one paragraph.** If a + problem touches step-up AND step-down AND edge-slide AND + walkable-polygon tracking, walk through them one at a time, each + with what it does and why it exists. +- **Show the math when it matters, but explain it.** Don't just + drop a formula and move on; tag it with "what this means + geometrically". +- **Use frame-by-frame walk-throughs** for control-flow-heavy + physics: "frame N: player here, lands. Frame N+1: state checks…" + beats a function-call trace for understanding what's happening + in motion. +- **Flag terms of art** the first time they appear in a session, + even if they're sprinkled through code comments. "Broadphase", + "BSP", "step-up", "ContactPlane", "ValidateWalkable" — they + earn their meaning the first time you spell it out. + +The goal is collaborative learning. Don't simplify the content; just +make sure every term and number is grounded so the user can keep up +and build intuition over time. + ## Development workflow: grep named → decompile → verify → port **This is the mandatory workflow for implementing ANY AC-specific behavior.** From b1af56eb199d0e4c0caf1af4e80239f8ce8863d2 Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 13:22:07 +0200 Subject: [PATCH 04/10] =?UTF-8?q?fix(physics):=20L.4=20=E2=80=94=20steep?= =?UTF-8?q?=20airborne=20hits=20slide-tangent=20(interim,=20deviates=20fro?= =?UTF-8?q?m=20retail)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase L.4 closes the "stuck in falling animation on a steep roof" bug the user reported on 2026-04-30 ("I jump up, I land on it. It should not even let me land, should just slide with a falling animation"). After this commit the body no longer sticks to a steep roof when jumping into it — it slides along the slope while keeping the falling animation. Two pieces: 1. BSPQuery Path 6 steep-poly slide When an airborne sphere hits a polygon whose world normal Z is below FloorZ (≈ 0.6642, slope > ~49°), the previous flow was: Path 6 SetCollide → Path 4 set_walkable → ContactPlane committed → body "lands" on the steep poly with Contact bit + falling animation. This left the player stuck mid-slope because OnWalkable was cleared but Contact stayed set. The new branch detects the steep normal in Path 6 BEFORE SetCollide is called. Instead of entering the landing path, it removes the into-wall component of the move (project onto the steep face), sets CollisionNormal + SlidingNormal, and returns Slid. Same shape as Path 5's step-up fallback and CylinderCollision. The resolver retries; the sphere is now outside the poly; FindCollisions returns OK; ValidateTransition commits the slid position. ContactPlane is never set, so the body stays airborne with falling animation. 2. PlayerMovementController L.3a-bounce carve-out + Inelastic stop Re-enables the velocity-reflection bounce when the contact normal is upward-facing but steeper than walkable (0 < N.Z < FloorZ). The base L.3a rule suppresses bounce on landing transitions to avoid micro- bounce on flat terrain; that suppression also stuck the player to too-steep roofs they shouldn't land on. This carve-out re-enables the reflection specifically for the steep upward case. Also lands related L.2c precipice / edge-slide work that was in flight: - TransitionTypes EdgeSlideAfterStepDownFailed: walkable-poly-steep cliff route + steep-ContactPlane cliff route ordering, so that CliffSlide fires when the stored walkable polygon itself is too steep (Path 4 had previously accepted it as a "landing" via the permissive LandingZ threshold). - CliffSlide reference-normal selection: prefer LastWalkable, fall back to LastKnownContactPlane only when walkable, else use world-up. This prevents the cross(steepN, steepN) = 0 degenerate case that left the cliff slide as a no-op when both current and last-known were steep. - Phase 2 / step-down branch / edge-slide branch / cliff-slide diagnostic helpers gated on ACDREAM_DUMP_EDGE_SLIDE / ACDREAM_DUMP_STEEP_ROOF. - Two new airborne-mover regression tests in BSPStepUpTests + PhysicsEngineTests covering wall-slide and edge tangent motion. DEVIATION FROM RETAIL — DOCUMENTED FOR FOLLOW-UP The Path 6 steep slide is NOT what retail does. Retail's flow on the same hit is: Path 6 SetCollide (no steep check) → Path 4 find_walkable returns nothing for steep → Phase 3 reset path: restore_check_pos + kill_velocity → return COLLIDED → validate_transition reverts CheckPos to CurPos and forces OK. Net retail behavior: position reverts to pre-failed-move (typically just below the roof in the common jump-up case), velocity zeroed, gravity rebuilds Z next frame, body falls back down naturally with the falling animation. The "freeze" framing I used earlier was wrong; in the typical case retail just bounces the body off and lets gravity take over. Strict retail behavior would match the user's intent better in the common case AND avoid the bounce-energy-accumulation we saw with the slide-tangent approach (V grew to ~50 m/s in continuous-contact frames). However, retail's behavior degenerates in the edge case of an overhead landing onto a steep slope (body would freeze mid-air above the roof). This commit ships the slide-tangent fix as an interim "much better" state per user verification on 2026-04-30. Follow-up work to match retail strictly: revert Path 6 steep-slide, audit Phase 3 reset to ensure kill_velocity (matching OBJECTINFO::kill_velocity -> CPhysicsObj::set_velocity({0,0,0}, 0)) actually fires, and re-test. Refs: - acclient_2013_pseudo_c.txt:323784-323821 (Path 6 SetCollide) - acclient_2013_pseudo_c.txt:273191-273239 (Phase 3 reset path) - acclient_2013_pseudo_c.txt:272563-272596 (validate_transition revert) - acclient_2013_pseudo_c.txt:274467-274475 (kill_velocity) - acclient_2013_pseudo_c.txt:282699-282715 (handle_all_collisions bounce) Tests: 833/833 green. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/ISSUES.md | 11 +- ...26-04-29-movement-collision-conformance.md | 7 +- .../project_movement_collision_conformance.md | 6 + .../Input/PlayerMovementController.cs | 61 ++++++++ src/AcDream.Core/Physics/BSPQuery.cs | 82 +++++++++- src/AcDream.Core/Physics/TransitionTypes.cs | 141 +++++++++++++++++- .../Physics/BSPStepUpTests.cs | 79 ++++++++++ .../Physics/PhysicsEngineTests.cs | 46 ++++++ 8 files changed, 417 insertions(+), 16 deletions(-) diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 6033efe9..6d12894c 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -192,10 +192,13 @@ hard-blocks or accepts too much in several of these cases. `step_up_slide` now feels acceptable in live testing. Local/remote movement passes the retail-default `EdgeSlide` flag. The first precipice-slide slice now preserves terrain/BSP walkable polygon vertices and runs the retail back-probe -before `SPHEREPATH::precipice_slide`; `ACDREAM_DUMP_EDGE_SLIDE=1` now reports -whether a failed step-down had polygon context. Remaining gaps: real-DAT -building-edge fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` -dispatch. Named retail anchors include `CTransition::edge_slide`, +before `SPHEREPATH::precipice_slide`; edge-slide `Slid` / `Adjusted` results +now feed the `TransitionalInsert` retry loop instead of being reverted by outer +validation, and a synthetic diagonal terrain-boundary test covers tangent +motion. `ACDREAM_DUMP_EDGE_SLIDE=1` now reports whether a failed step-down had +polygon context. Remaining gaps: live visual confirmation of the retry-loop +fix, real-DAT building-edge fixtures, fuller `cliff_slide` coverage, and +`NegPolyHit` dispatch. Named retail anchors include `CTransition::edge_slide`, `CTransition::cliff_slide`, `SPHEREPATH::precipice_slide`, and `SPHEREPATH::step_up_slide`. diff --git a/docs/plans/2026-04-29-movement-collision-conformance.md b/docs/plans/2026-04-29-movement-collision-conformance.md index 90de9436..8bfc2c15 100644 --- a/docs/plans/2026-04-29-movement-collision-conformance.md +++ b/docs/plans/2026-04-29-movement-collision-conformance.md @@ -122,8 +122,11 @@ Current shipped slice (2026-04-30): wall-adjacent `step_up_slide` feels acceptable in live testing; player/remote movers pass `EdgeSlide`; terrain and BSP step-down/find-walkable now preserve walkable polygon vertices; failed step-down edge cases perform the retail back-probe before -`SPHEREPATH::precipice_slide`. Remaining L.2c work is real-DAT building-edge -fixtures, fuller `cliff_slide` coverage, and `NegPolyHit` dispatch. +`SPHEREPATH::precipice_slide`; precipice slide results now re-enter the +`TransitionalInsert` retry loop so tangent edge motion is preserved instead of +being reverted by outer validation. Remaining L.2c work is live visual +confirmation at real building/roof edges, real-DAT building-edge fixtures, +fuller `cliff_slide` coverage, and `NegPolyHit` dispatch. ### L.2d - Shape Fidelity: Sphere / CylSphere / Building Objects diff --git a/memory/project_movement_collision_conformance.md b/memory/project_movement_collision_conformance.md index 5db6326d..6117dc6b 100644 --- a/memory/project_movement_collision_conformance.md +++ b/memory/project_movement_collision_conformance.md @@ -72,3 +72,9 @@ InputDispatcher / PlayerMovementController step-down edge cases run the retail back-probe before precipice slide. `cliff_slide` has a first port, but `NegPolyHit`, `CELLARRAY`, full `cell_bsp`, and real-DAT building portal conformance remain open L.2 work. +- 2026-04-30: Edge-slide retry-loop lesson. `SPHEREPATH::precipice_slide` + usually returns `Slid` after applying the tangent offset. That result must + be handled inside `TransitionalInsert` like wall slide (`continue` and + re-test the adjusted `CheckPos`), not returned to `ValidateTransition`; the + outer validator treats non-OK as a collision and restores `CurPos`, making + edges feel like hard stops even when the tangent was computed. diff --git a/src/AcDream.App/Input/PlayerMovementController.cs b/src/AcDream.App/Input/PlayerMovementController.cs index 64f9f5c8..53c02259 100644 --- a/src/AcDream.App/Input/PlayerMovementController.cs +++ b/src/AcDream.App/Input/PlayerMovementController.cs @@ -441,6 +441,19 @@ public sealed class PlayerMovementController moverFlags: AcDream.Core.Physics.ObjectInfoState.IsPlayer | AcDream.Core.Physics.ObjectInfoState.EdgeSlide); + // L.4-diag (2026-04-30): trace position transitions so we can see + // whether the body is actually moving frame-to-frame on the steep + // roof, or whether it's frozen at the impact point. + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1" + && resolveResult.CollisionNormalValid) + { + Console.WriteLine( + $"[steep-roof] FRAME pre=({preIntegratePos.X:F2},{preIntegratePos.Y:F2},{preIntegratePos.Z:F2}) " + + $"post=({postIntegratePos.X:F2},{postIntegratePos.Y:F2},{postIntegratePos.Z:F2}) " + + $"resolved=({resolveResult.Position.X:F2},{resolveResult.Position.Y:F2},{resolveResult.Position.Z:F2}) " + + $"isOnGround={resolveResult.IsOnGround}"); + } + // Apply resolved position. _body.Position = resolveResult.Position; @@ -507,6 +520,47 @@ public sealed class PlayerMovementController ? !(prevOnWalkable && nowOnWalkable) : (!prevOnWalkable && !nowOnWalkable); + // L.4-steep-landing-bounce (2026-04-30): also bounce on + // landing IF the contact surface is upward-facing but + // steeper than walkable (FloorZ ≈ 49°). Per retail and the + // user's expectation: jumping onto a steep roof should NOT + // result in a landing — the player should bounce off, keep + // the falling animation, and slide off. + // + // Without this: the L.3a base rule suppresses the bounce on + // landing transitions (prev air → now ground) to avoid + // micro-bounce on flat terrain, but that suppression also + // sticks the player to too-steep roofs they shouldn't land + // on. This carve-out re-enables the bounce specifically for + // steep upward-facing surfaces. + // + // Range `0 < N.Z < FloorZ` means "facing upward but too + // steep" — excludes walls (N.Z ≈ 0) which are handled by the + // existing prevAirborne+nowAirborne rule, and ceilings + // (N.Z < 0) which the body shouldn't bounce off the same way. + if (!applyBounce + && resolveResult.CollisionNormalValid + && resolveResult.CollisionNormal.Z > 0f + && resolveResult.CollisionNormal.Z < PhysicsGlobals.FloorZ) + { + applyBounce = true; + } + + // L.4-diag (2026-04-30): per-frame bounce trace for steep-roof bug. + bool diagSteep = Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1"; + if (diagSteep && resolveResult.CollisionNormalValid) + { + var n0 = resolveResult.CollisionNormal; + var v0 = _body.Velocity; + Console.WriteLine( + $"[steep-roof] BOUNCE-CHECK applyBounce={applyBounce} " + + $"prevWalk={prevOnWalkable} nowWalk={nowOnWalkable} " + + $"N=({n0.X:F2},{n0.Y:F2},{n0.Z:F2}) FloorZ={PhysicsGlobals.FloorZ:F2} " + + $"V=({v0.X:F2},{v0.Y:F2},{v0.Z:F2}) " + + $"dot={Vector3.Dot(v0, n0):F3} " + + $"isOnGround={resolveResult.IsOnGround}"); + } + if (applyBounce) { if (_body.State.HasFlag(PhysicsStateFlags.Inelastic)) @@ -526,6 +580,13 @@ public sealed class PlayerMovementController // velocity reflects (subtle bounce). float k = -(dotVN * (_body.Elasticity + 1f)); _body.Velocity = v + n * k; + + if (diagSteep) + { + var v1 = _body.Velocity; + Console.WriteLine( + $"[steep-roof] BOUNCE-APPLIED V_after=({v1.X:F2},{v1.Y:F2},{v1.Z:F2}) k={k:F3}"); + } } } } diff --git a/src/AcDream.Core/Physics/BSPQuery.cs b/src/AcDream.Core/Physics/BSPQuery.cs index b86ccc4f..cb49f9f1 100644 --- a/src/AcDream.Core/Physics/BSPQuery.cs +++ b/src/AcDream.Core/Physics/BSPQuery.cs @@ -1481,7 +1481,6 @@ public static class BSPQuery var worldVertices = TransformVertices(hitPoly.Vertices, localToWorld, scale, worldOrigin); var worldPlane = BuildWorldPlane(worldNormal, worldVertices); collisions.SetContactPlane(worldPlane, path.CheckCellId, false); - path.SetWalkable(worldPlane, worldVertices, Vector3.UnitZ); return TransitionState.Adjusted; @@ -1572,16 +1571,68 @@ public static class BSPQuery hitPoly0!, contact0, scale, localToWorld); } + var worldNormal0 = L2W(hitPoly0!.Plane.Normal); + + // L.4-reject-steep-landing (2026-04-30): if the polygon is + // too steep to walk on (worldNormal.Z < FloorZ ≈ 0.6642), + // do NOT enter the SetCollide → Path-4 → SetContactPlane + // landing path. That path commits the player to the + // surface (sets ContactPlane), which sticks them to the + // steep roof in a falling animation. + // + // Instead, treat the steep-poly hit as a wall slide: + // project the move along the steep face (remove the + // into-wall component), set CollisionNormal + + // SlidingNormal, return Slid. Same shape as Path 5's + // step-up fallback (line 1545-1547) and CylinderCollision + // (TransitionTypes.cs:1518-1522). The position is updated + // in-place; on the next resolver iteration the sphere is + // outside the poly, FindCollisions returns OK, and + // ValidateTransition commits the new position. Body stays + // airborne, falling animation continues, gravity pulls + // down the slope. + // + // CRITICAL: this MUST happen before path.SetCollide(...) + // is called. Once Collide=true is set, TransitionalInsert + // Phase 3 either commits via ContactPlane+Placement (the + // walkable case, OK on shallow slopes) or RestoreCheckPos + // (the reset case, when ContactPlaneValid is false). The + // reset path REVERTS our slide and freezes the body. + // + // Per user 2026-04-30: + // "I jump up, I land on it. It should not even let me + // land, should just slide with a falling animation." + if (worldNormal0.Z < PhysicsGlobals.FloorZ) + { + Vector3 currWorld = path.GlobalCurrCenter[0].Origin; + Vector3 endWorld = path.GlobalSphere[0].Origin; + Vector3 gDelta = endWorld - currWorld; + float diff = Vector3.Dot(worldNormal0, gDelta); + if (diff < 0f) + path.AddOffsetToCheckPos(-worldNormal0 * diff); + + collisions.SetCollisionNormal(worldNormal0); + collisions.SetSlidingNormal(worldNormal0); + + if (Environment.GetEnvironmentVariable("ACDREAM_DUMP_STEEP_ROOF") == "1") + { + Console.WriteLine( + $"[steep-roof] PATH6-STEEP-SLIDE N=({worldNormal0.X:F2},{worldNormal0.Y:F2},{worldNormal0.Z:F2}) " + + $"FloorZ={PhysicsGlobals.FloorZ:F2} " + + $"diff={diff:F3} " + + $"newCheckPos=({path.CheckPos.X:F2},{path.CheckPos.Y:F2},{path.CheckPos.Z:F2})"); + } + return TransitionState.Slid; + } + // ─── SetCollide response ───────────────────────────────── - // Airborne sphere hits a polygon. Per retail, call SetCollide - // which saves backup position, records StepUpNormal = worldNormal, - // and sets WalkInterp=1. TransitionalInsert's Collide branch will - // then re-test as Placement to confirm we can land on the surface. + // Airborne sphere hits a shallow polygon (walkable surface). + // Call SetCollide so TransitionalInsert's Collide branch + // re-tests as Placement to confirm we can land on it. // // ACE: BSPTree.find_collisions default branch → SpherePath.SetCollide // + return Adjusted. // Named-retail: BSPTREE::find_collisions airborne branch → set_collide. - var worldNormal0 = L2W(hitPoly0!.Plane.Normal); path.SetCollide(worldNormal0); path.WalkableAllowance = PhysicsGlobals.LandingZ; return TransitionState.Adjusted; @@ -1597,8 +1648,25 @@ public static class BSPQuery if (hit1 || hitPoly1 is not null) { - // Head sphere hit: same SetCollide response. var worldNormal1 = L2W(hitPoly1!.Plane.Normal); + + // L.4-reject-steep-landing: same steep-poly slide + // for head-sphere hits. + if (worldNormal1.Z < PhysicsGlobals.FloorZ) + { + Vector3 currWorld = path.GlobalCurrCenter[0].Origin; + Vector3 endWorld = path.GlobalSphere[0].Origin; + Vector3 gDelta = endWorld - currWorld; + float diff = Vector3.Dot(worldNormal1, gDelta); + if (diff < 0f) + path.AddOffsetToCheckPos(-worldNormal1 * diff); + + collisions.SetCollisionNormal(worldNormal1); + collisions.SetSlidingNormal(worldNormal1); + return TransitionState.Slid; + } + + // Head sphere hit shallow surface: same SetCollide response. path.SetCollide(worldNormal1); path.WalkableAllowance = PhysicsGlobals.LandingZ; return TransitionState.Adjusted; diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index ce4bb079..7f28d4f8 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -607,6 +607,9 @@ public sealed class Transition // ── Phase 2: object (static BSP + cylinder) collision ─────── // Env was OK — now test objects. var objState = FindObjCollisions(engine); + // L.4-diag: log Phase outcomes per attempt so we can see whether + // we're escaping to the step-down branch or churning in retries. + DumpPhase2(attempt, transitState, objState); if (objState == TransitionState.Collided) return TransitionState.Collided; @@ -716,6 +719,8 @@ public sealed class Transition // is invalid OR steeper than walkable. bool contactInvalidOrSteep = !ci.ContactPlaneValid || ci.ContactPlane.Normal.Z < PhysicsGlobals.FloorZ; + // L.4-diag (2026-04-30): trace why we don't slide down roofs. + DumpStepDownBranchGate(contactInvalidOrSteep); if (contactInvalidOrSteep && oi.Contact && !sp.StepDown && sp.CheckCellId != 0 && oi.StepDown) { @@ -832,6 +837,7 @@ public sealed class Transition if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal && oi.EdgeSlide) { var cliffPlane = ci.ContactPlane; + DumpEdgeSlideBranch("priority/steep-cliffslide", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -843,6 +849,7 @@ public sealed class Transition // movement carries EdgeSlide, so the local avatar takes the slide path. if (!oi.OnWalkable || !oi.EdgeSlide) { + DumpEdgeSlideBranch("branch1/!onwalkable-or-!edgeslide", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -853,6 +860,7 @@ public sealed class Transition if (ci.ContactPlaneValid && ci.ContactPlane.Normal.Z < zVal) { var cliffPlane = ci.ContactPlane; + DumpEdgeSlideBranch("branch2/steep-cliffslide", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -865,6 +873,32 @@ public sealed class Transition if (sp.HasWalkablePolygon) { + // L.4-walkable-steep (2026-04-30): the stored Walkable polygon + // can be a too-steep surface (e.g., a roof the player jumped + // onto — Path 4's airborne-landing branch uses LandingZ, the + // permissive 0.087 threshold, so steep roofs get accepted as + // "walkable" for the landing). On subsequent frames the player + // is STANDING ON that polygon, not crossing its edge, so + // PrecipiceSlide's find_crossed_edge returns false and the + // player gets stuck in a Collided revert loop. + // + // Detect the case: if the walkable polygon's plane is steeper + // than FloorZ, route to CliffSlide using that plane instead of + // PrecipiceSlide. CliffSlide deflects motion along the ridge + // between current-steep and last-known-walkable; gravity then + // produces visible downhill drift. + if (sp.WalkablePlane.Normal.Z < PhysicsGlobals.FloorZ) + { + var cliffPlane = sp.WalkablePlane; + DumpEdgeSlideBranch("walkable-poly-steep-cliffslide", zVal); + sp.ClearWalkable(); + sp.RestoreCheckPos(); + ci.ContactPlaneValid = false; + ci.ContactPlaneIsWater = false; + return CliffSlide(cliffPlane); + } + + DumpEdgeSlideBranch("branch3/precipice-slide", zVal); ci.ContactPlaneValid = false; ci.ContactPlaneIsWater = false; return sp.PrecipiceSlide(this); @@ -872,6 +906,7 @@ public sealed class Transition if (ci.ContactPlaneValid) { + DumpEdgeSlideBranch("branch4/contact-no-walkable", zVal); sp.ClearWalkable(); sp.RestoreCheckPos(); ci.ContactPlaneValid = false; @@ -906,20 +941,53 @@ public sealed class Transition var sp = SpherePath; var ci = CollisionInfo; - if (!ci.LastKnownContactPlaneValid) - return TransitionState.OK; + // L.4-cliffslide-fallback (2026-04-30): use the LAST WALKABLE plane + // as the cross-product reference, falling back to world-up when no + // walkable history is available. Without this, when the player has + // been on a steep slope for >1 frame, ValidateTransition's L.2.3i + // FloorZ test propagates the steep plane into LastKnownContactPlane, + // so cross(currentSteep, lastKnownSteep) = 0 → degenerate, no + // deflection. Using LastWalkable preserves the prior flat-ground + // plane across continuous-slope frames; world-up gives a guaranteed + // non-zero deflection when no walkable history exists at all. + Vector3 referenceNormal; + string refSource; + if (sp.HasLastWalkablePolygon && sp.LastWalkablePlane.Normal.Z >= PhysicsGlobals.FloorZ) + { + referenceNormal = sp.LastWalkablePlane.Normal; + refSource = "last-walkable"; + } + else if (ci.LastKnownContactPlaneValid && ci.LastKnownContactPlane.Normal.Z >= PhysicsGlobals.FloorZ) + { + referenceNormal = ci.LastKnownContactPlane.Normal; + refSource = "last-known-walkable"; + } + else + { + // Fallback: world up. cross(steepNormal, UnitZ) gives the + // ridge direction (horizontal contour line of the slope). + // collideNormal then becomes the downhill horizontal axis. + referenceNormal = Vector3.UnitZ; + refSource = "world-up-fallback"; + } - Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, ci.LastKnownContactPlane.Normal); + Vector3 contactNormal = Vector3.Cross(contactPlane.Normal, referenceNormal); contactNormal.Z = 0f; Vector3 collideNormal = new(-contactNormal.Y, contactNormal.X, 0f); if (collideNormal.LengthSquared() < PhysicsGlobals.EpsilonSq) + { + DumpCliffSlide($"degenerate-cross/{refSource}", contactPlane, + new Plane(referenceNormal, 0f), contactNormal, 0f, false); return TransitionState.OK; + } collideNormal = Vector3.Normalize(collideNormal); Vector3 offset = sp.GlobalSphere[0].Origin - sp.GlobalCurrCenter[0].Origin; float angle = Vector3.Dot(collideNormal, offset); + DumpCliffSlide($"ok/{refSource}", contactPlane, + new Plane(referenceNormal, 0f), collideNormal, angle, true); if (angle <= 0f) { @@ -948,6 +1016,73 @@ public sealed class Transition $"edge-slide: stepdown-failed cur={Fmt(sp.CurPos)} check={Fmt(sp.CheckPos)} cell=0x{sp.CheckCellId:X8} edgeFlag={oi.EdgeSlide} contactFlag={oi.Contact} onWalkable={oi.OnWalkable} contactPlane={ci.ContactPlaneValid} lastPlane={ci.LastKnownContactPlaneValid} walkableValid={sp.WalkableValid} walkablePoly={sp.HasWalkablePolygon} lastWalkablePoly={sp.HasLastWalkablePolygon} stepDown={stepDownHeight:F3} zVal={zVal:F3}")); } + /// + /// L.4-diag: log step-down branch gate decision. Whether we entered or + /// skipped the contact-recovery branch matters for whether CliffSlide + /// has any chance of firing. + /// + private void DumpStepDownBranchGate(bool contactInvalidOrSteep) + { + if (!DumpEdgeSlideEnabled) return; + + var sp = SpherePath; + var ci = CollisionInfo; + var oi = ObjectInfo; + + bool wouldEnter = contactInvalidOrSteep && oi.Contact && !sp.StepDown + && sp.CheckCellId != 0 && oi.StepDown; + + if (!wouldEnter) return; // only log when entering, to keep noise low + + Console.WriteLine( + System.FormattableString.Invariant( + $"edge-slide: stepdown-branch-enter cur={Fmt(sp.CurPos)} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} contact={oi.Contact}")); + } + + /// + /// L.4-diag: log Phase 2 outcome per inner attempt. Tells us whether + /// we're churning in Slid retries or escaping to step-down branch. + /// + private void DumpPhase2(int attempt, TransitionState envState, TransitionState objState) + { + if (!DumpEdgeSlideEnabled) return; + if (objState == TransitionState.OK) return; // skip clean attempts + + Console.WriteLine( + System.FormattableString.Invariant( + $"edge-slide: phase2 attempt={attempt} env={envState} obj={objState}")); + } + + /// + /// L.4-diag: log which branch of EdgeSlideAfterStepDownFailed fired. + /// Tells us whether CliffSlide gets called or whether we hit a + /// stop-at-edge branch. + /// + private void DumpEdgeSlideBranch(string branch, float zVal) + { + if (!DumpEdgeSlideEnabled) return; + var sp = SpherePath; + var ci = CollisionInfo; + var oi = ObjectInfo; + Console.WriteLine( + System.FormattableString.Invariant( + $"edge-slide: branch={branch} contactValid={ci.ContactPlaneValid} contactN.Z={(ci.ContactPlaneValid ? ci.ContactPlane.Normal.Z : 0f):F3} lastValid={ci.LastKnownContactPlaneValid} lastN.Z={(ci.LastKnownContactPlaneValid ? ci.LastKnownContactPlane.Normal.Z : 0f):F3} walkPolyValid={sp.HasWalkablePolygon} walkPolyN.Z={(sp.HasWalkablePolygon ? sp.WalkablePlane.Normal.Z : 0f):F3} lastWalkPolyN.Z={(sp.HasLastWalkablePolygon ? sp.LastWalkablePlane.Normal.Z : 0f):F3} onWalk={oi.OnWalkable} edgeFlag={oi.EdgeSlide} zVal={zVal:F3}")); + } + + /// + /// L.4-diag: log CliffSlide invocation. Tells us whether the + /// cross-product is degenerate (no slide) or producing a real + /// deflection. + /// + private void DumpCliffSlide(string outcome, Plane current, Plane lastKnown, + Vector3 collideNormal, float angle, bool willApply) + { + if (!DumpEdgeSlideEnabled) return; + Console.WriteLine( + System.FormattableString.Invariant( + $"edge-slide: cliffslide outcome={outcome} curN={Fmt(current.Normal)} lastN={Fmt(lastKnown.Normal)} collideN={Fmt(collideNormal)} angle={angle:F4} apply={willApply}")); + } + private static string Fmt(Vector3 value) => System.FormattableString.Invariant($"({value.X:F3},{value.Y:F3},{value.Z:F3})"); diff --git a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs index 258fd58b..47bdfb84 100644 --- a/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs +++ b/tests/AcDream.Core.Tests/Physics/BSPStepUpTests.cs @@ -506,6 +506,85 @@ public class BSPStepUpTests "indicates Path 5 recursing through DoStepUp without guard."); } + /// + /// L.2c regression: an airborne mover jumping/falling into a vertical wall + /// must keep its vertical displacement. With no live or last-known contact + /// plane, SlideSphere must remove only the component into the wall; inventing + /// a flat UnitZ plane projects the displacement onto the wall/floor crease + /// and leaves the character stuck in falling animation against the wall. + /// + [Fact] + public void D3_AirborneMover_TallWall_PreservesVerticalMotion() + { + var (root, resolved) = BSPStepUpFixtures.TallWall(); + + var from = new Vector3(0.1f, 0f, 2.0f); + var to = new Vector3(0.6f, 0f, 1.5f); + + var t = BSPStepUpFixtures.MakeAirborneTransition(from, to); + var engine = MakeTestEngine(root, resolved, terrainZ: -50f); + + t.FindTransitionalPosition(engine); + + Assert.True(t.SpherePath.CurPos.Z < from.Z - 0.1f, + $"Expected airborne wall-slide to preserve downward motion; " + + $"from.Z={from.Z:F3}, CurPos.Z={t.SpherePath.CurPos.Z:F3}"); + Assert.True(t.SpherePath.CurPos.X <= 0.5f - BSPStepUpFixtures.SphereRadius + PhysicsGlobals.EPSILON * 20f, + $"Expected wall to block X penetration; got CurPos.X={t.SpherePath.CurPos.X:F3}"); + } + + /// + /// L.2c regression: if an airborne wall collision happens in a one-substep + /// frame, the collision normal has to survive into the next frame. Retail + /// does this with transient_state bit 2 + InitSlidingNormal. Without that, + /// every frame replays the same hard stop and the character hangs in falling + /// animation until another correction breaks the loop. + /// + [Fact] + public void D4_AirborneMover_TallWall_PersistsSlidingNormalAcrossFrames() + { + var (root, resolved) = BSPStepUpFixtures.TallWall(); + var engine = MakeTestEngine(root, resolved, terrainZ: -50f); + var body = new PhysicsBody + { + Position = new Vector3(0.25f, 0f, 2.0f), + TransientState = TransientStateFlags.Active, + }; + + var frame1 = engine.ResolveWithTransition( + currentPos: body.Position, + targetPos: new Vector3(0.36f, 0f, 1.92f), + cellId: 0xA9B40001u, + sphereRadius: BSPStepUpFixtures.SphereRadius, + sphereHeight: 0f, + stepUpHeight: 0.04f, + stepDownHeight: 0.04f, + isOnGround: false, + body: body); + + body.Position = frame1.Position; + + Assert.True(body.TransientState.HasFlag(TransientStateFlags.Sliding), + "First airborne wall hit should cache SlidingNormal for the next frame."); + Assert.Equal(2.0f, frame1.Position.Z, precision: 3); + + var frame2 = engine.ResolveWithTransition( + currentPos: body.Position, + targetPos: body.Position + new Vector3(0.11f, 0f, -0.08f), + cellId: 0xA9B40001u, + sphereRadius: BSPStepUpFixtures.SphereRadius, + sphereHeight: 0f, + stepUpHeight: 0.04f, + stepDownHeight: 0.04f, + isOnGround: false, + body: body); + + Assert.True(frame2.Position.Z < frame1.Position.Z - 0.05f, + $"Expected cached wall-slide normal to allow falling on frame 2; " + + $"frame1.Z={frame1.Position.Z:F3}, frame2.Z={frame2.Position.Z:F3}"); + Assert.InRange(frame2.Position.X, 0.24f, 0.31f); + } + // ========================================================================= // Helpers // ========================================================================= diff --git a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs index 79d6ac86..dc53d93d 100644 --- a/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs +++ b/tests/AcDream.Core.Tests/Physics/PhysicsEngineTests.cs @@ -261,6 +261,52 @@ public class PhysicsEngineTests Assert.Equal(50f, result.Position.Z, precision: 2); } + [Fact] + public void ResolveWithTransition_EdgeSlideAtLoadedTerrainBoundary_PreservesTangentMotion() + { + var engine = MakeFlatEngine(terrainZ: 50f); + var body = new PhysicsBody + { + Position = new Vector3(191f, 96f, 50f), + TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + ContactPlaneValid = true, + ContactPlane = new Plane(Vector3.UnitZ, -50f), + ContactPlaneCellId = 0x003Du, + }; + + var settled = engine.ResolveWithTransition( + currentPos: new Vector3(191f, 96f, 50f), + targetPos: new Vector3(191.25f, 96f, 50f), + cellId: 0x003Du, + sphereRadius: 0.5f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.EdgeSlide); + + Assert.True(body.WalkablePolygonValid); + Assert.NotNull(body.WalkableVertices); + + var result = engine.ResolveWithTransition( + currentPos: settled.Position, + targetPos: new Vector3(193f, 98f, 50f), + cellId: 0x003Du, + sphereRadius: 0.5f, + sphereHeight: 1.2f, + stepUpHeight: 0.4f, + stepDownHeight: 0.4f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.EdgeSlide); + + Assert.True(result.IsOnGround); + Assert.InRange(result.Position.X, 190.75f, 192.0001f); + Assert.True(result.Position.Y > 96.2f); + Assert.Equal(50f, result.Position.Z, precision: 2); + } + [Fact] public void ResolveWithTransition_LandblockBoundary_UpdatesFullOutdoorCellId() { From 235de3322aeb94ffa583f99d5eb92be84194193d Mon Sep 17 00:00:00 2001 From: Erik Date: Thu, 30 Apr 2026 22:41:12 +0200 Subject: [PATCH 05/10] feat(physics): #32 L.5 30Hz physics tick + retail debugger toolchain (#35) + Phase 3 retail-faithful kill_velocity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three intertwined changes from a single investigation session driven by attaching cdb to a live retail acclient.exe (v11.4186, Sept 2013 EoR build) and tracing what retail actually DOES on the steep-roof wedge scenario the user reported in acdream. ═══════════════════════════════════════════════════════════ 1. L.5 — physics-tick MinQuantum gate (PlayerMovementController) ═══════════════════════════════════════════════════════════ Retail's CPhysicsObj::update_object subdivides per-frame dt into 1/30 s sized integration steps and SKIPS entirely when accumulated dt is below MinQuantum. Live trace evidence: update_object = 40,960 calls UpdatePhysicsInternal = 25,087 calls (61%) i.e., 39% of update_object calls return early via the MinQuantum gate. Retail's effective physics tick rate is 30Hz even at 60+ Hz render. acdream's PlayerMovementController bypassed the existing PhysicsBody. update_object and called UpdatePhysicsInternal(dt) directly each render frame, which compressed bounce-energy / gravity-tangent accumulation into half the time and amplified our steep-roof wedge dynamics. Fix: add `_physicsAccum` accumulator. Integrate only when accumulated dt ≥ MinQuantum (clamped to MaxQuantum to bound stale-frame jumps). HugeQuantum drops accumulated time to discard truly stale frames (debugger break, GC pause). Render still runs at full rate; only the physics step is gated. ═══════════════════════════════════════════════════════════ 2. Phase 3 reset retail-faithful kill_velocity (TransitionTypes) ═══════════════════════════════════════════════════════════ Retail's reset path (acclient_2013_pseudo_c.txt:273231-273239) gates kill_velocity on `last_known_contact_plane_valid`: if (last_known_valid == 0) { set_collision_normal(step_up_normal); return COLLIDED; } kill_velocity(this); last_known_valid = 0; return COLLIDED; Earlier in this session I deviated to "unconditional kill_velocity" as a hypothesis-driven wedge fix. The live trace then showed the deviation CAUSED a different wedge by zeroing V every frame, leaving the body with no tangent momentum to escape (V = (0,0,0) for 169 consecutive frames while position pre/resolved frozen). The retail- faithful gate is restored. Note: the gate rarely fires in normal airborne play because our L.2.4 proximity guard clears last_known_valid soon after the body separates from its remembered floor. Live retail trace also showed kill_velocity = 0 hits over an entire play session — same behavior. So acdream's kill_velocity is correct as ported now. The supporting ObjectInfo.VelocityKilled flag + StopVelocity wiring + PhysicsEngine.ResolveWithTransition consumer that actually zeros body.Velocity when the flag is set — these were a no-op stub before this session and are now correctly wired. Retail anchor: OBJECTINFO::kill_velocity → CPhysicsObj::set_velocity({0,0,0}, 0) at acclient_2013_pseudo_c.txt:274467-274475. ═══════════════════════════════════════════════════════════ 3. Retail debugger toolchain (#35) ═══════════════════════════════════════════════════════════ When the question is "what does retail actually DO at runtime?" — not "what does retail's code SAY" — the decomp at docs/research/named-retail/ is invaluable but doesn't capture state interactions across frames. This commit ships infrastructure to attach Windows' cdb.exe to a live retail acclient.exe with full PDB symbols and capture state at any breakpoint. - tools/pdb-extract/check_exe_pdb.py — reads any PE's CodeView entry and reports MATCH / MISMATCH against refs/acclient.pdb's GUID. Always run before attaching cdb. The matching v11.4186 build's GUID is 9e847e2f-777c-4bd9-886c-22256bb87f32. - tools/pdb-extract/dump_pdb_info.py — dumps a PDB's expected build timestamp + GUID + age. Used to figure out which acclient.exe build pairs with our PDB. CLAUDE.md gets a Step -1 in the development workflow ("ATTACH cdb TO RETAIL when behavior is the question, not code") and a full "Retail debugger toolchain" section with the workflow, sample .cdb script structure, and watchouts (PDB names use snake_case for some classes / PascalCase for CPhysicsObj; ; is cdb's command separator; killing cdb kills the debuggee; high-hit-rate breakpoints lag the game). memory/project_retail_debugger.md captures the workflow + key findings so future sessions inherit the toolchain by reading project memory. ═══════════════════════════════════════════════════════════ 4. BSPQuery Path 6 slide-tangent restored (b1af56e behavior) ═══════════════════════════════════════════════════════════ After this session's retail-strict experiments showed that retail- faithful Path 6 (SetCollide + Phase 3 reset chain) produces a "lands on roof in falling animation, can't slide off" half-state in acdream — because our acdream port of step_up_slide / cliff_slide is incomplete for grounded-on-steep movement — the L.4 slide-tangent deviation from commit b1af56e is restored as the pragmatic ship state. The deviation: when an airborne sphere hits a polygon whose normal Z is below FloorZ (≈ 0.6642, slope > ~49°), project the move along the steep face to remove the into-wall displacement, set CollisionNormal + SlidingNormal, return Slid. Body never gets ContactPlane on the steep poly, never gets the half-state, slides off the slope under gravity's tangent contribution. Retail-strict requires the deeper step_up_slide / cliff_slide audit (filed under #32). Until that lands, slide-tangent is the right deviation — produces user-acceptable "slide off the roof" behavior. ═══════════════════════════════════════════════════════════ Test status: 833/833 green. Refs: acclient_2013_pseudo_c.txt:283950 (CPhysicsObj::update_object) acclient_2013_pseudo_c.txt:273231-273239 (Phase 3 reset path) acclient_2013_pseudo_c.txt:274467-274475 (OBJECTINFO::kill_velocity) acclient_2013_pseudo_c.txt:323783-323821 (BSPTREE::find_collisions Path 6) Closes #35. Updates #32 with L.4/L.5 status. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 132 +++++++++++++++++- docs/ISSUES.md | 89 +++++++++++- .../Input/PlayerMovementController.cs | 81 +++++++---- src/AcDream.Core/Physics/BSPQuery.cs | 95 +++++++------ src/AcDream.Core/Physics/PhysicsEngine.cs | 19 +++ src/AcDream.Core/Physics/TransitionTypes.cs | 73 +++++++++- tools/pdb-extract/check_exe_pdb.py | 119 ++++++++++++++++ tools/pdb-extract/dump_pdb_info.py | 98 +++++++++++++ 8 files changed, 624 insertions(+), 82 deletions(-) create mode 100644 tools/pdb-extract/check_exe_pdb.py create mode 100644 tools/pdb-extract/dump_pdb_info.py diff --git a/CLAUDE.md b/CLAUDE.md index 328264c7..75ff1fe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,10 +164,22 @@ The triangle-boundary Z bug cost 5 failed fix attempts from guessing. The animation frame-swap bug cost 4 failed attempts. Every time we checked the decompiled code first, we got it right on the first try. **Now we have named retail symbols too — Step 0 cuts most lookups -from 30 minutes to 5 seconds.** +from 30 minutes to 5 seconds. And as of 2026-04-30, when "what does +retail actually DO at runtime?" is the question and decomp alone +isn't enough, attach cdb to a live retail client (Step -1).** ### For each new feature or bug fix: +-1. **ATTACH cdb TO RETAIL (when behavior is the question, not code).** + For "what does retail actually DO frame-by-frame?" questions — + wedges, weird animation flicker, geometry-specific bugs, anything + where the decomp is correct but it's not clear how it produces the + visible behavior — **don't guess; attach the Windows debugger to + a live retail client and trace it.** See "Retail debugger toolchain" + below for setup. We discovered the steep-roof wedge had a 30Hz + physics-tick cause this way; would have taken weeks of guessing + without the trace. + 0. **GREP NAMED FIRST.** Before any decompilation work, search `docs/research/named-retail/acclient_2013_pseudo_c.txt` by `class::method` name. 99.6% of functions have real names from the @@ -249,6 +261,124 @@ Before marking any phase as done: - [ ] Roadmap updated - [ ] Memory updated if there's a durable lesson +## Retail debugger toolchain (live runtime trace) + +**When the question is "what does retail actually DO frame-by-frame?"** +the decomp alone is often not enough — code paths interact with state +(LastKnownContactPlane, transient flags, accumulated counters) in ways +that aren't obvious from reading. As of 2026-04-30 we have a working +toolchain to attach Windows' console debugger (cdb.exe) to a live +retail acclient.exe with full PDB symbols and capture state at any +breakpoint. **Use this when guessing has failed twice in a row.** + +### What we have + +- **Matching binary**: `C:\Turbine\Asheron's Call\acclient.exe` + v11.4186 (linker timestamp `2013-09-06 00:17:42 UTC`, + CodeView GUID `9e847e2f-777c-4bd9-886c-22256bb87f32`). Pairs + exactly with our `refs/acclient.pdb`. +- **Debugger**: `cdb.exe` (console WinDbg) at + `C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe`. + Install via Microsoft Store WinDbg (~50 MB). 32-bit version is + required for acclient.exe. +- **PDB**: `refs/acclient.pdb` (29 MB, Sept 2013 EoR build). + 18,366 named functions + 5,371 named struct types resolve. +- **Symbol verifier**: `tools/pdb-extract/check_exe_pdb.py ` + reads any acclient.exe and prints whether it pairs with our PDB + (`MATCH` / `MISMATCH (expected GUID = ...)`). Always run this on + a candidate binary BEFORE attaching. +- **PDB metadata dumper**: `tools/pdb-extract/dump_pdb_info.py refs/acclient.pdb` + prints the PDB's expected timestamp + GUID + age. Use to figure + out which build to look for if the chain ever breaks. + +### Workflow + +1. **Verify the binary matches the PDB:** + ```bash + py tools/pdb-extract/check_exe_pdb.py "C:/Turbine/Asheron's Call/acclient.exe" + ``` + Expect: `=== MATCH: this exe pairs with our acclient.pdb ===` + +2. **Have the user launch retail client** and connect to local ACE. + Retail must already be in-world before attaching. + +3. **Write a `.cdb` script** that arms breakpoints with non-blocking + actions (count + log + `gc`). Pattern: + ``` + .logopen + .sympath C:\Users\erikn\source\repos\acdream\refs + .symopt+ 0x40 + .reload /f acclient.exe + + r $t0 = 0 + bp acclient!CTransition::transitional_insert "r $t0 = @$t0 + 1; .if (@$t0 % 5000 == 0) { .printf \"...\" }; .if (@$t0 >= 30000) { qd } .else { gc }" + bp acclient!OBJECTINFO::kill_velocity "r $t1 = @$t1 + 1; gc" + ... + g + ``` + `gc` = "go conditional" (continue without breaking). Auto-detach + via `qd` after a hit-count threshold to avoid manual cleanup. + +4. **Launch cdb in the background** via a PowerShell wrapper: + ```powershell + & "C:\Program Files (x86)\Windows Kits\10\Debuggers\x86\cdb.exe" ` + -pn acclient.exe -cf