diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 1e1355fb..e7335df3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -158,9 +158,76 @@ resolution. --- -## #345 — Walking angled into a too-steep slope: 100% of input eaten with an UP collision normal, no slide +## #347 — Steep-slope glide runs at HALF retail's rate: arm/move alternation instead of within-tick redirect -**Status:** OPEN — HIGH (user-felt, capture-backed). **A/B COMPLETE, same +**Status:** OPEN — MEDIUM (behavioral residual of the #345 fix, trace-backed). +With #345 fixed, the glide works but alternates in a strict two-tick cycle: +the arming tick absorbs the whole request while the edge response sets the +sliding normal ((0.707,-0.707,0) on the conformance fixture), and only the +NEXT tick's `AdjustOffset` pre-projection consumes it and moves (+0.115, ++0.115 per moving tick on the 45-degree fixture) — then the clean move +clears the normal and the cycle repeats. Fixture trace: 14 of 30 post- +crossing ticks stuck, positions advancing every other tick +(`Issue345SteepSlopeGlideTests` + the deleted Scratch345 dump, 2026-08-08). +Retail instead redirects WITHIN the tick: the live cdb profile +(`345-retail-glide.cdb.log`) fired `edge_slide`/`cliff_slide` 594 times +EACH in lockstep with `set_sliding_normal` 538 over a ~15 s glide — every +30 Hz tick, which an alternation would halve — so retail's +`transitional_insert` re-enters the redirected offset in the SAME +transition and yields motion every tick. Ours ends the transition on the +arming tick with zero yield. The fix lives in the edge-family response +semantics (`EdgeSlideAfterStepDownFailed` and the insert's continuation +after an applied edge constraint) — deliberately NOT touched by the #345 +landing (fresh AD-66 in the same block; the Campaign S response-layer +landings all user-gated). Net user-visible effect: gliding along a steep +face at ~half retail's lateral speed; direction and angle-scaling correct. +If the #345 in-game gate reports "slides but slower than retail," this is +the mechanism, already filed. + +## #345 — Walking angled into a too-steep slope: 100% of input eaten with an UP collision normal, no slide — CLOSED 2026-08-08 (in-game glide gate PASSED: "Well it works, we are sliding. I cant detect any speed change from retail") + +**Dual Opus review verdicts: CONFIRMED-FAITHFUL (conformance, independent +byte re-decode incl. the stack-slot frame arithmetic and every ret site) and +SAFE (blast radius, truth-table instrumented: exactly one row moves; the +placement/teleport family proven immune via mover-flag grep + the +RuntimeSetPositionMoverPreparationTests pin; independent five-angle sabotage +table, monotone angle→lateral 10°–85°). Named non-blocking residuals, all +retail-consistent or filed: the placement-arm REJECT→ACCEPT flip at +DoStepDown's final insert and the Collide-branch re-test (retail's +validate_walkable is insert-type-agnostic — same behavior, untested +topology); the other-cell ValidateWalkable site has zero fixture coverage +(a neighbouring-cell steep fixture would close it); projectiles (no +EdgeSlide flag) resting on a steep face commit below-plane instead of +pushing out (retail-mover-agnostic, narrow); and ACE SHARES the misport +server-side, so NPCs/remotes may show lateral drift-then-snap at steep +terrain until the next UpdatePosition — name that mechanism before +misdiagnosing any future remote-prediction report. AD-71 (mutable +WalkableAllowance operand) filed the same session.** + +**FIX (one conditional return, byte-proven):** retail's +`OBJECTINFO::validate_walkable` @0x0050d010 initializes its return slot to +OK (`0x0050d025: mov [esp+0xc], 1`) and assigns ADJUSTED only INSIDE the +below-plane guard, immediately after the push executes (`0x0050d249`, +after `add_offset_to_check_pos`). The guard-fail path — grounded mover, +OnWalkable, plane too steep (`0x0050d1b9 je 0x50d251`) — skips the +contact write, the push, AND the Adjusted assignment: retail returns OK +and simply IGNORES the steep plane at primary validation, letting the +insert proceed so the step-down phase fails and the edge family produces +the glide. ACE flattened this into an unconditional `return Adjusted` +(`ObjectInfo.cs:169`) and we inherited it — the dead loop was our +`TransitionalInsert` retrying the byte-identical Adjusted forever. +`ValidateWalkable`'s below-plane arm now scopes the return exactly as the +bytes do. Evidence chain: the live cdb glide profile (594 edge/cliff +lockstep, step_up=0), the capstone byte-decode +(`docs/research/2026-08-08-345-d0-branch-pin.md`), the D0 implementer's +correct STOP (synthetic fixtures reproduced the stuck fingerprint while +faithfully executing the ACE-shaped reading — refuting the reading, not +the code), and the discriminating conformance fixture (in-cell diagonal +split, flat+steep triangles sharing one cell — sabotage red with the +exact stuck position; the cell-boundary topology does NOT discriminate +and is pinned as supplementary). Residual: #347 (half-rate alternation). + +**Original report:** OPEN — HIGH (user-felt, capture-backed). **A/B COMPLETE, same morning: PRE-EXISTING, not a campaign regression.** The pre-campaign binary (d4e956b4, built in a throwaway worktree, same recorder, same slope, same protocol) shows the identical signature — eleven consecutive 2-second windows diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 917854ba..741e9c5c 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -164,6 +164,8 @@ readiness/requeue adaptation. See | AD-67 | **Filed 2026-08-07 at the #32 closeout.** The narrowed `CollisionInfo.SetContactPlane` still writes `ContactPlaneCellId`, which retail's `COLLISIONINFO::set_contact_plane` @0x00509d80 does not — retail writes the cell id only in `CTransition::init_contact_plane` (@0x0050e8ca). Kept deliberately at the #32 fix on the research doc's own advice: acdream's consumers (the `[support]` probe's provenance, water-plane bookkeeping, `AdjustOffset`'s `ContactPlaneCellId != 0` gate) rely on the cell id being current per contact write, and retail's equivalent state travels a different route the port has not needed. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`SetContactPlane`, the `ContactPlaneCellId = cellId` line) | The #32 fix removed the four LAST-KNOWN writes — the defect — and deliberately did not also change this contact-group field in the same commit; two behaviour changes in one fix would have made the user's cliff gate ambiguous. | A consumer that assumes the cell id changes ONLY at transition seed time (retail's timing) would observe it changing per contact write instead. No such consumer is known; `AdjustOffset`'s gate wants the current value. | `COLLISIONINFO::set_contact_plane` 0x00509d80 (22 bytes, no cell-id write); `CTransition::init_contact_plane` 0x0050e850 (cell id at 0x0050e8ca) | | AD-68 | **Filed 2026-08-07 at the #338 closure.** During an entity's ASYNC-RESIDENCY window — its flat Setup collision not yet resident — `LiveEntityMotionRuntimeController.GetSetupMoverShape` returns a placeholder mover shape: empty sphere list (falling back to the legacy 0.48/1.835 capsule reconstruction) and step heights **0.4/0.4**, values that appear nowhere in retail (authored human values are 0.600/1.500; retail's not-on-walkable fallback is 0.04). The local player has the same window between controller construction (0.4f defaults) and the publication candidate's adoption. Retail loads Setups synchronously and has no such window at all. Measured scale: 358 placeholder resolves vs 111,248 authored-pair resolves across one long session — seconds per entity, once. | `src/AcDream.App/Physics/LiveEntityMotionRuntimeController.cs` (`GetSetupMoverShape`, the `setup is null` and `<= 0f` arms); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (0.4f field defaults, adopted-over at publication) | An adaptation to async residency, not a wiring defect — #338's live probe proved prepare/publish/resolve all carry the authored values in steady state. Left as-is deliberately: shrinking the window is streaming work, not physics work. | A remote moving DURING its residency window steps 0.4 instead of its authored heights, and collides as a capsule instead of its sphere list — briefly, once per entity. If a future report says "an NPC stumbled on a stair right as it appeared", this row is the first suspect. | `CTransition::step_up` 0x0050b610 (0.04 fallback at 0x0050b655); `CPartArray::GetStepUpHeight` 0x005180d0; issue #338 | | AD-69 | **Filed 2026-08-07 at the S4 pseudocode pass (implementer finding, verified against the decomp).** `Transition.AdjustOffset`'s safety push-out computes `dist` WITHOUT the cell-relative correction retail applies: retail's `adjust_offset` (and ACE's port, independently) run the sphere centre through `LandDefs::get_block_offset` against the contact plane's own cell before the plane-distance dot, so a contact plane owned by a DIFFERENT landblock than the mover's current cell measures in the plane's frame. acdream dots the raw world-space centre against the stored plane. Same-landblock contact (the overwhelming case) is identical; a landblock-SEAM contact measures dist offset by the block delta, mis-firing or mis-suppressing the push-out at seams. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`AdjustOffset`, the dist computation ahead of the push-out block) | Discovered during S4 but deliberately not folded in: S4's own AD-66 half was withheld the same night (#341), and a third change in the same block would have made the anomaly investigation unattributable. Fix alongside the AD-66 relanding. | A mover resting on a contact plane owned by the neighbouring landblock (seam walking) gets a push-out computed against a dist that is wrong by the block offset — either a spurious lift or a missed penetration correction, exactly at landblock seams, the #176/#177 symptom neighbourhood. | `CTransition::adjust_offset` 0x0050a370 (pc:272271-272393); `LandDefs::get_block_offset`; ACE `Transition.AdjustOffset` (cross-check); issue #341 (sequencing) | +| AD-70 | **Filed 2026-08-08 with the #345 fix.** Our steep-slope glide alternates: the edge-family arming tick absorbs the request (zero yield) and only the next tick's `AdjustOffset` pre-projection moves, then the clean move clears the sliding normal — a strict two-tick cycle. Retail redirects WITHIN the tick (`edge_slide`/`cliff_slide` 594 each over a ~15 s live glide — every 30 Hz tick, lockstep with `set_sliding_normal` 538) and yields motion every tick. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`EdgeSlideAfterStepDownFailed` + the insert's post-constraint continuation) | The #345 landing deliberately touched only `validate_walkable`'s return scoping; the response bodies were freshly user-gated (Campaign S) and AD-66 had just relanded in the same block. | Gliding along a too-steep face at ~half retail's lateral speed; direction and angle-scaling correct. Visible as "slides but slower than retail" in a side-by-side. | `345-retail-glide.cdb.log` counters; `Issue345SteepSlopeGlideTests` tick trace; issue #347 | +| AD-71 | **Filed 2026-08-08 (reviewer finding on the #345 fix).** `ValidateWalkable`'s walkable test uses the MUTABLE `sp.WalkableAllowance` where retail's `validate_walkable` calls `CPhysicsObj::is_valid_walkable` @0x0050f530 — a FIXED global threshold (N.z >= [0x8ede5c], the walkable constant; the function reads no object state). Several code paths write `WalkableAllowance = LandingZ` (0.0871557 — TransitionTypes.cs:1688,2264, BSPQuery.cs:2330, FlatBspQuery.cs:2085) and `ClearWalkable()` does not restore it, so a stale-permissive value entering a grounded `!StepDown && OnWalkable` validate makes the guard PASS where retail's fails. Every override is permissive, so the #345 fix cannot REGRESS through this path — but for planes with N.z in (0.0872, 0.6642) a stale allowance leaves the old Adjusted-without-push dead loop reachable. The #345 landing GREW this row's blast radius: the operand now gates the return value (OK vs Adjusted), not merely the push (reviewer B, 2026-08-08). Also folds in: our `FloorZ = 0.6642f` vs ACE's 0.66417414f flips OK/Adjusted in a ~0.002-degree band. | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateWalkable`, the `walkable` guard operand) | Deliberately not folded into the #345 landing: the allowance plumbing is shared with the step-down family and needs its own conformance pass over every WalkableAllowance write/restore site. | A too-steep plane between LandingZ and FloorZ validated right after a placement/landing path that left the allowance permissive: the guard pushes+Adjusts where retail returns OK — the #345 stop, in a narrower band. | capstone decode of 0x0050f530 (reviewer A, 2026-08-08); `docs/research/2026-08-08-345-d0-branch-pin.md` flagged-secondary section | --- diff --git a/docs/research/2026-08-08-345-d0-branch-pin.md b/docs/research/2026-08-08-345-d0-branch-pin.md index da549707..4d3fc6ba 100644 --- a/docs/research/2026-08-08-345-d0-branch-pin.md +++ b/docs/research/2026-08-08-345-d0-branch-pin.md @@ -217,3 +217,60 @@ seeding difference at insert entry. If prediction 1 fails (retail also retry-storms), the divergence is inside the retry's convergence instead, and the Adjusted-production question reopens. Either way the capture discriminates. + +## D0 RESOLVED (2026-08-08, parent session): the missed branch is validate_walkable's RETURN SCOPING — byte-proven + +Capstone disassembly of the PDB-paired binary (`C:\Users\erikn\Downloads\ +acclient.exe`, v11.4186) at `OBJECTINFO::validate_walkable` @0x0050d010: + +``` +0050d020 mov edi, 1 +0050d025 mov [esp+0xc], edi ; var_1c = 1 = OK_TS (return-value slot) +...below-plane arm... +0050d1a9 test ecx, ecx ; sp->step_down +0050d1af jne 0x50d1bf ; set → guard body +0050d1b1 test byte [ebp+4], 2 ; oi->state & OnWalkable +0050d1b5 je 0x50d1bf ; clear → guard body +0050d1b7 test eax, eax ; is_valid_walkable(N) +0050d1b9 je 0x50d251 ; TOO STEEP → SKIP guard body ENTIRELY +0050d1bf ...set_contact_plane, step-down interp (fail: mov eax,2 → COLLIDED), +0050d244 call add_offset_to_check_pos ; the push +0050d249 mov dword [esp+0x10], 3 ; var_1c = ADJUSTED — ONLY after the push +0050d251 ...collision-normal tail (Contact set → skipped)... +0050d271 mov eax, [esp+0x10] ; return var_1c +``` + +**The guard-fail path (grounded mover, OnWalkable, too-steep plane) never +touches var_1c: retail returns OK.** The steep below-plane is deliberately +ignored by primary walkable validation; the insert proceeds, the OK-arm +step-down phase runs at the advanced position, its walkable probe fails on +the steep landing (`check_walkable` early-out: `0050d187 mov eax,2` — +COLLIDED, also byte-confirmed), `StepDown` fails, and `EdgeSlide` / +`CliffSlide` produce the per-tick lateral glide. Every round-1 counter +(594 edge/cliff lockstep, step_up=0, walkable_hits_sphere=0, +adjust_sphere_to_plane=0, vwalk high) is reproduced by this reading. + +**ACE misported this** (`ObjectInfo.cs:169` returns Adjusted +unconditionally after the guard block) and acdream inherited the shape. +Binary Ninja had `var_1c = 3` correctly scoped inside the guard all along +(pc:274525+ region) — but its `void` return typing and bare `return;` +statements hid the return-value mechanics from every prior reading. + +**The fix (supersedes the contract's D1 scope, per its own "find the real +branch" clause):** in our `ValidateWalkable` below-plane arm +(`TransitionTypes.cs:3744-3785`), return `Adjusted` only when the guard +passed and the push executed; return `OK` when the guard fails. No routing +change in `TransitionalInsert` is needed — the existing OK-arm step-down +block + edge family already produce the cascade (proven live-healthy by +this morning's 18 clean edge-family entries). + +Also byte-confirmed while here: the step-down interp failure returns +COLLIDED (`0050d28b mov eax,2`) — ACE and our port are RIGHT there; BN's +bare `return` hid it. And the check_walkable early-out returns COLLIDED +(`0050d187`) — all three agree. + +**Flagged secondary (file, don't chase):** retail's guard calls +`CPhysicsObj::is_valid_walkable(N)` (fixed retail threshold) at BOTH the +resting and below-plane sites; ours tests `N.z >= sp.WalkableAllowance`. +In this scenario both reject N.z=0.6, so it is not #345's cause — but the +operand difference needs its own conformance check. diff --git a/src/AcDream.Core/Physics/TransitionTypes.cs b/src/AcDream.Core/Physics/TransitionTypes.cs index e42e5f9c..051bf8c7 100644 --- a/src/AcDream.Core/Physics/TransitionTypes.cs +++ b/src/AcDream.Core/Physics/TransitionTypes.cs @@ -3743,6 +3743,15 @@ public sealed class Transition // contactPlane.Normal.Z is 1 for flat ground, so this is just dist. float zDist = dist / contactPlane.Normal.Z; + // Retail 0x0050d025/0x0050d249: the return value starts OK and + // becomes Adjusted ONLY when the push below executes. A grounded + // mover already OnWalkable dipping below a TOO-STEEP plane skips + // the whole block (0x0050d1b9 -> 0x0050d251) and returns OK — + // primary validation deliberately ignores the steep face so the + // insert proceeds and the step-down failure routes into the + // edge-slide family (#345: retail's glide-along-steep-slope). + // ACE's unconditional Adjusted here is a misport. + var result = TransitionState.OK; bool walkable = contactPlane.Normal.Z >= sp.WalkableAllowance; if (sp.StepDown || !oi.OnWalkable || walkable) { @@ -3751,7 +3760,8 @@ public sealed class Transition if (sp.StepDown) { - // Validate step-down interpolation factor. + // Validate step-down interpolation factor + // (failure returns Collided: retail 0x0050d28b). float interp = (1f - (-1f / (sp.StepDownAmt * sp.WalkInterp)) * zDist) * sp.WalkInterp; if (interp >= sp.WalkInterp || interp < -0.1f) { @@ -3766,6 +3776,7 @@ public sealed class Transition // Push the sphere up out of the terrain. sp.AddOffsetToCheckPos(new Vector3(0f, 0f, -zDist)); + result = TransitionState.Adjusted; } // #345 probe (2026-08-08): named local, same rationale as @@ -3780,9 +3791,9 @@ public sealed class Transition PhysicsDiagnostics.TraceTransitValidateWalkable( oi.SelfEntityId, "below-push", dist, waterDepth, oi.Contact, sp.StepDown, belowPushGuardPassed, - contactPlane.Normal, TransitionState.Adjusted); + contactPlane.Normal, result); - return TransitionState.Adjusted; + return result; } private static void CacheWalkableContext( diff --git a/tests/AcDream.Core.Tests/Physics/Issue345SteepSlopeGlideTests.cs b/tests/AcDream.Core.Tests/Physics/Issue345SteepSlopeGlideTests.cs new file mode 100644 index 00000000..f97dc772 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/Issue345SteepSlopeGlideTests.cs @@ -0,0 +1,271 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// #345 acceptance: a grounded mover walking at an angle into a TOO-STEEP +/// terrain face must GLIDE laterally along it — faster the more angled the +/// approach — while a perpendicular approach stops. The user's retail +/// observation ("it glides, faster the more angle") is the axiom; the live +/// cdb profile (594 edge_slide/cliff_slide lockstep per run, step_up=0) +/// and the byte-pin in +/// docs/research/2026-08-08-345-d0-branch-pin.md establish the +/// mechanism: retail's validate_walkable below-plane arm returns OK +/// (not Adjusted) when its guard fails on a grounded-OnWalkable mover +/// against a too-steep plane (0x0050d1b9 jumps past the push AND past the +/// var_1c = 3 at 0x0050d249, leaving the 0x0050d025 init of OK), so +/// the insert proceeds, the step-down phase fails on the steep landing, +/// and the edge-slide family produces the per-tick lateral glide. +/// +/// +/// Discriminating fixture (the live #345 topology): the flat and +/// steep triangles share ONE terrain cell's diagonal. Cell (3,3) of the +/// synthetic landblock splits SW→NE (FSplitNESW), so raising only its TL +/// post to 32 m leaves the below-diagonal triangle {BL,BR,TR} flat while +/// the above-diagonal triangle {BL,TR,TL} carries the whole rise: normal +/// (0.469, -0.469, 0.469-normalized) with N.z ≈ 0.469 — well below the +/// walkable threshold (~0.664). Because both triangles live in the SAME +/// cell, the primary-phase terrain sample validates the steep plane the +/// moment the check position crosses the diagonal — the exact spot the +/// pre-fix code dead-looped (Adjusted with no push, byte-identical +/// retries). A cell-BOUNDARY face does NOT reproduce that loop (the +/// cell-scoped primary sample skips a triangle outside the primary cell), +/// which is why the supplementary boundary test below is not the +/// discriminator. +/// +/// +public sealed class Issue345SteepSlopeGlideTests +{ + private const float DxyPerTick = 0.23f; // the captured per-tick request + private const int Ticks = 30; + + // Cell (3,3): x,y in [72,96]. The diagonal runs BL(72,72) → TR(96,96) + // (the line y = x). Start on the flat triangle ~0.42 m perpendicular + // from the diagonal, mid-cell, so every approach engages the steep + // face within a couple of ticks. + private const float StartX = 80.4f; + private const float StartY = 79.8f; + + // In-cell face frame: the steep face's horizontal trace is the + // diagonal, direction (1,1)/√2; the into-face perpendicular (from the + // flat side toward the steep side) is (-1,1)/√2. + private static readonly Vector2 Lateral = new(0.70710678f, 0.70710678f); + private static readonly Vector2 IntoFace = new(-0.70710678f, 0.70710678f); + + [Fact] + public void Angled45Approach_GlidesAlongTheDiagonal() + { + var (finalPos, stuckTicks) = RunApproach(angleFromPerpendicularDeg: 45f); + + float lateral = LateralAdvance(finalPos); + Assert.True(lateral > 1.0f, + $"expected the lateral component to survive against the " + + $"too-steep face (the retail glide), got only {lateral:F3} m " + + $"along the face over {Ticks} ticks (final=" + + $"{finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})"); + + // The glide must not secretly climb the steep face — and the new + // OK return is specifically the "no push-out" path, so the mover + // must not sink below the flat triangle (z=0) either. + Assert.True(finalPos.Z < 1.0f, + $"expected the mover to stay at the base of the too-steep " + + $"face, but Z climbed to {finalPos.Z:F3}"); + Assert.True(finalPos.Z > -0.05f, + $"expected the mover to stay on the flat surface (z=0), but " + + $"it sank to Z={finalPos.Z:F3}"); + + // The pre-fix dead loop spent EVERY post-crossing tick stuck + // (resolve output identical to input against a nonzero request; + // 28 of 30 here). The fixed glide alternates: the arming tick + // absorbs the request while edge-slide sets the sliding normal, + // the next tick consumes it and moves (14 of 30 stuck). The + // alternation itself is a KNOWN half-rate residual vs retail's + // within-tick redirect (#347) — this assertion only rejects the + // dead loop. Stuck ticks are counted from positions, not the + // (temporary) transit-fail probe, so the assertion survives the + // probe family's eventual strip; the lower bound keeps it from + // going vacuous if the fixture stops engaging the face at all. + Assert.InRange(stuckTicks, 1, Ticks / 2 + 2); + } + + [Fact] + public void SteeperApproachAngle_YieldsMoreLateralAdvance() + { + // "Faster the more angle you run towards it" — ordering only, no + // feel constants. + var (pos30, _) = RunApproach(angleFromPerpendicularDeg: 30f); + var (pos60, _) = RunApproach(angleFromPerpendicularDeg: 60f); + + float lat30 = LateralAdvance(pos30); + float lat60 = LateralAdvance(pos60); + Assert.True(lat60 > lat30, + $"expected the more-angled approach to glide farther " + + $"(lat60={lat60:F3} m vs lat30={lat30:F3} m)"); + } + + [Fact] + public void PerpendicularApproach_Stops() + { + // The user's paired retail observation: walking straight at the + // face stops — there is no lateral component to preserve. + var (finalPos, _) = RunApproach(angleFromPerpendicularDeg: 0f); + + float lateral = MathF.Abs(LateralAdvance(finalPos)); + Assert.True(lateral < 0.15f, + $"expected no lateral drift on a perpendicular approach, got " + + $"{lateral:F3} m"); + + float dx = finalPos.X - StartX; + float dy = finalPos.Y - StartY; + float xyTravel = MathF.Sqrt(dx * dx + dy * dy); + Assert.True(xyTravel < 1.2f, + $"expected the too-steep face to stop the perpendicular " + + $"approach at its base (~0.4 m away), got {xyTravel:F3} m of " + + $"travel"); + Assert.True(finalPos.Z < 1.0f, + $"expected no climb on a perpendicular approach, got " + + $"Z={finalPos.Z:F3}"); + Assert.True(finalPos.Z > -0.05f, + $"expected no sink on a perpendicular approach, got " + + $"Z={finalPos.Z:F3}"); + } + + /// + /// Supplementary coverage, NOT the discriminator: a steep face rising + /// from a cell BOUNDARY (whole neighboring cell raised). The primary + /// terrain sample is cell-scoped, so this topology resolves through + /// the cross-cell path and glides both pre- and post-fix; it pins the + /// boundary behavior so the diagonal fix cannot regress it. + /// + [Fact] + public void CellBoundaryFace_Angled45_AlsoGlides() + { + var engine = BuildBoundaryFaceEngine(); + var body = NewGroundedBody(); + + var position = new Vector3(91f, 36f, 0f); + uint cell = TerrainSurface.ComputeOutdoorCellId(0xA9B4FFFFu, 91f, 36f); + float d = DxyPerTick * 0.70710678f; + + for (int tick = 0; tick < 40; tick++) + { + var result = engine.ResolveWithTransition( + currentPos: position, + targetPos: new Vector3(position.X + d, position.Y + d, position.Z), + cellId: cell, + sphereRadius: 0.47f, + sphereHeight: 1.20f, + stepUpHeight: 0.60f, + stepDownHeight: 1.50f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x5000000Au); + position = result.Position; + cell = result.CellId; + } + + Assert.True(position.Y - 36f > 0.5f, + $"expected lateral advance along the boundary face, got " + + $"{position.Y - 36f:F3} m"); + Assert.True(position.Z < 1.0f, + $"expected no climb up the boundary face, got Z={position.Z:F3}"); + } + + private static float LateralAdvance(Vector3 finalPos) + => (finalPos.X - StartX) * Lateral.X + (finalPos.Y - StartY) * Lateral.Y; + + private static (Vector3 FinalPos, int StuckTicks) RunApproach( + float angleFromPerpendicularDeg) + { + var engine = BuildDiagonalFaceEngine(); + var body = NewGroundedBody(); + + float rad = angleFromPerpendicularDeg * MathF.PI / 180f; + Vector2 dir = MathF.Cos(rad) * IntoFace + MathF.Sin(rad) * Lateral; + float dx = DxyPerTick * dir.X; + float dy = DxyPerTick * dir.Y; + + var position = new Vector3(StartX, StartY, 0f); + uint cell = TerrainSurface.ComputeOutdoorCellId(0xA9B4FFFFu, StartX, StartY); + int stuckTicks = 0; + + for (int tick = 0; tick < Ticks; tick++) + { + var result = engine.ResolveWithTransition( + currentPos: position, + targetPos: new Vector3(position.X + dx, position.Y + dy, position.Z), + cellId: cell, + sphereRadius: 0.47f, + sphereHeight: 1.20f, + stepUpHeight: 0.60f, + stepDownHeight: 1.50f, + isOnGround: true, + body: body, + moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide, + movingEntityId: 0x5000000Au); + + // The stuck-tick predicate, from positions: nonzero XY request, + // zero XY delivered. + if (result.Position.X == position.X && result.Position.Y == position.Y) + stuckTicks++; + + position = result.Position; + cell = result.CellId; + } + + return (position, stuckTicks); + } + + private static PhysicsBody NewGroundedBody() => new() + { + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Active | TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + }; + + /// + /// Only cell (3,3)'s TL post (x-index 3, y-index 4) is raised: its + /// below-diagonal triangle stays flat at z=0 and its above-diagonal + /// triangle carries the 32 m rise (N.z ≈ 0.469, too steep). x-major + /// heights[x*9+y]; heightTable[i] = i meters. + /// + private static PhysicsEngine BuildDiagonalFaceEngine() + { + var heights = new byte[81]; + heights[3 * 9 + 4] = 32; + return BuildEngine(heights); + } + + /// + /// Posts 0..4 flat at 0, posts 5..8 at 32 m: cell cx=4 (x in [96,120]) + /// carries the rise as a whole-cell face on the x=96 boundary + /// (N = (-0.8, 0, 0.6)). + /// + private static PhysicsEngine BuildBoundaryFaceEngine() + { + var heights = new byte[81]; + for (int x = 5; x < 9; x++) + for (int y = 0; y < 9; y++) + heights[x * 9 + y] = 32; + return BuildEngine(heights); + } + + private static PhysicsEngine BuildEngine(byte[] heights) + { + var heightTable = new float[256]; + for (int i = 0; i < 256; i++) heightTable[i] = i; + + var engine = new PhysicsEngine(); + engine.AddLandblock( + 0xA9B4FFFFu, + new TerrainSurface(heights, heightTable), + Array.Empty(), + Array.Empty(), + worldOffsetX: 0f, + worldOffsetY: 0f); + return engine; + } +}