From 4fe60779308dad737b3bd2737472264f4c873bd5 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Sat, 8 Aug 2026 02:04:28 -0400 Subject: [PATCH] fix: correct movement parity regressions on main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bubble columns applied one impulse per overlapped block cell. An entity carries a single resolved column contact per tick regardless of how many cells its hitbox spans, so a 1.8-block-tall player — who almost always overlaps two cells — accelerated at roughly double the real rate for every tick spent in a column. Resolve one contact from the topmost overlapped cell, since only that cell can have the open air above it that selects the surface form, and apply once. Column contact also clears fall distance, which was not modelled. Sneak edge protection ran on airborne ticks. The gate was widened from on-ground to a fall-distance leniency borrowed from Java's Player.isAboveGround; vanilla has no fall-distance input to this decision at all and requires ground strictly. A sneaking player stepping off a ledge was clamped when they should move freely. Honey wall slide required a horizontal collision and stopped after the first block. Contact with the block volume is sufficient, and each overlapped block compounds the horizontal factor, carrying the clamped vertical velocity between them. The accompanying fall-distance reset is left out: its shape is known but its thresholds could not be measured, and guessing them would trade one wrong behaviour for another. Bed restitution and its cap were changed to -0.75/0.75 with no stated source, against -0.66/1.0 from the reference docs, the Dragonfly implementation and Java. Neither value is proven here, so this restores the corroborated one and names the cap; revert if a capture says otherwise. --- block_effects.go | 7 +++---- bubble.go | 21 ++++++++++++++++----- bubble_test.go | 12 +++++++++--- constants.go | 4 +++- parity_test.go | 19 +++++++++++++------ simulation.go | 16 ++-------------- 6 files changed, 46 insertions(+), 33 deletions(-) diff --git a/block_effects.go b/block_effects.go index daae343..51d0e7a 100644 --- a/block_effects.go +++ b/block_effects.go @@ -91,10 +91,10 @@ func (s *Simulator) applyInsideBlockEffects(state *MovementState) { s.applyHoneyWallSlide(state) } +// applyHoneyWallSlide slows the entity once per overlapped honey block. Contact +// with the block volume is enough; a horizontal collision is not required, and +// overlapping two blocks compounds the horizontal factor. func (s *Simulator) applyHoneyWallSlide(state *MovementState) { - if !state.CollideX && !state.CollideZ { - return - } bb := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl32.Vec3{1e-3, 0, 1e-3}) min, maxPoint := bb.Min(), bb.Max() for x := int(math32.Floor(min.X())); x < int(math32.Ceil(maxPoint.X())); x++ { @@ -110,7 +110,6 @@ func (s *Simulator) applyHoneyWallSlide(state *MovementState) { velocity[1] = max(-0.12, velocity[1]) velocity[2] *= 0.4 state.SetVel(velocity) - return } } } diff --git a/bubble.go b/bubble.go index 53286f7..2e5af00 100644 --- a/bubble.go +++ b/bubble.go @@ -40,6 +40,10 @@ func applyBubbleColumn(state *MovementState, direction BubbleColumnDirection, su state.SetVel(velocity) } +// applyBubbleColumns applies at most one column impulse per tick. An entity +// carries a single resolved column contact regardless of how many column cells +// its hitbox overlaps, and the topmost overlapped cell decides the contact: +// only that cell can have the open air above it that selects the surface form. func (s *Simulator) applyBubbleColumns(state *MovementState) { provider, ok := s.World.(BubbleColumnProvider) if !ok { @@ -47,20 +51,27 @@ func (s *Simulator) applyBubbleColumns(state *MovementState) { } bb := state.BoundingBox(s.Options.UseSlideOffset) min, max := bb.Min(), bb.Max() + contact, found := cube.Pos{}, false + var direction BubbleColumnDirection for x := int(math32.Floor(min.X())); x < int(math32.Ceil(max.X())); x++ { for y := int(math32.Floor(min.Y())); y < int(math32.Ceil(max.Y())); y++ { for z := int(math32.Floor(min.Z())); z < int(math32.Ceil(max.Z())); z++ { pos := cube.Pos{x, y, z} - direction, found := provider.BubbleColumn(pos) - if !found { + cellDirection, ok := provider.BubbleColumn(pos) + if !ok || (found && pos.Y() <= contact.Y()) { continue } - above := pos.Side(cube.FaceUp) - _, liquidAbove := s.liquidAt(above) - applyBubbleColumn(state, direction, !liquidAbove && s.blockAir(s.blockAtPos(above))) + contact, direction, found = pos, cellDirection, true } } } + if !found { + return + } + above := contact.Side(cube.FaceUp) + _, liquidAbove := s.liquidAt(above) + applyBubbleColumn(state, direction, !liquidAbove && s.blockAir(s.blockAtPos(above))) + state.FallDistance = 0 } func (s *Simulator) attemptRiptide(state *MovementState, touchingWater bool) bool { diff --git a/bubble_test.go b/bubble_test.go index a2b72b1..05cad52 100644 --- a/bubble_test.go +++ b/bubble_test.go @@ -55,7 +55,7 @@ func TestBubbleColumnSurfaceAcceptsRegistryBackedAir(t *testing.T) { } } -func TestBubbleColumnAppliesForEachOccupiedCell(t *testing.T) { +func TestBubbleColumnAppliesOnceForOverlappedCells(t *testing.T) { w := environmentWorld{ bubbles: map[cube.Pos]BubbleColumnDirection{ {0, 0, 0}: BubbleColumnUp, @@ -68,11 +68,17 @@ func TestBubbleColumnAppliesForEachOccupiedCell(t *testing.T) { } state := newBaseState() state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.FallDistance = 4 (&Simulator{World: w}).applyBubbleColumns(state) - if want := float32(0.16); math32.Abs(state.Vel.Y()-want) > 1e-6 { - t.Fatalf("bubble-column velocity = %v, want per-cell impulses totaling %v", state.Vel.Y(), want) + // The topmost overlapped cell has open air above it, so this resolves to the + // surface form and applies once: 0.1, not 0.06+0.1 for the two cells. + if want := float32(0.1); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("bubble-column velocity = %v, want a single impulse of %v", state.Vel.Y(), want) + } + if state.FallDistance != 0 { + t.Fatalf("bubble-column contact left fall distance = %v", state.FallDistance) } } diff --git a/constants.go b/constants.go index 2c4f698..231fdbd 100644 --- a/constants.go +++ b/constants.go @@ -11,7 +11,9 @@ const ( StepHeight = float32(0.5625) SlideOffsetMultiplier = float32(0.4) SlimeBounceMultiplier = float32(-1) - BedBounceMultiplier = float32(-0.75) + BedBounceMultiplier = float32(-0.66) + // BedBounceCap bounds the upward bounce velocity. + BedBounceCap = float32(1) // This can be validated in Mob::ascendLadder(). ClimbSpeed = float32(0.2) MaxConsumingImpulse = float32(0.1225) diff --git a/parity_test.go b/parity_test.go index 31e263e..fdbf3ea 100644 --- a/parity_test.go +++ b/parity_test.go @@ -61,15 +61,22 @@ func TestBedrockStepHeight(t *testing.T) { } } -func TestBedBounceUsesBedrockRestitutionAndCap(t *testing.T) { +func TestBedBounceUsesCorroboratedRestitutionAndCap(t *testing.T) { sim := &Simulator{BlockSemantics: overrideBlockSemantics{semantics: movementblock.MovementSemantics{Bounce: movementblock.BounceBed}}} state := newBaseState() state.Vel = mgl32.Vec3{0, -2} sim.landOnBlock(state, state.Vel, block.Air{}) - if want := float32(0.75); state.Vel.Y() != want { - t.Fatalf("expected bed bounce %v, got %v", want, state.Vel.Y()) + // -0.66 * -2 = 1.32, above the cap. + if want := BedBounceCap; math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected capped bed bounce %v, got %v", want, state.Vel.Y()) + } + + state.Vel = mgl32.Vec3{0, -1} + sim.landOnBlock(state, state.Vel, block.Air{}) + if want := -BedBounceMultiplier; math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected uncapped bed bounce %v, got %v", want, state.Vel.Y()) } } @@ -101,7 +108,7 @@ func TestSlowFallingChangesGlideGravity(t *testing.T) { } } -func TestSneakEdgeProtectionWhileSlightlyAboveGround(t *testing.T) { +func TestSneakEdgeProtectionRequiresGround(t *testing.T) { sim := &Simulator{World: staticWorld{chunkLoaded: true, boxes: []cube.BBox32{ cube.Box32(-1, -1, -1, 0, 0, 1), }}} @@ -113,7 +120,7 @@ func TestSneakEdgeProtectionWhileSlightlyAboveGround(t *testing.T) { sim.avoidEdge(state) - if state.Vel.X() >= 0.5 { - t.Fatalf("expected edge protection above nearby ground, got velocity %v", state.Vel) + if state.Vel.X() != 0.5 { + t.Fatalf("edge protection ran while airborne: %v", state.Vel) } } diff --git a/simulation.go b/simulation.go index 8ad3341..4220e6d 100644 --- a/simulation.go +++ b/simulation.go @@ -725,7 +725,7 @@ func (s *Simulator) landOnBlock(state *MovementState, old mgl32.Vec3, blockUnder newVel[1] = 0.0 } case movementblock.BounceBed: - newVel[1] = math32.Min(0.75, BedBounceMultiplier*old.Y()) + newVel[1] = math32.Min(BedBounceCap, BedBounceMultiplier*old.Y()) default: newVel[1] = 0 } @@ -1051,7 +1051,7 @@ func (s *Simulator) avoidEdge(state *MovementState) { if w == nil { return } - if !state.Sneaking || !s.isAboveGround(state) || state.Vel.Y() > 0 { + if !state.Sneaking || !state.OnGround || state.Vel.Y() > 0 { s.debugf( "avoidEdge: conditions not met (sneaking=%v onGround=%v yVel=%v)", state.Sneaking, @@ -1128,18 +1128,6 @@ func (s *Simulator) avoidEdge(state *MovementState) { s.debugf("(avoidEdge): oldVel=%v newVel=%v", oldVel, newVel) } -func (s *Simulator) isAboveGround(state *MovementState) bool { - if state.OnGround { - return true - } - if state.FallDistance >= 0.6 || s.World == nil { - return false - } - distance := 0.6 - state.FallDistance - bb := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl32.Vec3{-0.025, 0, -0.025}) - return s.hasNearbyBBoxes(state, bb.Translate(mgl32.Vec3{0, -distance})) -} - func (s *Simulator) isInsideCobweb(state *MovementState) bool { if s.World == nil { return false