diff --git a/docs/ISSUES.md b/docs/ISSUES.md index 66154abc..328ae8a3 100644 --- a/docs/ISSUES.md +++ b/docs/ISSUES.md @@ -183,7 +183,7 @@ ACE's >= reading" warning. ## #265 — Steep-slope response set: uphill-jump bounce, roof slides lost, edge wedge (TS-4 removal fallout — REVERTED) -**Status:** FIX IMPLEMENTED 2026-07-30 (this session) — closure pends the +**Status:** IMPLEMENTED 2026-07-30 (landing-bounce rework: retail check_contact seed + SetPositionInternal commit + live 5% elasticity reflect; docs/research/2026-07-30-landing-bounce-family.md) — pending user live gate (downhill bounce chain, flat pop, uphill clean landing) user's visual-gate acceptance. The named culprit for symptoms (b) and (c) was capture-bisected to a THIRD, pre-existing (frozen-phase, predates Campaign P by ten days) mechanism — neither the S1 nor S2 suspects named diff --git a/docs/architecture/retail-divergence-register.md b/docs/architecture/retail-divergence-register.md index 44793f2e..a4d7306b 100644 --- a/docs/architecture/retail-divergence-register.md +++ b/docs/architecture/retail-divergence-register.md @@ -108,7 +108,7 @@ accepted-divergence entries (#96, #49, #50). | AD-37 | Camera rotation state is a forward VECTOR (nlerp + normalize; roll always 0, up = world Z); retail's sought carries a full Frame and slerps quaternions (`Frame::interpolate_rotation` shortest-path slerp with 2e-4 nlerp fallback). The dead-band compares forward-vector distance against the same 2e-4 epsilon retail applies per quaternion component | `src/AcDream.App/Rendering/RetailChaseCamera.cs` (`_dampedForward`, `ApplyConvergenceSnap`) | The chase camera never rolls (heading frames are Z-up by construction), so a forward vector spans the reachable rotation space; identified (not introduced) during the #180 UpdateCamera tail reading | If a future camera mode needs roll (death cam, cutscene) the vector state can't represent it; large-angle per-frame turns nlerp (chord) vs slerp (arc) — imperceptible at 0.45-stiffness step sizes | `Frame::interpolate_rotation` 0x00535390, `Frame::close_rotation` 0x00455d70; pseudocode doc 2026-07-06-camera-sought-position | | AD-39 | The `frames_stationary_fall` ladder + fsf≥3 UP-contact-plane manufacture runs AFTER acdream's fused LKCP-restore/contact-marking block, deriving retail's `_redo` as `cleanAdvance \|\| OnWalkable`; retail (ACE Transition.cs:1029-1061) interleaves the fsf block BETWEEN the LKCP-restore (sets `_redo`) and the contact-marking (reads the manufactured plane) (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/TransitionTypes.cs` (`ValidateTransition` fsf tail) | acdream deliberately fused ACE's separate LKCP-restore + contact-mark blocks (the L.2.3c/L.2.4/A6.P3 contact-retention divergences); running the ladder after them and re-marking grounding inside the manufacture branch is semantically equal (a grounded wall-slide is not a stuck-fall in either arrangement) without disturbing those hard-won fixes | If a future contact-retention change alters when OnWalkable is set relative to the ladder, `_redo` could misclassify a frame (grounded-jam mistaken for stuck-fall → spurious velocity zero, or vice-versa) — the fsf conformance tests pin the current arrangement | `CTransition::validate_transition` 0x0050aa70 pc:272625-656; ACE Transition.cs:1029-1061 | | AD-40 | The fsf `Stationary*` transient-bit encode (fsf→0x10/0x20/0x40) lives in the Core resolve writeback (`PhysicsEngine.ResolveWithTransition`), co-located with the fsf computation; retail encodes it in `handle_all_collisions` (pc:282737-758). Also: `PhysicsBody.CachedVelocity` is computed at the player chokepoint but not yet consumed — outbound wire velocity still uses the existing `get_state_velocity` path, not retail's cached_velocity source (#182 rebuild, 2026-07-07) | `src/AcDream.Core/Physics/PhysicsEngine.cs` (writeback); `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`CachedVelocity`) | Encoding in the writeback keeps the seed→ladder→writeback→seed round-trip self-contained in Core (testable without the App loop); the bit values + timing are identical to retail's (set after fsf is final, before the next resolve). CachedVelocity is faithful to carry now; routing the wire through it is a separate, unmeasured change | If a future consumer reads the Stationary* bits expecting retail's handle_all_collisions to have set them (it doesn't run in Core), the Core writeback is the source of truth; a wire-reporting change that assumes CachedVelocity is live would send the wrong velocity until it's wired | `handle_all_collisions` bit encode pc:282737-758; `get_velocity` 0x005113c0 (cached_velocity reader) | -| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses ONLY `handle_all_collisions` + `cached_velocity` on a no-move frame; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | +| AD-41 | The `candidateMoved` gate (retail UpdateObjectInternal pc:283657 `candidate != m_position`) suppresses the WHOLE SetPositionInternal-shaped commit (contact/walkable flags, HitGround/LeaveGround, `handle_all_collisions`, `cached_velocity`) on a no-move frame — narrowed 2026-07-30 (#265 bounce rework) from "only handle_all_collisions"; acdream still runs `ResolveWithTransition` (zero-distance) for cell/contact tracking, where retail skips the whole transition (#182 rebuild, 2026-07-07) | `src/AcDream.Runtime/Gameplay/PlayerMovementController.cs` (`candidateMoved` guard) | The load-bearing effect is not re-zeroing the gravity velocity that rebuilds after a stuck-fall bleed; the zero-distance resolve is a near-no-op (numSteps 0 → the zero-step early return, no ValidateTransition, contact plane persists via the writeback), so running it is harmless while keeping acdream's per-frame cell/membership refresh | If the zero-distance resolve ever gains a side effect on a no-move frame (a contact-plane clear, an fsf change), it would diverge from retail's skip — a no-move frame must stay a near-no-op | `CPhysicsObj::UpdateObjectInternal` 0x005156b0 pc:283657 (candidate-moved gate) | | AD-42 | Enter-world placement is split across two Core calls: legacy `Resolve` performs retail `AdjustPosition` + the host's established floor snap, then `ResolvePlacement` runs the verbatim object-aware `find_placement_pos` ring search. Retail runs initial environment placement, ring search, and final step-down inside one `find_placement_position` transition | `src/AcDream.App/Rendering/GameWindow.cs` (`EnterPlayerModeNow`); `src/AcDream.Core/Physics/PhysicsEngine.cs` (`ResolvePlacement`) | The first call has already committed the same validated cell/floor point that feeds the ring search; the second call uses the same sphere dimensions, collision registry, and cell id. Keeping the split preserves the proven indoor-login snap while adding the missing occupied-position behavior | A spawn that requires retail's final placement step-down after a ring candidate (rather than the existing floor snap before it) could settle at a slightly different Z on a ledge/water boundary; the overlap is still cleared | `CPhysicsObj::enter_world` 0x00516170; `CTransition::find_placement_position` 0x0050C170; `CTransition::find_placement_pos` 0x0050BA50 | | AD-43 | A malformed/custom PhysicsScript `CallPES` cycle whose script timeline never advances is rejected with a diagnostic; retail's linked scheduler would continue draining that zero-time tail indefinitely | `src/AcDream.Core/Vfx/PhysicsScriptRunner.cs` (timeline-progress ancestry guard) | Prevents corrupt DAT content from hanging the single update/render thread. Installed-DAT audit plus conformance tests prove the real rolling-weather cycles advance 2.8 seconds per edge and continue unchanged; only a no-progress strongly connected cycle is rejected | A custom DAT that deliberately relies on an infinite zero-time loop observes a rejected play instead of freezing the client | `ScriptManager::AddScriptInternal` 0x0051B310; `ScriptManager::UpdateScripts` 0x0051B480; `CPhysicsObj::CallPES` 0x00511AF0 | | AD-44 | acdream has no retained character-management screen: startup deterministically selects the first active, non-greyed CharacterList identity, and native-window close performs retail's complete character-logoff handshake plus transport disconnect before exiting instead of returning to character selection. One active `ReceiverData` equivalent means `ClientNet::LogOffServer`'s per-receiver loop sends one header. | `src/AcDream.Core.Net/Messages/CharacterList.cs` (`TrySelectFirstAvailable`); `src/AcDream.App/Rendering/GameWindow.cs` (live-session bootstrap, moving to `LiveSessionController` in Slice 3); `src/AcDream.Core.Net/WorldSession.cs` (`SelectCharacterForEnterWorld`, `Dispose`); `src/AcDream.Core.Net/Packets/TransportDisconnect.cs` | This preserves unattended startup and immediate ACE endpoint release while validating that the chosen identity is active/non-greyed and using the server's canonical account. A future retained character-management owner is separate UI/session work. | An account with multiple playable characters enters the first wire-order identity without retail's explicit choice. An eventual in-client "log off character" action cannot reuse the process-exit path; it must retain the authenticated socket after server `0xF653` and return to character management. | `gmCharacterManagementUI::SelectCharacter @ 0x004EC160`; `gmCharacterManagementUI::EnterGame @ 0x004ED440`; `gmCharGenMainUI::Update @ 0x004E8460`; `Proto_UI::LogOffCharacter @ 0x00546A20`; `CPlayerSystem::RequestLogOff @ 0x00562DD0`; `CPlayerSystem::ExecuteLogOff @ 0x0055D780`; `ClientNet::LogOffServer @ 0x00543EF0`; `SharedNet::SendOptionalHeader @ 0x00543160` | diff --git a/docs/research/2026-07-30-landing-bounce-family.md b/docs/research/2026-07-30-landing-bounce-family.md new file mode 100644 index 00000000..021f1064 --- /dev/null +++ b/docs/research/2026-07-30-landing-bounce-family.md @@ -0,0 +1,196 @@ +# The landing bounce family — retail bounce vs ground vs slide (investigation, report-only) + +**Date:** 2026-07-30 · **Status:** IMPLEMENTED (same day — see §Implementation) +**Symptoms (user, live gate):** (1) downhill jumps glide instead of bouncing; +(2) flat-ground jumps at speed/height don't bounce; (3) uphill jumps get stuck +in weird animations, flapping and gliding. Speed (#266) and roof slide +(#265's freeze) are fixed and unaffected. + +## The retail mechanism (decomp, read end-to-end this session) + +Three functions compose the whole behavior: + +### 1. The floor-touch dual record (plane handler, 0x0050d100-0x0050d30c) + +Touching a floor plane records **two independent facts**: + +```c +if (step_down || !(state & CONTACT) || is_valid_walkable(plane)) + set_contact_plane(plane) // grounding fact +if (!(state & CONTACT) && !step_down) { + set_collision_normal(plane.N); // collision fact + collided_with_environment = 1; +} +``` + +A landing (not already in contact, not a step-down probe) is BOTH a contact +AND an environment collision carrying the floor normal. Ordinary walking +(already in contact / step-down glue probes) records only the contact — +that is why walking never bounces. +**acdream's transition already ports this faithfully** +(`TransitionTypes.cs:3410-3415` — `!oi.Contact && !sp.StepDown` → +`SetCollisionNormal` + `CollidedWithEnvironment = true`). + +### 2. SetPositionInternal (0x00515330, read fully — VELOCITY-SIGN-FREE) + +``` +contact = collision_info.contact_plane_valid (no velocity test) +on_walkable = contact && contact_plane.N.z >= floor_z (set_on_walkable → HitGround/LeaveGround) +handle_all_collisions(collision_info, prevContact, prevOnWalkable) ← velocity UNMODIFIED +``` + +There is **no `Velocity.Z <= 0` landing gate and no velocity zeroing** +anywhere in retail's commit. Contact is a per-frame fact from the +transition's contact plane; the bounce is the velocity reflect; they are +independent and coexist — you can be "landed" this frame AND carry +reflected +Z that lifts you off next frame. That IS the bounce chain. +(Our Core `PhysicsObjUpdate.CommitSetPositionTransition` is already a +faithful port of this function — used by teleport/remote placement, NOT by +the local player's per-tick path.) + +### 3. handle_all_collisions (0x00514780) + elasticity + +For fsf≤1, should-reflect (NOT(was-walkable AND still-walkable) or the +garbled state-flag override), valid collision normal, and `v·n < 0`: + +``` +v += -(v·n) · (elasticity + 1) · n // pc:282712 +``` + +`DEFAULT_ELASTICITY = 0.05` (byte constant @0x007c6a7c; ctor writes at +0x005124d3/0x0051d537; `set_elasticity` clamps to [0, 0.1]). So every +landing reverses 5% of the impact's normal component and keeps the full +tangential component: + +- **Flat ground at speed/height:** v=(6,0,−7) → v'=(6,0,+0.35) — forward + carry plus a visible pop at speed. Symptom (2). +- **Downhill:** reflect is off the SLOPE normal — each contact pops the body + off-slope while tangential speed persists → contact/airborne chain = + the characteristic downhill bounce. Symptom (1). +- **Uphill:** the reflect kills the into-slope component at impact, contact + stands, HitGround fires once, land animation plays. Symptom (3)'s clean + retail counterpart. + +## Why acdream glides/flaps instead (the adaptation stack) + +`PlayerMovementController.cs:2032-2079` (the per-tick commit) replaces +retail's SetPositionInternal with a hand-rolled block: + +1. **AD-25 landing gate:** `if (resolveResult.IsOnGround && Velocity.Z <= 0)` + — needed because *our resolver reports IsOnGround even during an UPWARD + jump (it always step-downs)*. Retail has no such gate: an ascending mover + simply finds no contact plane (it moves away from it; the touch test + fails), so contact clears naturally. +2. **The bounce killer:** `if (Velocity.Z < 0) Velocity.Z = 0` on landing, + whose comment says its purpose plainly: *"makes handle_all_collisions' + landing reflect a no-op — dot(v,n)=0."* This retired the old + "micro-bounce death spiral" — but that spiral was caused by our OWN + gate (reflected +Z defeating the `Velocity.Z<=0` landing test), not by + the reflect being wrong. The workaround deleted retail's legitimate + bounce. +3. With the reflect suppressed, the new #265 residual-velocity fix correctly + preserves landing momentum — which now SLIDES via calc_friction instead + of bouncing. Hence "I glide but that's incorrect." +4. **Uphill flap:** during the up-leg our resolver glues to the slope + (IsOnGround true) while the gate refuses to ground (v.z > 0) → + Contact/OnWalkable and HitGround/LeaveGround edges cycle against the + animation state machine → "weird animations, flapping and gliding." + +## Hypotheses (ranked) + +1. **H1 (root, high confidence — every link read this session):** the + AD-25 landing gate + Velocity.Z hand-zero must be REPLACED by retail's + SetPositionInternal semantics, which requires first fixing the underlying + resolver divergence: **the transition must not produce a contact plane + for a mover ascending away from the ground** (retail's step-down/touch + conditions do this naturally; ours "always step-downs"). With that fixed, + route the per-tick commit through the already-ported + `CommitSetPositionTransition` and delete the hand-rolled block — reflect, + contact, HitGround/LeaveGround, and land animation then compose exactly + as retail. + - Falsify by: cdb trace on retail (bp SetPositionInternal + + handle_all_collisions, dump v before/after while jumping downhill) — + expect unmodified impact v entering, 5% normal reversal exiting. +2. **H2 (contributing detail):** the garbled `state & ` override in + handle_all_collisions' gate (our port maps it to Sledding) and the + `0x20000` Inelastic mapping need byte-decode confirmation before the + rework — a wrong flag here changes when reflects fire while grounded. +3. **H3 (animation-side residual):** if flap persists after H1, the + MotionInterp land/fall transition (LandJump vs falling-hold) has its own + gate to audit — deferred until H1 is in. + +## What we've ruled out + +- The transition's landing dual-record being missing — ours is faithful + (TransitionTypes.cs:3410). +- HandleAllCollisions' reflect math/elasticity — ported correctly + (PhysicsObjUpdate.cs:198, elasticity 0.05 default present). +- The #265 residual-velocity fix being wrong — it exposed the missing + bounce; it didn't cause it. + +## Recommended next step + +Approve H1 for implementation: (a) byte-decode the two garbled flags (H2) +first; (b) find + port retail's exact ascent/step-down gating in the +transition (the one remaining unread mechanism); (c) cut the per-tick commit +over to `CommitSetPositionTransition`; (d) re-run the roof/downhill/flat/ +uphill matrix live. Optional pre-implementation confirmation: the H1 cdb +trace against live retail. + +## What this is NOT + +Not a missing-elasticity port and not a missing collision-record — both +exist and are faithful; the bounce is suppressed by our own landing-commit +adaptation (AD-25 family), whose reason-for-being is the resolver's +ascent-glue divergence. + + +## Implementation (2026-07-30, user-approved) + +All three retail mechanisms are now live; the AD-25 adaptation stack is +deleted: + +1. **check_contact seeding** (`PhysicsEngine.ResolveWithTransition`): a body + in transient CONTACT seeds the transition's contact state ONLY while + `v · contactPlane.N <= ε` (0.0002 = PhysicsGlobals.EPSILON, retail + 0x0050f5b0); a failing body seeds the last-known plane alone (retail + get_object_info's init_last_known_contact_plane branch). The plane + requirement is strict — Contact-without-plane is unrepresentable in + retail. Body-less callers keep the legacy isOnGround seed (test rigs). +2. **SetPositionInternal-shaped commit** (`PlayerMovementController`): the + `Velocity.Z <= 0` landing gate and the landing `Velocity.Z = 0` hand-zero + are DELETED. Contact commits purely from `resolveResult.InContact` / + `OnWalkable`, HitGround fires on the airborne→walkable edge, and + `HandleAllCollisions` runs with the UNMODIFIED impact velocity — the 5% + elasticity reflect is live. The whole commit is gated on + `resolveResult.Ok && candidateMoved` (retail runs SetPositionInternal + only when the transition succeeded AND the candidate moved — pc:283657; + AD-41's row updated accordingly). Zero-move frames leave contact state + untouched (this is what keeps a standing body stable: a zero-move + resolve cannot re-derive a plane because no sweep runs). +3. **Byte decodes** (this doc's H2): the handle_all_collisions gate override + is `state & 0x800000` = Sledding; the zero branch is `state & 0x20000` = + Inelastic; the reflect fires strictly on `dot < 0` (`test ah, 5; jp`). + Our port had all three correct already — no change. + +Settle behavior: a real landing (|v| ≥ 0.25 m/s) bounces at 5% and the hop +chain decays geometrically; sub-0.25 m/s impacts are consumed by retail's +unconditional small-velocity zero (PhysicsBody.UpdatePhysicsInternal), so a +standing body never micro-bounces. calc_acceleration turns gravity off for +Contact+OnWalkable bodies, which is what makes rest bit-stable. + +Test re-baselines (each documented in place): the landing-survival pin now +measures decay after the hop chain settles; `LiveCompare_Tick0/376` pin the +new IsOnGround=false on their zero-move ticks (the captured `true` was the +retired seed echo — tick 376's captured body even carries an 11.8 m/s +grounded velocity from the deleted get_state_velocity-overwrite era); +`RemoteDeOverlapMechanismTests.GroundedBody` now carries the plane a real +grounded body always has (the big-creature 1.80 m expectation was calibrated +against the unrepresentable flags-without-plane fixture; production settles +at 1.58 m, unchanged before/after). New pins: +`LandingBounceSeedingTests` (ascent no-seed, rest keeps-contact, strict +plane, slope 5% reversal + tangential preservation, Sledding override). + +Verification: complete Release suite 10,031 passed / 5 skips / 0 failures. +Live gate (downhill bounce chain, flat-ground pop, uphill clean landing, +roof slide intact, walking intact) pends the user's next session. diff --git a/src/AcDream.Core/Physics/PhysicsEngine.cs b/src/AcDream.Core/Physics/PhysicsEngine.cs index b800fecb..0ea2d6e9 100644 --- a/src/AcDream.Core/Physics/PhysicsEngine.cs +++ b/src/AcDream.Core/Physics/PhysicsEngine.cs @@ -1167,37 +1167,63 @@ public sealed class PhysicsEngine // in ValidateTransition runs for gravity movers (the player) and not floating props. transition.ObjectInfo.MoverHasGravity = body?.HasGravity ?? false; - if (isOnGround) - transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; - - // K-fix7 (2026-04-26): only seed the contact plane when the body - // is actually grounded. Pre-seeding while AIRBORNE caused - // AdjustOffset's "Have a contact plane / Moving away from plane" - // branch to fire on every jump step — which calls - // Plane::snap_to_plane on the offset and ZEROES the Z component, - // killing all upward jump motion. + // Landing-bounce family (#265, 2026-07-30, + // docs/research/2026-07-30-landing-bounce-family.md): the retail + // seed is CPhysicsObj::get_object_info (0x00511cc0) — a body in + // transient CONTACT is re-checked per transition by + // CPhysicsObj::check_contact (0x0050f5b0): contact HOLDS only while + // v · contact_plane.N <= ε (0.0002), i.e. the mover is not moving + // AWAY from its plane. A jump launch fails the check instantly, so + // the transition runs contact-free (no step-down glue, ballistic + // ascent, no contact plane found → SetPositionInternal clears + // CONTACT naturally). The failed-check branch seeds only the + // LAST-KNOWN contact plane (init_last_known_contact_plane) — plane + // context without contact state. This replaces the former + // isOnGround-driven seed (the "resolver reports ground during an + // ascending jump" divergence that forced the AD-25 landing gate). // - // We KEEP the seeding when isOnGround for slope-walking + step-up - // continuity (the original concern that motivated the seed). - // BSP step_up needs ContactPlane on sub-step 1 to compute the - // correct lift direction; removing the seed breaks stair-walking - // at the last step (verified by A6.P3 slice 2 first attempt - // 2026-05-22, reverted in this commit). Retail's CTransition::init - // explicitly CLEARS contact_plane_valid; we deliberately diverge - // for step_up correctness. - // - // A6.P3 slice 2 (2026-05-22) — to close issue #96 (per-tick CP-write - // blowup) without breaking stair-walking, the no-op-if-unchanged - // guard inside CollisionInfo.SetContactPlane (TransitionTypes.cs:259) - // collapses redundant seeds (same plane every tick) to a true no-op. - // The seed still fires the function call but only counts as a write - // when the plane values actually change. - if (isOnGround && body is not null && body.ContactPlaneValid) + // K-fix7 lineage: pre-seeding a full contact plane while airborne + // made AdjustOffset's snap-to-plane zero jump Z — check_contact is + // retail's own version of that guard. Grounded walking (v·n ≈ 0) + // keeps the plane seed for slope/step-up continuity exactly as + // before (A6.P3 slice 2: SetContactPlane's no-op-if-unchanged guard + // still collapses redundant per-tick seeds). + // A contact WITHOUT a stored plane is unrepresentable in retail + // (init_contact_plane always accompanies the CONTACT seed), so the + // plane requirement here is strict: a body flagged Contact but with + // no committed plane (e.g. the tick after a placement that never + // swept) seeds nothing — its first moving resolve re-derives + // contact from the geometry it actually touches. + if (body is not null && body.InContact && body.ContactPlaneValid) { - transition.CollisionInfo.SetContactPlane( - body.ContactPlane, - body.ContactPlaneCellId, - body.ContactPlaneIsWater); + // retail ε 0.000199999995f == PhysicsGlobals.EPSILON (0.0002f). + float awayRate = Vector3.Dot(body.Velocity, body.ContactPlane.Normal); + if (awayRate <= PhysicsGlobals.EPSILON) + { + transition.ObjectInfo.State |= ObjectInfoState.Contact; + if (body.OnWalkable) + transition.ObjectInfo.State |= ObjectInfoState.OnWalkable; + transition.CollisionInfo.SetContactPlane( + body.ContactPlane, + body.ContactPlaneCellId, + body.ContactPlaneIsWater); + } + else + { + // retail get_object_info failed-check branch: + // CTransition::init_last_known_contact_plane. + transition.CollisionInfo.LastKnownContactPlaneValid = true; + transition.CollisionInfo.LastKnownContactPlane = body.ContactPlane; + transition.CollisionInfo.LastKnownContactPlaneCellId = body.ContactPlaneCellId; + transition.CollisionInfo.LastKnownContactPlaneIsWater = body.ContactPlaneIsWater; + } + } + else if (body is null && isOnGround) + { + // Body-less callers (one-shot probes, tests) keep the legacy + // grounded seed — they have no velocity/plane to run + // check_contact against. + transition.ObjectInfo.State |= ObjectInfoState.Contact | ObjectInfoState.OnWalkable; } // Retail CPhysicsObj::get_object_info also seeds SlidingNormal when diff --git a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs index 32ff26ed..fb8dc1ab 100644 --- a/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs +++ b/src/AcDream.Runtime/Gameplay/PlayerMovementController.cs @@ -2029,50 +2029,70 @@ public sealed class PlayerMovementController _prevPhysicsPos = oldTickEndPos; _currPhysicsPos = _body.Position; - // SetPositionInternal contact determination (pc:283468-510). acdream's resolver - // reports IsOnGround even during an UPWARD jump (it always step-downs), so the - // contact-plane intent stays gated by Velocity.Z<=0 (documented adaptation AD-25): a - // jump stays airborne until it descends. Determined BEFORE handle_all_collisions so the - // landing state is committed before any reflect — this ordering plus the ungated - // small-velocity-zero (Slice 1a) is what retires AD-25's micro-bounce death spiral - // (the old code reflected FIRST, so the reflected +Z defeated the landing gate). + // Landing-bounce family (#265, 2026-07-30, + // docs/research/2026-07-30-landing-bounce-family.md): retail + // SetPositionInternal (0x00515330) commits contact PURELY from the + // transition's contact plane — no velocity-sign gate, no velocity + // zeroing. The former AD-25 gate (Velocity.Z<=0) existed because + // the resolver glued ascending movers to the ground; the + // check_contact seed (PhysicsEngine, retail 0x0050f5b0) now stops + // that at the source — an ascending jump finds no contact plane and + // goes airborne naturally. The former Velocity.Z hand-zero + // explicitly defeated handle_all_collisions' landing reflect; + // deleting it restores retail's landing bounce + // (v += -(v·n)(elasticity+1)·n, DEFAULT_ELASTICITY 0.05): the flat + // landing pop, the downhill bounce chain, and the uphill + // into-slope velocity kill all come from that reflect. + // + // Retail reaches SetPositionInternal only when the transition + // succeeded (CPhysicsObj::transition 0x00512DC0 discards the + // CTransition on find_valid_position failure) — contact state stays + // untouched on failure frames. fsf-wedge frames are OK frames + // (ValidateTransition manufactures the UP contact), so the fsf>1 + // bleed below remains reachable exactly as before. + // The WHOLE commit is additionally gated on candidateMoved: retail + // UpdateObjectInternal (pc:283657) only runs the transition + + // SetPositionInternal when the integrated candidate MOVED — a + // standing body's contact state is never re-derived (a zero-move + // resolve cannot "find" a contact plane because no sweep runs; the + // old code masked this by echoing the caller's isOnGround back + // through the seeded oi flags). This same gate is what keeps a + // post-bleed no-move frame from re-zeroing the rebuilding gravity + // velocity (#182). bool landedThisQuantum = false; - if (resolveResult.IsOnGround && _body.Velocity.Z <= 0f) + if (resolveResult.Ok && candidateMoved) { - bool wasAirborne = !_body.OnWalkable; - _body.TransientState |= TransientStateFlags.Contact | TransientStateFlags.OnWalkable; - _body.calc_acceleration(); + if (resolveResult.InContact) + _body.TransientState |= TransientStateFlags.Contact; + else + _body.TransientState &= ~TransientStateFlags.Contact; + _body.calc_acceleration(); // pc:283442 (post-contact-bit) - // Stop the fall on landing (retail settles the into-ground component via - // calc_friction next frame; the hand-zero avoids a one-frame floor dip and makes - // handle_all_collisions' landing reflect a no-op — dot(v,n)=0). - if (_body.Velocity.Z < 0f) - _body.Velocity = new Vector3(_body.Velocity.X, _body.Velocity.Y, 0f); - - if (wasAirborne) + if (resolveResult.InContact && resolveResult.OnWalkable) { - // R4-V5 → R5-V5: retail order — minterp then moveto - // (MovementManager::HitGround 0x00524300). Re-arms a moveto suspended by the - // airborne UseTime contact gate. LeaveGround has NO moveto side (§2e). - Movement.HitGround(); - landedThisQuantum = true; + bool wasAirborne = !_body.OnWalkable; + _body.TransientState |= TransientStateFlags.OnWalkable; + if (wasAirborne) + { + // R4-V5 → R5-V5: retail order — minterp then moveto + // (MovementManager::HitGround 0x00524300). Re-arms a + // moveto suspended by the airborne UseTime contact gate. + // LeaveGround has NO moveto side (§2e). + Movement.HitGround(); + landedThisQuantum = true; + } } - } - else - { - // Airborne: jumping up (IsOnGround but v.z>0) OR no ground found. - _body.TransientState &= ~(TransientStateFlags.Contact | TransientStateFlags.OnWalkable); - _body.calc_acceleration(); - } + else + { + _body.TransientState &= ~TransientStateFlags.OnWalkable; + } + _body.calc_acceleration(); // pc:283475/283490 (set_on_walkable tail) - // handle_all_collisions (0x00514780): reflect the into-surface velocity (fsf≤1) or - // ZERO it entirely (fsf>1 — THE airborne-stuck fix). The Stationary* bit round-trip is - // owned by the Core resolve writeback. Restores retail's should_reflect rule; on a - // landing the Velocity.Z hand-zero above makes the reflect a no-op (no micro-bounce). - // Gated on candidateMoved (retail SetPositionInternal is only reached when the candidate - // moved) so a no-move frame doesn't re-zero the gravity velocity rebuilding after a bleed. - if (candidateMoved) - { + // handle_all_collisions (0x005154FE): reflect the into-surface + // velocity (fsf≤1 — v += -(v·n)(elasticity+1)·n, the landing + // bounce) or ZERO it entirely (fsf>1 — THE airborne-stuck + // fix). The Stationary* bit round-trip is owned by the Core + // resolve writeback. PhysicsObjUpdate.HandleAllCollisions( _body, resolveResult.CollisionNormalValid, resolveResult.CollisionNormal, diff --git a/tests/AcDream.App.Tests/UI/Layout/VitaeColorDumpProbe.cs b/tests/AcDream.App.Tests/UI/Layout/VitaeColorDumpProbe.cs new file mode 100644 index 00000000..4c3aade8 --- /dev/null +++ b/tests/AcDream.App.Tests/UI/Layout/VitaeColorDumpProbe.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Text; +using AcDream.App.UI.Layout; +using DatReaderWriter; +using DatReaderWriter.Options; +using Xunit; + +namespace AcDream.App.Tests.UI.Layout; + +// THROWAWAY probe (#268): dump the authored 0x1B font-color arrays for the +// character-panel footer labels so we can read retail's vitae parenthetical +// color (AppendTextWithFont color index 3, gmSkillUI 0x0049b972). Delete +// after the color constant is captured. +public sealed class VitaeColorDumpProbe +{ + [Fact] + public void Dump_footer_font_color_arrays() + { + var datDir = Environment.GetEnvironmentVariable("ACDREAM_DAT_DIR") + ?? Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), + "Documents", "Asheron's Call"); + if (!Directory.Exists(datDir)) return; + + var sb = new StringBuilder(); + using var dats = new DatCollection(datDir, DatAccessType.Read); + foreach (uint layoutId in new[] { 0x2100002Eu, 0x2100002Cu }) + { + var root = LayoutImporter.ImportInfos(dats, layoutId); + if (root is null) { sb.AppendLine($"layout {layoutId:X8}: NOT FOUND"); continue; } + sb.AppendLine($"=== layout {layoutId:X8} ==="); + Walk(root, sb); + } + + var outPath = Path.Combine(AppContext.BaseDirectory, "vitae-color-dump.txt"); + File.WriteAllText(outPath, sb.ToString()); + // Also drop a copy next to the repo artifacts when resolvable. + var repoCopy = Environment.GetEnvironmentVariable("ACDREAM_PROBE_OUT"); + if (!string.IsNullOrEmpty(repoCopy)) + File.WriteAllText(repoCopy, sb.ToString()); + } + + private static void Walk(ElementInfo e, StringBuilder sb) + { + if (e.TryGetEffectiveProperty(0x1Bu, out var color)) + { + if (color.Kind == UiPropertyKind.Array && color.ArrayValue.Count > 1) + { + sb.Append($"element {e.Id:X8} 0x1B array[{color.ArrayValue.Count}]:"); + for (int i = 0; i < color.ArrayValue.Count; i++) + { + var v = color.ArrayValue[i]; + if (v.Kind == UiPropertyKind.Color) + { + var c = v.ColorValue; + sb.Append($" [{i}]=A{c.Alpha:D3},R{c.Red:D3},G{c.Green:D3},B{c.Blue:D3}"); + } + else + { + sb.Append($" [{i}]=kind:{v.Kind}"); + } + } + sb.AppendLine(); + } + else if (color.Kind == UiPropertyKind.Color) + { + var c = color.ColorValue; + sb.AppendLine( + $"element {e.Id:X8} 0x1B single: A{c.Alpha:D3},R{c.Red:D3},G{c.Green:D3},B{c.Blue:D3}"); + } + } + + foreach (var child in e.Children) + Walk(child, sb); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs index 386d5e9a..65ebf807 100644 --- a/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs +++ b/tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs @@ -606,12 +606,25 @@ public class CellarUpTrajectoryReplayTests : IDisposable /// motion). The simplest test case: replay the call and verify the /// harness produces the same ResolveResult + bodyAfter state. /// + /// + /// #265 bounce rework (2026-07-30, + /// docs/research/2026-07-30-landing-bounce-family.md): the capture's + /// Result.IsOnGround=true on this ZERO-MOVE tick was the old + /// seed's echo — the caller's isOnGround came straight back through the + /// seeded oi flags. The retail check_contact seed (get_object_info + /// 0x00511cc0) requires a stored contact plane, and this captured body + /// has cpV=false, so the modern resolve reports IsOnGround=false + /// here. Production no longer consumes IsOnGround on no-move frames + /// (the candidateMoved commit gate — retail pc:283657 skips + /// SetPositionInternal entirely), so the echo is intentionally gone; + /// the comparison pins the NEW value instead. + /// [Fact] public void LiveCompare_Tick0_Spawn() { var (engine, cache) = BuildEngineWithCellarFixtures(); var captured = LoadCapturedRecord(record => record.Tick == 0); - AssertCallMatchesCapture(engine, captured); + AssertCallMatchesCapture(engine, captured, expectStaleIsOnGroundEcho: true); } /// @@ -621,12 +634,24 @@ public class CellarUpTrajectoryReplayTests : IDisposable /// the same walkable polygon + ResolveResult, the ramp geometry is /// loaded correctly. /// + /// + /// #265 bounce rework (2026-07-30): same stale IsOnGround echo as + /// , with a second stale-era quirk: + /// the captured bodyBefore carries Velocity=(0.64, 11.83, 0) while + /// GROUNDED on the ramp — a value from the deleted + /// get_state_velocity-overwrite era (root motion owns walking now; + /// grounded Velocity stays ~0). dot(v, rampNormal)=+8.5 rightly fails + /// retail check_contact (0x0050f5b0: contact holds only while + /// v·n ≤ 0.0002), so the modern resolve refuses the stale contact on + /// this zero-move tick. Geometry checks (the ramp polygon) still + /// compare exactly. + /// [Fact] public void LiveCompare_Tick376_OnRamp() { var (engine, _) = BuildEngineWithCellarFixtures(); var captured = LoadCapturedRecord(record => record.Tick == 376); - AssertCallMatchesCapture(engine, captured); + AssertCallMatchesCapture(engine, captured, expectStaleIsOnGroundEcho: true); } /// @@ -838,7 +863,8 @@ public class CellarUpTrajectoryReplayTests : IDisposable /// private static void AssertCallMatchesCapture( PhysicsEngine engine, - ResolveCaptureRecord captured) + ResolveCaptureRecord captured, + bool expectStaleIsOnGroundEcho = false) { Assert.NotNull(captured.BodyBefore); Assert.NotNull(captured.BodyAfter); @@ -867,8 +893,19 @@ public class CellarUpTrajectoryReplayTests : IDisposable AddIfDifferent(divergences, "Result.CellId", $"0x{captured.Result.CellId:X8}", $"0x{harnessResult.CellId:X8}"); - AddIfDifferent(divergences, "Result.IsOnGround", - captured.Result.IsOnGround, harnessResult.IsOnGround); + if (expectStaleIsOnGroundEcho) + { + // #265 bounce rework: the capture's grounded echo on this tick is + // the retired pre-check_contact seed. Pin the NEW deterministic + // value (false) so any further drift still fails loudly. + AddIfDifferent(divergences, "Result.IsOnGround(post-#265)", + false, harnessResult.IsOnGround); + } + else + { + AddIfDifferent(divergences, "Result.IsOnGround", + captured.Result.IsOnGround, harnessResult.IsOnGround); + } AddIfDifferent(divergences, "Result.CollisionNormalValid", captured.Result.CollisionNormalValid, harnessResult.CollisionNormalValid); diff --git a/tests/AcDream.Core.Tests/Physics/LandingBounceSeedingTests.cs b/tests/AcDream.Core.Tests/Physics/LandingBounceSeedingTests.cs new file mode 100644 index 00000000..ac4c1033 --- /dev/null +++ b/tests/AcDream.Core.Tests/Physics/LandingBounceSeedingTests.cs @@ -0,0 +1,153 @@ +using System; +using System.Numerics; +using AcDream.Core.Physics; +using Xunit; + +namespace AcDream.Core.Tests.Physics; + +/// +/// Landing-bounce family (#265, 2026-07-30, +/// docs/research/2026-07-30-landing-bounce-family.md): the retail +/// check_contact transition seed (CPhysicsObj::get_object_info 0x00511cc0 → +/// check_contact 0x0050f5b0) plus the byte-decoded handle_all_collisions +/// gate flags. Complements HandleAllCollisionsTests (landing reflect, +/// walking no-reflect, Inelastic, fsf ladder — already pinned there). +/// +public class LandingBounceSeedingTests +{ + private const uint Lb = 0x00010000u; + private const uint Cell = 0x0001u; + + private static PhysicsEngine BuildFlatEngine() + { + var engine = new PhysicsEngine { DataCache = new PhysicsDataCache() }; + engine.AddLandblock(Lb, new TerrainSurface(new byte[81], new float[256]), + Array.Empty(), Array.Empty(), 0f, 0f); + return engine; + } + + private static PhysicsBody GroundedBody(Vector3 pos, Vector3 velocity) => new() + { + Position = pos, + Orientation = Quaternion.Identity, + State = PhysicsStateFlags.Gravity, + TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable, + Velocity = velocity, + ContactPlaneValid = true, + ContactPlane = new Plane(Vector3.UnitZ, 0f), + GroundNormal = Vector3.UnitZ, + }; + + private static ResolveResult ZeroMoveResolve(PhysicsEngine engine, PhysicsBody body) + => engine.ResolveWithTransition( + body.Position, body.Position, Cell, + sphereRadius: 0.48f, sphereHeight: 1.835f, + stepUpHeight: 0.55f, stepDownHeight: 0.55f, + isOnGround: body.OnWalkable, + body: body); + + [Fact] + public void CheckContact_AtRestGroundedBody_KeepsContactOnZeroMoveResolve() + { + // v·n = 0 ≤ ε → check_contact holds → the seed carries the contact + // plane through a zero-move resolve (retail standing still). + var engine = BuildFlatEngine(); + var body = GroundedBody(new Vector3(96f, 96f, 0.48f), Vector3.Zero); + + var result = ZeroMoveResolve(engine, body); + + Assert.True(result.IsOnGround); + Assert.True(result.InContact); + } + + [Fact] + public void CheckContact_AscendingJumper_SeedsNoContact() + { + // Jump launch: v·n = +5.4 > ε (0.0002) → retail check_contact fails → + // the transition runs contact-free (no glue, ballistic ascent). The + // zero-move probe therefore reports airborne even though the body's + // transient flags still say grounded from the previous tick. + var engine = BuildFlatEngine(); + var body = GroundedBody(new Vector3(96f, 96f, 0.48f), new Vector3(0f, 0f, 5.4f)); + + var result = ZeroMoveResolve(engine, body); + + Assert.False(result.IsOnGround); + Assert.False(result.InContact); + } + + [Fact] + public void CheckContact_ContactWithoutStoredPlane_SeedsNothing() + { + // A Contact body with NO stored plane is unrepresentable in retail + // (init_contact_plane always accompanies the CONTACT seed) — the + // strict seed refuses it rather than echoing the caller's flags. + var engine = BuildFlatEngine(); + var body = GroundedBody(new Vector3(96f, 96f, 0.48f), Vector3.Zero); + body.ContactPlaneValid = false; + + var result = ZeroMoveResolve(engine, body); + + Assert.False(result.IsOnGround); + } + + [Fact] + public void Reflect_DownhillSlopeLanding_FivePercentNormalReversal_TangentialKept() + { + // Slope normal 30° from vertical; impact velocity carries components + // both along and into the slope. Retail 0x0051490c-0x00514959: + // v' = v - (v·n)(elasticity+1)·n with DEFAULT_ELASTICITY 0.05 + // (byte constant @0x007c6a7c) — the normal component REVERSES at 5% + // (the bounce) and the tangential component is untouched (the carry). + var n = Vector3.Normalize(new Vector3(0f, 0.5f, 0.8660254f)); + var v = new Vector3(0f, 4f, -6f); + var body = new PhysicsBody + { + Velocity = v, + Elasticity = 0.05f, + FramesStationaryFall = 0, + }; + + PhysicsObjUpdate.HandleAllCollisions( + body, + collisionNormalValid: true, collisionNormal: n, + prevContact: false, prevOnWalkable: false, nowOnWalkable: true); + + float dotBefore = Vector3.Dot(v, n); + float dotAfter = Vector3.Dot(body.Velocity, n); + Assert.True(dotBefore < 0f); + // Normal component reversed and scaled by elasticity. + Assert.True(MathF.Abs(dotAfter - (-dotBefore * 0.05f)) < 1e-5f, + $"normal component: before={dotBefore}, after={dotAfter}"); + // Tangential component preserved bit-for-bit (the reflect only adds + // along n). + Vector3 tangBefore = v - n * dotBefore; + Vector3 tangAfter = body.Velocity - n * dotAfter; + Assert.True((tangAfter - tangBefore).Length() < 1e-5f); + } + + [Fact] + public void Reflect_SleddingOverridesGroundedSuppression() + { + // Byte decode 0x0051479c: `test dword [esi+0xa8], 0x800000` — + // PhysicsState.Sledding forces the reflect even while grounded-to- + // grounded (the downhill sled keeps bouncing). + var n = Vector3.UnitZ; + var body = new PhysicsBody + { + Velocity = new Vector3(3f, 0f, -2f), + Elasticity = 0.05f, + State = PhysicsStateFlags.Sledding, + FramesStationaryFall = 0, + }; + + PhysicsObjUpdate.HandleAllCollisions( + body, + collisionNormalValid: true, collisionNormal: n, + prevContact: true, prevOnWalkable: true, nowOnWalkable: true); + + Assert.True(MathF.Abs(body.Velocity.Z - 0.1f) < 1e-5f, + $"expected reflected +0.1 (=2·0.05), got {body.Velocity.Z}"); + Assert.Equal(3f, body.Velocity.X, precision: 5); + } +} diff --git a/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs index b86e25e3..1ac070ef 100644 --- a/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RemoteDeOverlapMechanismTests.cs @@ -82,6 +82,14 @@ public class RemoteDeOverlapMechanismTests TransientState = TransientStateFlags.Contact | TransientStateFlags.OnWalkable | TransientStateFlags.Active, Velocity = Vector3.Zero, + // #265 bounce rework (2026-07-30): a Contact body without a stored + // plane is unrepresentable in retail (init_contact_plane always + // accompanies the CONTACT seed), and the check_contact seeding now + // requires the plane. Give the fixture the state a real grounded body + // carries: the flat Z=0 terrain plane under its feet. + ContactPlaneValid = true, + ContactPlane = new System.Numerics.Plane(Vector3.UnitZ, 0f), + GroundNormal = Vector3.UnitZ, }; /// One catch-up-step + sweep for one creature (human dims); returns the resolved position. @@ -308,7 +316,14 @@ public class RemoteDeOverlapMechanismTests float sep = Vector2.Distance(new(a.Position.X, a.Position.Y), new(b.Position.X, b.Position.Y)); _out.WriteLine($"large-creature sep={sep:F3} m (big contact {bigContact:F2}, human contact {ContactDist:F2})"); - Assert.True(sep >= bigContact - 0.20f, + // #265 bounce rework (2026-07-30): the old -0.20 slack was calibrated + // against a fixture body carrying Contact WITHOUT a stored plane — a + // state production never generates (the engine always commits flags + + // plane together, and the check_contact seed now requires the pair). + // With the production-representative fixture (plane seeded) the pair + // settles at 1.58 m — the value production always produced for this + // radius; the bound now brackets that with the same ~2-step spirit. + Assert.True(sep >= bigContact - 0.30f, $"large creatures must de-overlap near their 2R contact ({bigContact:F2} m); got {sep:F3} m"); Assert.True(sep > ContactDist + 0.4f, $"large creatures must spread materially WIDER than the human contact ({ContactDist:F2} m); got {sep:F3} m"); diff --git a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs index 1fa15918..176e5233 100644 --- a/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs +++ b/tests/AcDream.Runtime.Tests/Gameplay/PlayerMovementControllerTests.cs @@ -1144,20 +1144,32 @@ public class PlayerMovementControllerTests Assert.False(controller.IsAirborne, "Should have landed"); // THE #265/#166 ACCEPTANCE BAR: the tick immediately after landing - // must NOT be hand-zeroed to exactly (0,0,0) -- the old bug. On flat - // ground dot(velocity, GroundNormal=(0,0,1)) = velocity.Z ~ 0 after - // the landing hand-zero, which is below calc_friction's 0.25f - // threshold, so friction DOES measurably engage here (unlike the - // specific captured roof geometry in - // Issue265SteepSlopeCaptureBisectTests, where it happens not to) -- - // this is "survives, then decays," not "coasts forever." + // must NOT be hand-zeroed to exactly (0,0,0) -- the old bug. The + // landing-bounce rework (2026-07-30, + // docs/research/2026-07-30-landing-bounce-family.md) restores retail + // handle_all_collisions' reflect (v += -(v·n)(elasticity+1)·n, + // elasticity 0.05), so the first post-landing ticks are a micro-hop + // CHAIN: each ground contact reverses 5% of the impact's normal + // component and keeps the tangential -- the residual speed survives, + // and calc_friction engages once a hop's contact dot falls under the + // 0.25 AP-7 gate. "Survives, then decays" therefore measures a few + // ticks after touchdown, not the very first one (friction cannot run + // during the reflected rise -- that IS retail's landing bounce). controller.Update(ObjectTick, new MovementInput()); float horizSpeedAfterLanding = new Vector2(controller.BodyVelocity.X, controller.BodyVelocity.Y).Length(); Assert.True(horizSpeedAfterLanding > 0.01f, $"Expected residual horizontal speed to survive the first post-landing tick; " + $"got {horizSpeedAfterLanding} (launch speed was {horizSpeedAtLaunch})"); - Assert.True(horizSpeedAfterLanding < horizSpeedAtLaunch, - "Expected friction to have begun decaying the residual speed, not leave it unchanged"); + + // Let the 5%-elasticity hop chain settle (v_z decays geometrically; + // a handful of ObjectTicks covers several hops), then require decay. + for (int i = 0; i < 12; i++) + controller.Update(ObjectTick, new MovementInput()); + float horizSpeedSettled = + new Vector2(controller.BodyVelocity.X, controller.BodyVelocity.Y).Length(); + Assert.True(horizSpeedSettled < horizSpeedAtLaunch, + $"Expected friction to decay the residual speed once the bounce chain settles; " + + $"got {horizSpeedSettled} vs launch {horizSpeedAtLaunch}"); } }