fix(physics): #265 landing-bounce family - retail check_contact seed + velocity-free landing commit
Retail jump landings BOUNCE: the floor touch records both a contact plane (grounding) AND a collision normal (collided_with_environment), and handle_all_collisions reflects the unmodified impact velocity off it at 5% elasticity (v += -(v.n)(elasticity+1).n, DEFAULT_ELASTICITY 0.05 @0x007c6a7c). Our transition already recorded both facts; the bounce was suppressed by the AD-25 adaptation stack in the per-tick commit: a Velocity.Z<=0 landing gate (needed because the resolver glued ascending movers to the ground) plus a landing Velocity.Z=0 hand-zero whose stated purpose was making the reflect a no-op. Downhill glided instead of bouncing, flat-ground landings had no pop, and uphill jumps flapped between grounded/airborne against the animation machine. Three retail mechanisms replace the stack: - check_contact (0x0050f5b0) seeding in ResolveWithTransition: a body in CONTACT seeds the transition's contact only while v.contactPlane.N <= 0.0002; moving away seeds the last-known plane alone (get_object_info 0x00511cc0). Ascending jumps therefore run contact-free (ballistic, no glue) - the gate's reason-for-being is gone. The plane requirement is strict: Contact-without-plane is unrepresentable in retail. - SetPositionInternal-shaped commit (0x00515330, byte-read end-to-end, velocity-sign-FREE): contact purely from the transition's contact plane, HitGround on the airborne->walkable edge, HandleAllCollisions with unmodified impact velocity. Whole commit gated on Ok && candidateMoved (retail pc:283657 skips SetPositionInternal entirely when the candidate did not move) - a standing body's contact state is never re-derived, which is what keeps rest bit-stable (AD-41 updated). - Byte decodes: gate override state&0x800000=Sledding, zero branch state&0x20000=Inelastic, reflect strictly dot<0 - our port already had all three correct. Settle: real landings (>=0.25 m/s) bounce and decay geometrically; smaller impacts are consumed by retail's unconditional small-velocity zero, so standing never micro-bounces. Re-baselines documented in place: landing-survival pin measures decay post-settle; LiveCompare_Tick0/376 pin the new IsOnGround=false on zero-move ticks (captured true was the retired seed echo; tick 376's captured body carries an 11.8 m/s grounded velocity from the deleted get_state_velocity-overwrite era); de-overlap fixture now carries the plane real grounded bodies always have. New pins: LandingBounceSeedingTests (ascent no-seed, rest keeps contact, strict plane, slope 5% reversal + tangential preservation, Sledding override). Investigation + implementation record: docs/research/2026-07-30-landing-bounce-family.md. Complete Release suite: 10,031 passed / 5 skips / 0 failures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
7fcc7db1d1
commit
2d611b2b01
10 changed files with 620 additions and 84 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
196
docs/research/2026-07-30-landing-bounce-family.md
Normal file
196
docs/research/2026-07-30-landing-bounce-family.md
Normal file
|
|
@ -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 & <mush>` 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
77
tests/AcDream.App.Tests/UI/Layout/VitaeColorDumpProbe.cs
Normal file
77
tests/AcDream.App.Tests/UI/Layout/VitaeColorDumpProbe.cs
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// #265 bounce rework (2026-07-30,
|
||||
/// docs/research/2026-07-30-landing-bounce-family.md): the capture's
|
||||
/// <c>Result.IsOnGround=true</c> 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 <c>cpV=false</c>, 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.
|
||||
/// </remarks>
|
||||
[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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -621,12 +634,24 @@ public class CellarUpTrajectoryReplayTests : IDisposable
|
|||
/// the same walkable polygon + ResolveResult, the ramp geometry is
|
||||
/// loaded correctly.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// #265 bounce rework (2026-07-30): same stale IsOnGround echo as
|
||||
/// <see cref="LiveCompare_Tick0_Spawn"/>, 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.
|
||||
/// </remarks>
|
||||
[Fact]
|
||||
public void LiveCompare_Tick376_OnRamp()
|
||||
{
|
||||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||||
var captured = LoadCapturedRecord(record => record.Tick == 376);
|
||||
AssertCallMatchesCapture(engine, captured);
|
||||
AssertCallMatchesCapture(engine, captured, expectStaleIsOnGroundEcho: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
@ -838,7 +863,8 @@ public class CellarUpTrajectoryReplayTests : IDisposable
|
|||
/// </summary>
|
||||
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);
|
||||
|
|
|
|||
153
tests/AcDream.Core.Tests/Physics/LandingBounceSeedingTests.cs
Normal file
153
tests/AcDream.Core.Tests/Physics/LandingBounceSeedingTests.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System;
|
||||
using System.Numerics;
|
||||
using AcDream.Core.Physics;
|
||||
using Xunit;
|
||||
|
||||
namespace AcDream.Core.Tests.Physics;
|
||||
|
||||
/// <summary>
|
||||
/// 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).
|
||||
/// </summary>
|
||||
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<CellSurface>(), Array.Empty<PortalPlane>(), 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
||||
/// <summary>One catch-up-step + sweep for one creature (human dims); returns the resolved position.</summary>
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue