From 9338a6549650848d46d112ea9bce13820fb512fa Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 6 Aug 2026 17:45:16 -0700 Subject: [PATCH 01/23] fix: close movement parity edge cases --- README.md | 19 ++- block_test.go | 6 + bubble.go | 14 ++ collision.go | 10 +- interfaces.go | 20 +++ liquid.go | 14 +- movement.go | 37 +++++- parity_regressions_test.go | 181 +++++++++++++++++++++++++ result.go | 2 + simulation.go | 263 +++++++++++++++++++++++++++++++------ validation.go | 67 ++++++++++ 11 files changed, 584 insertions(+), 49 deletions(-) create mode 100644 parity_regressions_test.go create mode 100644 validation.go diff --git a/README.md b/README.md index 5476557..327f6cb 100644 --- a/README.md +++ b/README.md @@ -83,11 +83,23 @@ implement `BubbleColumnProvider` for upward/downward columns and scaffolding and powder snow. Dynamic collision resolution receives sneak and descend intent plus leather-boots state. +For reliable streaming-world simulation, implement `MovementAreaProvider` so a +swept movement volume can be checked precisely. Without it, BedSim checks every +chunk touched by the current bounding box and velocity. Implement +`ClimbableContactProvider` when ladder/vine orientation is resolved outside the +block registry, and `MovementSupportProvider` when dynamic collision shapes +need to identify their supporting block. + `MovementEquipmentProvider` supplies Depth Strider, Soul Speed, Swift Sneak, Riptide, and leather-boots checks. The legacy `DepthStriderProvider` inventory extension remains a fallback when the equipment provider reports no Depth Strider level. `EffectsProvider` also controls Weaving-aware web movement. +Use `MovementState.QueueKnockback` and `MovementState.QueueTeleport` for +authoritative events instead of setting their timer fields by hand. Set +`MovementState.JumpStrength` for a custom base jump velocity; zero keeps the +default. + Riptide input flags are not trusted on their own. Set `MovementState.RiptideReady` for the simulation tick only after validating a charged Riptide-trident release. Set `MovementState.RiptideCollision` after a server-observed entity collision to @@ -191,6 +203,11 @@ would be a breaking change outside liquid scope. Set - `Simulate` — applies client input, runs physics, advances tick counters, and returns the result. Use this when bedsim owns the full tick lifecycle. - `SimulateState` — runs physics on the current state without applying input or ticking counters. Use this when your caller handles input parsing and tick management externally. +Both entry points reject NaN and infinite state/input values with +`SimulationOutcomeInvalidInput`. Mounted players return +`SimulationOutcomeMounted` after being aligned to their client-reported state; +vehicle physics belongs in the caller's vehicle simulation. + ### Correction modes - `SimulationModeAuthoritative` — `NeedsCorrection` becomes true if position or velocity drift exceeds thresholds. @@ -205,4 +222,4 @@ Each tick returns a `SimulationResult` containing: - Collision flags (`CollideX`, `CollideY`, `CollideZ`, `OnGround`) - `PositionDelta` / `VelocityDelta` — difference from client-reported values - `NeedsCorrection` — whether deltas exceed configured thresholds -- `Outcome` — which simulation path was taken (normal, teleport, unreliable, unloaded chunk, immobile) +- `Outcome` — which simulation path was taken (normal, teleport, unreliable, unloaded chunk, immobile, mounted, or invalid input) diff --git a/block_test.go b/block_test.go index e830b6d..94f1c6e 100644 --- a/block_test.go +++ b/block_test.go @@ -27,6 +27,9 @@ func (namedBlock) Model() world.BlockModel { } func TestBlockNameCachesRawHashPair(t *testing.T) { + key := blockNameKey{base: 0xf32ca, state: 7} + blockNameCache.Delete(key) + t.Cleanup(func() { blockNameCache.Delete(key) }) var calls int b := namedBlock{name: "test:cached", base: 0xf32ca, state: 7, encodeCalls: &calls} @@ -53,6 +56,9 @@ func TestBlockNameDoesNotCacheUnknownHash(t *testing.T) { } func TestBlockNameCachesMaxStateWithKnownBase(t *testing.T) { + key := blockNameKey{base: 1, state: math.MaxUint64} + blockNameCache.Delete(key) + t.Cleanup(func() { blockNameCache.Delete(key) }) var calls int b := namedBlock{name: "test:max_state", base: 1, state: math.MaxUint64, encodeCalls: &calls} diff --git a/bubble.go b/bubble.go index 53286f7..af7c84a 100644 --- a/bubble.go +++ b/bubble.go @@ -3,6 +3,7 @@ package bedsim import ( "github.com/chewxy/math32" + "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/block/cube" "github.com/go-gl/mathgl/mgl32" ) @@ -85,6 +86,19 @@ func (s *Simulator) attemptRiptide(state *MovementState, touchingWater bool) boo return true } +func (s *Simulator) simulateRiptide(state *MovementState) { + oldVel := state.Vel + oldOnGround := state.OnGround + oldY := state.Pos.Y() + state.OnGround = false + s.tryCollisions(state, false) + stopRiptideOnBlockCollision(state) + updateFallDistance(state, oldY) + state.SetMov(state.Vel) + s.setPostCollisionMotion(state, oldVel, oldOnGround, block.Air{}) + s.applyInsideBlockEffects(state) +} + func stopRiptideOnBlockCollision(state *MovementState) { if state.RiptideTicks > 0 && (state.CollideX || state.CollideZ) { state.RiptideTicks = 0 diff --git a/collision.go b/collision.go index 3765af8..1468e50 100644 --- a/collision.go +++ b/collision.go @@ -114,7 +114,13 @@ func doBBClipCollide(stationary, moving cube.BBox32, velocity mgl32.Vec3) (resul return } -// BBHasZeroVolume returns true if the bounding box has zero volume. +// BBHasZeroVolume returns true for empty or invalid bounding boxes. func BBHasZeroVolume(bb cube.BBox32) bool { - return bb.Min() == bb.Max() + min, max := bb.Min(), bb.Max() + for axis := range 3 { + if !finiteFloat(min[axis]) || !finiteFloat(max[axis]) || min[axis] >= max[axis] { + return true + } + } + return false } diff --git a/interfaces.go b/interfaces.go index f9738ce..b53c76a 100644 --- a/interfaces.go +++ b/interfaces.go @@ -15,6 +15,13 @@ type WorldProvider interface { IsChunkLoaded(chunkX, chunkZ int32) bool } +// MovementAreaProvider can provide a precise loaded/known check for a swept +// movement volume. Worlds that only expose chunk loading use BedSim's +// conservative chunk-range fallback. +type MovementAreaProvider interface { + IsMovementAreaLoaded(aabb cube.BBox32) bool +} + // LiquidProvider returns liquids from either block layer at a position. type LiquidProvider interface { Liquid(pos cube.Pos) (world.Liquid, bool) @@ -36,6 +43,19 @@ type MovementCollisionProvider interface { GetMovementBBoxes(aabb cube.BBox32, context MovementCollisionContext) []cube.BBox32 } +// ClimbableContactProvider resolves orientation-aware ladder and vine contact. +// The built-in fallback scans intersecting block volumes when this is absent. +type ClimbableContactProvider interface { + HasClimbableContact(aabb cube.BBox32) bool +} + +// MovementSupportProvider resolves the exact support block for dynamic shapes. +// It is optional because a generic collision provider may not retain source +// block identities. +type MovementSupportProvider interface { + SupportingBlock(aabb cube.BBox32, context MovementCollisionContext) (cube.Pos, bool) +} + // BlockMovementSemanticsProvider resolves the complete movement behavior for a // block from a custom world registry or block data. GroundFriction and // GroundAccelerationFrictionMultiplier must be finite and positive; invalid diff --git a/liquid.go b/liquid.go index 78749de..b7b0a75 100644 --- a/liquid.go +++ b/liquid.go @@ -100,6 +100,7 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, moveRelativeSpeed += (state.MovementSpeed - moveRelativeSpeed) * depthStriderFraction } } + moveRelativeSpeed *= s.movementEffectMultiplier() moveRelative(state, moveRelativeSpeed) stuckMovement := applyStuckSpeedMultiplier(state) @@ -182,7 +183,7 @@ func (s *Simulator) updateSwimTravel(state *MovementState) { if targetY > 0 && !state.WantDownSlow && !state.PressingDescend { belowPos := posFromVec3(state.Pos.Add(mgl32.Vec3{0, DefaultPlayerHeightOffset - 1.1})) - if _, belowAir := s.liquidMovementBlock(belowPos).(block.Air); belowAir { + if s.blockAir(s.liquidMovementBlock(belowPos)) { liquidPos := posFromVec3(state.Pos.Add(mgl32.Vec3{0, DefaultPlayerHeightOffset - 1.2})) if _, liquid := s.liquidAt(liquidPos); !liquid { vel := state.Vel @@ -217,6 +218,9 @@ func (s *Simulator) touchingLiquidBlocks(state *MovementState, kind liquidKind) if !ok || !kind.matches(liquid) { continue } + if !liquidIntersects(box, pos, liquid) { + continue + } if s.Options.Debugf != nil { height := liquidHeight(liquid) surface := float32(pos[1]) + height @@ -300,6 +304,11 @@ func liquidHeight(liquid world.Liquid) float32 { return float32(liquid.LiquidDepth()+1) / 9 } +func liquidIntersects(box cube.BBox32, pos cube.Pos, liquid world.Liquid) bool { + surface := float32(pos[1]) + liquidHeight(liquid) + return box.Max().Y() > float32(pos[1]) && box.Min().Y() < surface +} + func (s *Simulator) containsAnyLiquid(box cube.BBox32) bool { min, max := box.Min(), box.Max() minX, minY, minZ := int(math32.Floor(min.X())), int(math32.Floor(min.Y())), int(math32.Floor(min.Z())) @@ -307,7 +316,8 @@ func (s *Simulator) containsAnyLiquid(box cube.BBox32) bool { for x := minX; x < maxX; x++ { for z := minZ; z < maxZ; z++ { for y := minY; y < maxY; y++ { - if _, ok := s.liquidAt(cube.Pos{x, y, z}); ok { + pos := cube.Pos{x, y, z} + if liquid, ok := s.liquidAt(pos); ok && liquidIntersects(box, pos, liquid) { return true } } diff --git a/movement.go b/movement.go index 1237a82..700e5f9 100644 --- a/movement.go +++ b/movement.go @@ -42,8 +42,10 @@ type MovementState struct { SupportingBlockPos *cube.Pos - Gravity float32 - JumpHeight float32 + Gravity float32 + JumpHeight float32 + // JumpStrength is the base jump velocity. Zero uses DefaultJumpHeight. + JumpStrength float32 FallDistance float32 MovementSpeed float32 @@ -59,6 +61,7 @@ type MovementState struct { Knockback mgl32.Vec3 TicksSinceKnockback uint64 + KnockbackPending bool PendingTeleportPos mgl32.Vec3 PendingTeleports int @@ -67,6 +70,7 @@ type MovementState struct { TicksSinceTeleport uint64 TeleportCompletionTicks uint64 TeleportIsSmoothed bool + TeleportPending bool Sprinting, PressingSprint bool ServerSprint, ServerSprintApplied bool @@ -170,13 +174,38 @@ func (s *MovementState) SetRotation(newRot mgl32.Vec3) { } func (s *MovementState) HasKnockback() bool { - return s.TicksSinceKnockback == 0 + return s.KnockbackPending || s.TicksSinceKnockback == 0 && s.Knockback != (mgl32.Vec3{}) } func (s *MovementState) HasTeleport() bool { + if s.TeleportPending || s.PendingTeleports > 0 { + return true + } + if s.TeleportCompletionTicks == 0 { + return s.TicksSinceTeleport == 0 && s.TeleportPos != (mgl32.Vec3{}) + } return s.TicksSinceTeleport <= s.TeleportCompletionTicks } func (s *MovementState) RemainingTeleportTicks() int { - return int(s.TeleportCompletionTicks) - int(s.TicksSinceTeleport) + if !s.HasTeleport() || s.TicksSinceTeleport >= s.TeleportCompletionTicks { + return 0 + } + return int(s.TeleportCompletionTicks - s.TicksSinceTeleport) +} + +// QueueKnockback schedules one authoritative velocity replacement. +func (s *MovementState) QueueKnockback(velocity mgl32.Vec3) { + s.Knockback = velocity + s.KnockbackPending = true + s.TicksSinceKnockback = 0 +} + +// QueueTeleport schedules one authoritative teleport. +func (s *MovementState) QueueTeleport(pos mgl32.Vec3, smoothed bool, completionTicks uint64) { + s.TeleportPos = pos + s.TeleportIsSmoothed = smoothed + s.TeleportCompletionTicks = completionTicks + s.TicksSinceTeleport = 0 + s.TeleportPending = true } diff --git a/parity_regressions_test.go b/parity_regressions_test.go new file mode 100644 index 0000000..5115ef1 --- /dev/null +++ b/parity_regressions_test.go @@ -0,0 +1,181 @@ +package bedsim + +import ( + "testing" + + "github.com/chewxy/math32" + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl32" +) + +func TestZeroValueStateHasNoSyntheticEvents(t *testing.T) { + var state MovementState + if state.HasKnockback() { + t.Fatal("zero state must not report knockback") + } + if state.HasTeleport() { + t.Fatal("zero state must not report teleport") + } +} + +func TestSimulationRejectsNonFiniteInputAndState(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{1, 2, 3} + result := (&Simulator{World: mockWorld{}}).Simulate(state, InputState{Pitch: math32.NaN()}) + if result.Outcome != SimulationOutcomeInvalidInput || !result.NeedsCorrection { + t.Fatalf("invalid input result = %+v", result) + } + if state.Pos != (mgl32.Vec3{1, 2, 3}) { + t.Fatalf("invalid input mutated state position to %v", state.Pos) + } + + state = newBaseState() + state.Vel[0] = math32.Inf(1) + result = (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeInvalidInput || !result.NeedsCorrection { + t.Fatalf("invalid state result = %+v", result) + } +} + +func TestMountedStateSkipsMovement(t *testing.T) { + state := newBaseState() + state.InVehicle = true + state.Pos = mgl32.Vec3{10, 70, 10} + state.Vel = mgl32.Vec3{1, 2, 3} + state.Client.Pos = mgl32.Vec3{4, 5, 6} + state.Client.Vel = mgl32.Vec3{0.1, 0.2, 0.3} + + result := (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeMounted { + t.Fatalf("outcome = %v, want mounted", result.Outcome) + } + if state.Pos != state.Client.Pos || state.Vel != state.Client.Vel { + t.Fatalf("mounted state was simulated: pos=%v vel=%v", state.Pos, state.Vel) + } +} + +func TestSimulateStateLeavesTransientInputForCaller(t *testing.T) { + state := newBaseState() + state.RiptideReady = true + (&Simulator{World: mockWorld{}}).SimulateState(state) + if !state.RiptideReady { + t.Fatal("SimulateState must not consume caller-managed transient input") + } +} + +func TestActiveRiptideSkipsOrdinaryPhysics(t *testing.T) { + state := newBaseState() + state.RiptideTicks = 5 + state.Vel = mgl32.Vec3{0, 0.8, 0} + state.Impulse = mgl32.Vec2{0, 1} + state.Gravity = NormalGravity + + (&Simulator{World: mockWorld{}}).SimulateState(state) + if state.Pos != (mgl32.Vec3{0, 0.8, 0}) { + t.Fatalf("riptide tick applied ordinary displacement: %v", state.Pos) + } + if state.Vel != (mgl32.Vec3{0, 0.8, 0}) { + t.Fatalf("riptide tick applied ordinary acceleration: %v", state.Vel) + } +} + +func TestTeleportDoesNotApplyJumpImpulse(t *testing.T) { + state := newBaseState() + state.OnGround = true + state.Jumping = true + state.QueueTeleport(mgl32.Vec3{10, 20, 30}, false, 0) + + result := (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("outcome = %v, want teleport", result.Outcome) + } + if state.Vel != (mgl32.Vec3{}) { + t.Fatalf("teleport applied jump/other velocity: %v", state.Vel) + } + if state.HasTeleport() { + t.Fatal("completed hard teleport remained active") + } +} + +func TestGlideAtVerticalPitchRemainsFinite(t *testing.T) { + state := newBaseState() + state.Gliding = true + state.OnGround = false + state.Rotation = mgl32.Vec3{-90, 0, 0} + state.Vel = mgl32.Vec3{1, 0, 0} + + (&Simulator{World: mockWorld{}, Inventory: mockInventory{hasElytra: true}}).SimulateState(state) + for axis, value := range state.Vel { + if !finiteFloat(value) { + t.Fatalf("glide velocity axis %d is not finite: %v", axis, state.Vel) + } + } +} + +func TestShallowLiquidBelowPlayerIsNotContact(t *testing.T) { + w := newLiquidWorld().set(cube.Pos{0, 0, 0}, block.Water{Depth: 0, Still: true}) + sim := newLiquidSim(w) + state := submergedState() + + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 0 { + t.Fatalf("shallow liquid blocks = %d, want no contact above its surface", got) + } +} + +func TestMovementChecksSweptChunks(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{15.5, 0, 0.5} + state.Vel = mgl32.Vec3{1, 0, 0} + + result := (&Simulator{World: selectiveChunkWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for swept movement", result.Outcome) + } +} + +func TestUnloadedTickDoesNotCommitPoseChanges(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{16.5, 0, 0.5} + state.Swimming = true + state.Size[1] = state.StandingHeight + originalSize := state.Size + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{StopSwimming: true}) + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk", result.Outcome) + } + if !state.Swimming || state.Size != originalSize { + t.Fatalf("unloaded tick committed pose change: swimming=%v size=%v", state.Swimming, state.Size) + } +} + +func TestAdjacentClimbableContactIsDetected(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {1, 0, 0}: block.Ladder{Facing: cube.West}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.8, 0, 0.5} + state.Client.Pos = state.Pos + state.EffectiveJumping = true + state.Gravity = NormalGravity + + sim := &Simulator{World: w} + sim.SimulateState(state) + if state.Vel.Y() <= 0 { + t.Fatalf("adjacent ladder did not provide climb velocity: %v", state.Vel) + } +} + +type selectiveChunkWorld struct{} + +func (selectiveChunkWorld) Block(cube.Pos) world.Block { return block.Air{} } + +func (selectiveChunkWorld) BlockCollisions(cube.Pos) []cube.BBox32 { return nil } + +func (selectiveChunkWorld) GetNearbyBBoxes(cube.BBox32) []cube.BBox32 { return nil } + +func (selectiveChunkWorld) IsChunkLoaded(chunkX, chunkZ int32) bool { + return chunkX == 0 && chunkZ == 0 +} diff --git a/result.go b/result.go index 029881a..85cdb5d 100644 --- a/result.go +++ b/result.go @@ -11,6 +11,8 @@ const ( SimulationOutcomeUnreliable SimulationOutcomeUnloadedChunk SimulationOutcomeImmobileOrNotReady + SimulationOutcomeMounted + SimulationOutcomeInvalidInput ) // SimulationResult captures the outcome of a single simulation tick. diff --git a/simulation.go b/simulation.go index 8ad3341..aa1fa4b 100644 --- a/simulation.go +++ b/simulation.go @@ -2,6 +2,7 @@ package bedsim import ( "iter" + "sort" "github.com/chewxy/math32" @@ -15,12 +16,22 @@ import ( // Simulate runs a movement simulation tick and returns the resulting state. func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationResult { - if state == nil { - return SimulationResult{} + if state == nil || !finiteMovementState(state) || !finiteInput(input) { + return invalidSimulationResult() } + pose := movementPoseSnapshot{ + size: state.Size, + sneaking: state.Sneaking, + crawling: state.Crawling, + swimming: state.Swimming, + swimAmt: state.SwimAmount, + } s.applyInput(state, input) - reason := s.simulateCore(state) + reason := s.simulateCore(state, true) + if reason == SimulationOutcomeUnloadedChunk { + pose.restore(state) + } if s.Options.SprintTiming == SprintTimingLegacy { s.applyLegacySprint(state, input) } @@ -28,13 +39,29 @@ func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationR return s.resultFromState(state, reason) } +type movementPoseSnapshot struct { + size mgl32.Vec3 + sneaking bool + crawling bool + swimming bool + swimAmt float32 +} + +func (p movementPoseSnapshot) restore(state *MovementState) { + state.Size = p.size + state.Sneaking = p.sneaking + state.Crawling = p.crawling + state.Swimming = p.swimming + state.SwimAmount = p.swimAmt +} + // SimulateState runs movement simulation using the current state values, without applying input updates // or advancing tick counters. This is useful when the caller handles input parsing and ticking externally. func (s *Simulator) SimulateState(state *MovementState) SimulationResult { - if state == nil { - return SimulationResult{} + if state == nil || !finiteMovementState(state) { + return invalidSimulationResult() } - reason := s.simulateCore(state) + reason := s.simulateCore(state, false) return s.resultFromState(state, reason) } @@ -50,11 +77,17 @@ func (s *Simulator) debugfIf(cond bool, format string, args ...any) { } } -func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { +func invalidSimulationResult() SimulationResult { + return SimulationResult{Outcome: SimulationOutcomeInvalidInput, NeedsCorrection: true} +} + +func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) SimulationOutcome { state.ensurePoseHeights() - defer func() { - state.RiptideReady = false - }() + if consumeTransient { + defer func() { + state.RiptideReady = false + }() + } teleported := s.attemptTeleport(state) if teleported { // A teleport relocates the player without observing the destination, @@ -63,6 +96,10 @@ func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { state.StuckSpeedMultiplier = mgl32.Vec3{} return SimulationOutcomeTeleport } + if state.InVehicle { + s.resetToClient(state) + return SimulationOutcomeMounted + } reliable := s.simulationIsReliable(state) if !reliable { @@ -74,7 +111,7 @@ func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { s.resetToClient(state) return SimulationOutcomeUnreliable } - if s.World != nil && !s.World.IsChunkLoaded(int32(math32.Floor(state.Pos.X()))>>4, int32(math32.Floor(state.Pos.Z()))>>4) { + if s.World != nil && !s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset).Extend(state.Vel)) { state.SetVel(mgl32.Vec3{}) state.SwimWaterGraceTicks = 0 state.StuckSpeedMultiplier = mgl32.Vec3{} @@ -291,7 +328,10 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.Jumping = input.StartJumping state.PressingJump = input.Jumping state.EffectiveJumping = input.Jumping || input.AutoJumpingInWater || input.AscendBlock - state.JumpHeight = DefaultJumpHeight + state.JumpHeight = state.JumpStrength + if state.JumpHeight <= 0 { + state.JumpHeight = DefaultJumpHeight + } if s.Effects != nil { if amp, ok := s.Effects.GetEffect(packet.EffectJumpBoost); ok { state.JumpHeight += float32(amp+1) * 0.1 @@ -301,7 +341,9 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { if !state.PressingJump { state.JumpDelay = 0 } - state.Gravity = NormalGravity + if state.Gravity == 0 { + state.Gravity = NormalGravity + } state.SlowFalling = false if s.Effects != nil { if _, ok := s.Effects.GetEffect(packet.EffectSlowFalling); ok { @@ -358,6 +400,7 @@ func (s *Simulator) tickState(state *MovementState) { } } state.TicksSinceKnockback++ + state.KnockbackPending = false if state.TicksSinceTeleport < math32.MaxUint64 { state.TicksSinceTeleport++ } @@ -395,9 +438,14 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SwimWaterGraceTicks = grace setSwimmingPoseFlags(state) } - if !state.Flying && s.attemptRiptide(state, inWater) { + riptideLaunched := !state.Flying && s.attemptRiptide(state, inWater) + if riptideLaunched { s.debugf("riptide launch applied: %v", state.Vel) } + if !state.Flying && state.RiptideTicks > 0 && !riptideLaunched { + s.simulateRiptide(state) + return + } defer func() { if inWater { @@ -441,6 +489,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { accelerationFriction := blockFriction * accelerationMultiplier moveRelativeSpeed = mSpeed * (0.16277136 / (accelerationFriction * accelerationFriction * accelerationFriction)) } + moveRelativeSpeed *= s.movementEffectMultiplier() if state.Gliding && s.Effects != nil { if _, levitating := s.Effects.GetEffect(packet.EffectLevitation); levitating { @@ -481,7 +530,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { leatherBoots := s.Equipment != nil && s.Equipment.WearingLeatherBoots() applyAscendableMovement(state, insideSemantics.Traversal, leatherBoots) - nearClimbable := insideSemantics.Climbable + nearClimbable := insideSemantics.Climbable || s.hasClimbableContact(state) if nearClimbable { newVel := state.Vel negClimbSpeed := -ClimbSpeed @@ -615,6 +664,12 @@ func (s *Simulator) resetToClient(state *MovementState) { } func (s *Simulator) attemptTeleport(state *MovementState) bool { + if state.PendingTeleports > 0 { + if state.TeleportPos == (mgl32.Vec3{}) || state.PendingTeleportPos != (mgl32.Vec3{}) { + state.TeleportPos = state.PendingTeleportPos + } + state.TeleportPending = true + } if !state.HasTeleport() { return false } @@ -623,7 +678,11 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { state.SetPos(state.TeleportPos) state.SetVel(mgl32.Vec3{}) state.JumpDelay = 0 - s.attemptJump(state, nil) + state.TeleportPending = false + if state.PendingTeleports > 0 { + state.PendingTeleports-- + } + state.TicksSinceTeleport = teleportCompleteTick(state.TeleportCompletionTicks) return true } @@ -632,11 +691,25 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { newPos := state.Pos.Add(posDelta.Mul(1.0 / float32(remaining))) state.SetPos(newPos) state.JumpDelay = 0 - return remaining > 1 + if remaining == 1 { + state.TeleportPending = false + if state.PendingTeleports > 0 { + state.PendingTeleports-- + } + state.TicksSinceTeleport = teleportCompleteTick(state.TeleportCompletionTicks) + } + return true } return false } +func teleportCompleteTick(completionTicks uint64) uint64 { + if completionTicks == math32.MaxUint64 { + return completionTicks + } + return completionTicks + 1 +} + func (s *Simulator) simulateGlide(state *MovementState) { radians := math32.Pi / 180.0 yaw, pitch := state.Rotation.Z()*radians, state.Rotation.X()*radians @@ -662,7 +735,7 @@ func (s *Simulator) simulateGlide(state *MovementState) { vel[0] += lookX * yAccel / lookHz vel[2] += lookZ * yAccel / lookHz } - if pitch < 0 { + if pitch < 0 && math32.Abs(lookHz) > 1e-6 { yAccel := velHz * -pitchSin * 0.04 vel[1] += yAccel * 3.2 vel[0] -= lookX * yAccel / lookHz @@ -788,6 +861,20 @@ func moveRelative(state *MovementState, moveRelativeSpeed float32) { } } +func (s *Simulator) movementEffectMultiplier() float32 { + if s == nil || s.Effects == nil { + return 1 + } + multiplier := float32(1) + if amplifier, ok := s.Effects.GetEffect(packet.EffectSpeed); ok { + multiplier *= 1 + 0.2*float32(amplifier+1) + } + if amplifier, ok := s.Effects.GetEffect(packet.EffectSlowness); ok { + multiplier *= math32.Max(0, 1-0.15*float32(amplifier+1)) + } + return multiplier +} + func attemptKnockback(state *MovementState) bool { if state.HasKnockback() { state.SetVel(state.Knockback) @@ -999,8 +1086,8 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool if s.Options.IgnoreClientStepTiebreaker || collisionPosDist > correctionThreshold || stepPosDist <= collisionPosDist { collisionVel = stepVel collisionBB = stepBB + completedStep = true if useSlideOffset { - completedStep = true slideOffset := state.SlideOffset.Mul(SlideOffsetMultiplier) slideOffset[1] += stepVel.Y() state.SlideOffset = slideOffset @@ -1037,8 +1124,10 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool state.CollideY = yCollision state.CollideZ = math32.Abs(currVel.Z()-collisionVel.Z()) >= 1e-5 - state.OnGround = (yCollision && currVel.Y() < 0) || (state.OnGround && !yCollision && math32.Abs(currVel.Y()) <= 1e-5) - checkSupportingBlockPos(state, w, useSlideOffset, currVel) + state.OnGround = (yCollision && currVel.Y() < 0) || + (onGround && !yCollision && math32.Abs(currVel.Y()) <= 1e-5) || + (clientJumpPrevented && onGround) || completedStep + s.checkSupportingBlockPos(state, useSlideOffset, currVel) state.SetVel(collisionVel) s.debugf("clientVel=%v clientPos=%v", state.Client.Mov, state.Client.Pos) s.debugf("finalVel=%v finalPos=%v", collisionVel, state.Pos) @@ -1186,21 +1275,80 @@ func nearbyBlocks(aabb cube.BBox32, w WorldProvider) iter.Seq2[cube.Pos, world.B } } -func checkSupportingBlockPos(state *MovementState, w WorldProvider, useSlideOffset bool, vel mgl32.Vec3) { +func (s *Simulator) hasClimbableContact(state *MovementState) bool { + if s.World == nil { + return false + } + box := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl32.Vec3{0.05, 0.05, 0.05}) + if provider, ok := s.World.(ClimbableContactProvider); ok { + return provider.HasClimbableContact(box) + } + for pos, blockAt := range nearbyBlocks(box, s.World) { + if !s.blockMovementSemantics(blockAt).Climbable { + continue + } + if box.IntersectsWith(cube.Box32(0, 0, 0, 1, 1, 1).Translate(posVec3(pos))) { + return true + } + } + return false +} + +func (s *Simulator) movementAreaLoaded(aabb cube.BBox32) bool { + if s.World == nil { + return true + } + if provider, ok := s.World.(MovementAreaProvider); ok { + return provider.IsMovementAreaLoaded(aabb) + } + min, max := aabb.Min(), aabb.Max() + minX, minZ := int32(math32.Floor(min.X()))>>4, int32(math32.Floor(min.Z()))>>4 + maxX, maxZ := int32(math32.Ceil(max.X())-1)>>4, int32(math32.Ceil(max.Z())-1)>>4 + if maxX < minX { + maxX = minX + } + if maxZ < minZ { + maxZ = minZ + } + for chunkX := minX; chunkX <= maxX; chunkX++ { + for chunkZ := minZ; chunkZ <= maxZ; chunkZ++ { + if !s.World.IsChunkLoaded(chunkX, chunkZ) { + return false + } + } + } + return true +} + +func (s *Simulator) checkSupportingBlockPos(state *MovementState, useSlideOffset bool, vel mgl32.Vec3) { if !state.OnGround { state.SupportingBlockPos = nil return } decBB := state.BoundingBox(useSlideOffset).ExtendTowards(cube.FaceDown, 1e-3) - findSupportingBlock(state, w, decBB) + s.findSupportingBlock(state, decBB) if state.SupportingBlockPos == nil { decBB = decBB.Translate(mgl32.Vec3{-vel[0], 0, -vel[2]}) - findSupportingBlock(state, w, decBB) + s.findSupportingBlock(state, decBB) } } -func findSupportingBlock(state *MovementState, w WorldProvider, bb cube.BBox32) { +func (s *Simulator) findSupportingBlock(state *MovementState, bb cube.BBox32) { + w := s.World if w == nil { + state.SupportingBlockPos = nil + return + } + if provider, ok := w.(MovementSupportProvider); ok { + if pos, found := provider.SupportingBlock(bb, s.movementCollisionContext(state)); found { + state.SupportingBlockPos = &pos + } else { + state.SupportingBlockPos = nil + } + return + } + if _, dynamic := w.(MovementCollisionProvider); dynamic { + state.SupportingBlockPos = nil return } var blockPos *cube.Pos @@ -1242,16 +1390,50 @@ func (s *Simulator) nearbyBBoxes(state *MovementState, aabb cube.BBox32) []cube. return nil } if provider, ok := s.World.(MovementCollisionProvider); ok { - leatherBoots := s.Equipment != nil && s.Equipment.WearingLeatherBoots() - return provider.GetMovementBBoxes(aabb, MovementCollisionContext{ - Position: [3]float32(state.Pos), - Sneaking: state.Sneaking, - Descending: state.PressingDescend, - WantDown: state.WantDown, - LeatherBoots: leatherBoots, - }) - } - return s.World.GetNearbyBBoxes(aabb) + return sortedCollisionBoxes(provider.GetMovementBBoxes(aabb, s.movementCollisionContext(state))) + } + return sortedCollisionBoxes(s.World.GetNearbyBBoxes(aabb)) +} + +func (s *Simulator) movementCollisionContext(state *MovementState) MovementCollisionContext { + return MovementCollisionContext{ + Position: [3]float32(state.Pos), + Sneaking: state.Sneaking, + Descending: state.PressingDescend, + WantDown: state.WantDown, + LeatherBoots: s.Equipment != nil && s.Equipment.WearingLeatherBoots(), + } +} + +func sortedCollisionBoxes(boxes []cube.BBox32) []cube.BBox32 { + if len(boxes) < 2 { + if len(boxes) == 1 && BBHasZeroVolume(boxes[0]) { + return nil + } + return boxes + } + filtered := make([]cube.BBox32, 0, len(boxes)) + for _, box := range boxes { + if !BBHasZeroVolume(box) { + filtered = append(filtered, box) + } + } + sort.SliceStable(filtered, func(i, j int) bool { + leftMin, rightMin := filtered[i].Min(), filtered[j].Min() + for axis := range 3 { + if leftMin[axis] != rightMin[axis] { + return leftMin[axis] < rightMin[axis] + } + } + leftMax, rightMax := filtered[i].Max(), filtered[j].Max() + for axis := range 3 { + if leftMax[axis] != rightMax[axis] { + return leftMax[axis] < rightMax[axis] + } + } + return false + }) + return filtered } type nearbyBBoxProbe interface { @@ -1289,9 +1471,7 @@ func (s *Simulator) poseCollisionsAvailable(state *MovementState) bool { if s.World == nil { return true } - chunkX := int32(math32.Floor(state.Pos.X())) >> 4 - chunkZ := int32(math32.Floor(state.Pos.Z())) >> 4 - return s.World.IsChunkLoaded(chunkX, chunkZ) + return s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset)) } func setSwimmingPoseFlags(state *MovementState) { @@ -1301,13 +1481,16 @@ func setSwimmingPoseFlags(state *MovementState) { } func (s *Simulator) restorePoseAfterSwimming(state *MovementState, collisionsAvailable bool) { - if collisionsAvailable && s.canFitHeight(state, state.StandingHeight) { + if !collisionsAvailable { + return + } + if s.canFitHeight(state, state.StandingHeight) { state.Sneaking = false state.Crawling = false state.Size[1] = state.StandingHeight return } - if collisionsAvailable && s.canFitHeight(state, state.SneakingHeight) { + if s.canFitHeight(state, state.SneakingHeight) { state.Sneaking = true state.Crawling = false state.Size[1] = state.SneakingHeight diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..480643e --- /dev/null +++ b/validation.go @@ -0,0 +1,67 @@ +package bedsim + +import ( + "github.com/chewxy/math32" + "github.com/go-gl/mathgl/mgl32" +) + +func finiteFloat(value float32) bool { + return !math32.IsNaN(value) && !math32.IsInf(value, 0) +} + +func finiteVec2(value mgl32.Vec2) bool { + for axis := range 2 { + if !finiteFloat(value[axis]) { + return false + } + } + return true +} + +func finiteVec3(value mgl32.Vec3) bool { + for axis := range 3 { + if !finiteFloat(value[axis]) { + return false + } + } + return true +} + +func finiteInput(input InputState) bool { + return finiteVec2(input.MoveVector) && + finiteVec3(input.ClientPos) && + finiteVec3(input.ClientVel) && + finiteFloat(input.Pitch) && finiteFloat(input.Yaw) && finiteFloat(input.HeadYaw) +} + +func finiteMovementState(state *MovementState) bool { + if state == nil { + return false + } + for _, value := range []mgl32.Vec3{ + state.Client.Pos, state.Client.LastPos, state.Client.Vel, state.Client.LastVel, + state.Client.Mov, state.Client.LastMov, state.Pos, state.LastPos, state.Vel, + state.LastVel, state.Mov, state.LastMov, state.Rotation, state.LastRotation, + state.Knockback, state.TeleportPos, state.PendingTeleportPos, state.Size, + state.StuckSpeedMultiplier, + } { + if !finiteVec3(value) { + return false + } + } + if !finiteVec2(state.SlideOffset) || !finiteVec2(state.Impulse) { + return false + } + for _, value := range []float32{ + state.StandingHeight, state.SneakingHeight, state.CrawlingHeight, + state.Gravity, state.JumpHeight, state.JumpStrength, state.FallDistance, + state.MovementSpeed, state.DefaultMovementSpeed, state.AirSpeed, + state.UnderwaterMovementSpeed, state.LavaMovementSpeed, state.SwimSpeedMultiplier, + state.SwimAmount, + } { + if !finiteFloat(value) { + return false + } + } + return true +} From 95a65ddda0af8b2e8b3a31132cb488f4d52d9c28 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 6 Aug 2026 18:19:27 -0700 Subject: [PATCH 02/23] fix: close movement parity gaps --- block/environment.go | 4 ++++ block/semantics.go | 1 + block_effects.go | 5 ++++- block_effects_test.go | 35 +++++++++++++++++++++++++++++++++++ block_semantics_test.go | 13 +++++++++++++ bubble.go | 1 + bubble_test.go | 16 ++++++++++++++++ liquid.go | 2 +- liquid_test.go | 13 +++++++++++++ movement.go | 3 +++ simulation.go | 16 ++++++++++++++-- 11 files changed, 105 insertions(+), 4 deletions(-) diff --git a/block/environment.go b/block/environment.go index e22cf33..b5f338c 100644 --- a/block/environment.go +++ b/block/environment.go @@ -17,4 +17,8 @@ func (r environmentRule) Apply(s *resolution) { s.InsideMovement = r.inside s.Traversal = r.traversal s.Honey = r.honey + if r.honey { + s.GroundFriction = 0.8 + s.groundFrictionSet = true + } } diff --git a/block/semantics.go b/block/semantics.go index 09fd3e7..7fa940d 100644 --- a/block/semantics.go +++ b/block/semantics.go @@ -75,6 +75,7 @@ var rules = [...]rule{ environmentRule{name: "minecraft:powder_snow", inside: InsideMovementPowderSnow, traversal: TraversalPowderSnow}, environmentRule{name: "minecraft:scaffolding", traversal: TraversalScaffolding}, frictionBlock{name: "minecraft:ice", friction: 0.98}, + frictionBlock{name: "minecraft:frosted_ice", friction: 0.98}, frictionBlock{name: "minecraft:packed_ice", friction: 0.98}, frictionBlock{name: "minecraft:blue_ice", friction: 0.989}, } diff --git a/block_effects.go b/block_effects.go index daae343..edd08c8 100644 --- a/block_effects.go +++ b/block_effects.go @@ -47,12 +47,14 @@ func applyStuckSpeedMultiplier(state *MovementState) bool { return true } -func applyAscendableMovement(state *MovementState, traversal movementblock.Traversal, leatherBoots bool) { +func applyAscendableMovement(state *MovementState, traversal movementblock.Traversal, leatherBoots bool) bool { velocity := state.Vel switch traversal { case movementblock.TraversalScaffolding: if state.PressingDescend { velocity[1] = -0.15 + state.SetVel(velocity) + return true } else if state.PressingAscend { velocity[1] = 0.15 } @@ -64,6 +66,7 @@ func applyAscendableMovement(state *MovementState, traversal movementblock.Trave } } state.SetVel(velocity) + return false } func (s *Simulator) applyInsideBlockEffects(state *MovementState) { diff --git a/block_effects_test.go b/block_effects_test.go index 79c4ccf..b26866b 100644 --- a/block_effects_test.go +++ b/block_effects_test.go @@ -204,6 +204,41 @@ func TestSimulationAppliesScaffoldingTraversal(t *testing.T) { } } +func TestScaffoldingDescendSkipsAirGravity(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:scaffolding"}, + }} + sim := &Simulator{World: w, BlockSemantics: encodedBlockSemantics{}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Gravity = NormalGravity + state.HasGravity = true + state.PressingDescend = true + sim.SimulateState(state) + if math32.Abs(state.Vel.Y()-(-0.15*NormalGravityMultiplier)) > 1e-6 { + t.Fatalf("scaffolding descent velocity = %v, want %v", state.Vel.Y(), -0.15*NormalGravityMultiplier) + } +} + +func TestScaffoldingSupportEnablesDescent(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:scaffolding"}, + }} + sim := &Simulator{World: w, BlockSemantics: encodedBlockSemantics{}} + support := cube.Pos{0, 0, 0} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 1, 0.5} + state.OnGround = true + state.HasGravity = true + state.SupportingBlockPos = &support + state.PressingDescend = true + + sim.SimulateState(state) + if math32.Abs(state.Vel.Y()-(-0.15*NormalGravityMultiplier)) > 1e-6 { + t.Fatalf("supported scaffolding descent velocity = %v, want %v", state.Vel.Y(), -0.15*NormalGravityMultiplier) + } +} + func TestSimulationDetectsNonSolidWebAndAppliesWeaving(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{ {0, 0, 0}: semanticsNamedBlock{name: "minecraft:web"}, diff --git a/block_semantics_test.go b/block_semantics_test.go index 6f1f30c..fca74f3 100644 --- a/block_semantics_test.go +++ b/block_semantics_test.go @@ -106,6 +106,12 @@ func TestBlueIceFrictionMatchesAcrossBlockRepresentations(t *testing.T) { } } +func TestFrostedIceFrictionMatchesVanillaIce(t *testing.T) { + if got := movementblock.Resolve(semanticsNamedBlock{"minecraft:frosted_ice"}, "minecraft:frosted_ice").GroundFriction; got != 0.98 { + t.Fatalf("frosted ice friction = %.8f, want 0.98", got) + } +} + func TestDefaultMovementBlockSemanticsSpecialBlocks(t *testing.T) { for name, want := range map[string]struct { block world.Block @@ -151,6 +157,13 @@ func TestEnvironmentMovementSemantics(t *testing.T) { } } +func TestHoneyBlockFrictionMatchesVanilla(t *testing.T) { + got := movementblock.Resolve(semanticsNamedBlock{"minecraft:honey_block"}, "minecraft:honey_block") + if got.GroundFriction != 0.8 { + t.Fatalf("honey block friction = %.8f, want 0.8", got.GroundFriction) + } +} + // semanticsNamedBlock is enough to exercise name-based semantics without depending on // a particular Dragonfly block implementation being present in the registry. type semanticsNamedBlock struct{ name string } diff --git a/bubble.go b/bubble.go index af7c84a..617c2c9 100644 --- a/bubble.go +++ b/bubble.go @@ -97,6 +97,7 @@ func (s *Simulator) simulateRiptide(state *MovementState) { state.SetMov(state.Vel) s.setPostCollisionMotion(state, oldVel, oldOnGround, block.Air{}) s.applyInsideBlockEffects(state) + s.applyBubbleColumns(state) } func stopRiptideOnBlockCollision(state *MovementState) { diff --git a/bubble_test.go b/bubble_test.go index a2b72b1..c6a4402 100644 --- a/bubble_test.go +++ b/bubble_test.go @@ -76,6 +76,22 @@ func TestBubbleColumnAppliesForEachOccupiedCell(t *testing.T) { } } +func TestBubbleColumnAppliesOutsideLiquidTravel(t *testing.T) { + w := environmentWorld{ + bubbles: map[cube.Pos]BubbleColumnDirection{{0, 0, 0}: BubbleColumnUp}, + blocks: map[cube.Pos]world.Block{}, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.HasGravity = false + + (&Simulator{World: w}).SimulateState(state) + + if state.Vel.Y() != 0.1 { + t.Fatalf("normal movement missed surface bubble impulse: %v", state.Vel.Y()) + } +} + func TestRiptideLaunchesInWaterAndStartsSpinAttack(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Water{Still: true, Depth: 8}}} sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} diff --git a/liquid.go b/liquid.go index b7b0a75..4ca49c6 100644 --- a/liquid.go +++ b/liquid.go @@ -121,7 +121,7 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, vel := state.Vel if water { drag := float32(0.8) - if state.Sprinting { + if state.Sprinting || state.StoppedSwimmingThisTick { drag = 0.9 } if depthStriderLevel > 0 && swimSpeedMultiplier <= 1 { diff --git a/liquid_test.go b/liquid_test.go index 62f1d60..3e8897a 100644 --- a/liquid_test.go +++ b/liquid_test.go @@ -464,6 +464,19 @@ func TestSwimmingCancelsWaterGravity(t *testing.T) { } } +func TestStopSwimmingUsesFastWaterDragForOneTick(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = false + state.StoppedSwimmingThisTick = true + state.Vel = mgl32.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.X(), 0.45) { + t.Fatalf("stop-swimming drag = %v, want 0.45", state.Vel.X()) + } +} + // Gravity is skipped entirely when the state has no gravity. func TestNoGravityInLiquid(t *testing.T) { sim := newLiquidSim(filledColumn(waterSource)) diff --git a/movement.go b/movement.go index 700e5f9..1b8e465 100644 --- a/movement.go +++ b/movement.go @@ -85,6 +85,9 @@ type MovementState struct { Swimming bool SwimAmount float32 + // StoppedSwimmingThisTick selects the client's fast water drag on the + // transition out of swimming. + StoppedSwimmingThisTick bool // SwimWaterGraceTicks retains recent server-observed water contact. SwimWaterGraceTicks int64 AutoJumpingInWater bool diff --git a/simulation.go b/simulation.go index aa1fa4b..eb1e71c 100644 --- a/simulation.go +++ b/simulation.go @@ -281,6 +281,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } wasSwimming := state.Swimming + state.StoppedSwimmingThisTick = input.StopSwimming if input.StopSwimming { state.Swimming = false s.restorePoseAfterSwimming(state, poseCollisionsAvailable) @@ -414,6 +415,7 @@ func (s *Simulator) tickState(state *MovementState) { } } state.JustDisabledFlight = false + state.StoppedSwimmingThisTick = false } func (s *Simulator) simulateMovement(state *MovementState) { @@ -513,6 +515,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SetVel(mgl32.Vec3{}) } s.applyInsideBlockEffects(state) + s.applyBubbleColumns(state) return } @@ -527,8 +530,14 @@ func (s *Simulator) simulateMovement(state *MovementState) { s.debugf("moveRelative force applied (vel=%v)", state.Vel) s.debugfIf(s.attemptJump(state, &clientJumpPrevented), "jump force applied (sprint=%v): %v", state.Sprinting, state.Vel) insideSemantics := s.blockMovementSemantics(s.blockAtPos(posFromVec3(state.Pos))) + if insideSemantics.Traversal == movementblock.TraversalNone && state.SupportingBlockPos != nil { + supportingSemantics := s.blockMovementSemantics(s.blockAtPos(*state.SupportingBlockPos)) + if supportingSemantics.Traversal != movementblock.TraversalNone { + insideSemantics = supportingSemantics + } + } leatherBoots := s.Equipment != nil && s.Equipment.WearingLeatherBoots() - applyAscendableMovement(state, insideSemantics.Traversal, leatherBoots) + scaffoldDescend := applyAscendableMovement(state, insideSemantics.Traversal, leatherBoots) nearClimbable := insideSemantics.Climbable || s.hasClimbableContact(state) if nearClimbable { @@ -605,7 +614,9 @@ func (s *Simulator) simulateMovement(state *MovementState) { } newVel := state.Vel - if s.Effects != nil { + if scaffoldDescend && !state.OnGround { + newVel[1] *= NormalGravityMultiplier + } else if s.Effects != nil { if amp, ok := s.Effects.GetEffect(packet.EffectLevitation); ok { levSpeed := LevitationGravityMultiplier * float32(amp+1) newVel[1] += (levSpeed - newVel[1]) * 0.2 @@ -621,6 +632,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { newVel[2] *= blockFriction state.SetVel(newVel) s.applyInsideBlockEffects(state) + s.applyBubbleColumns(state) } func (s *Simulator) simulationIsReliable(state *MovementState) bool { From e7b85a65d5542762bd66f0529a38a5dc679ca3b5 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 6 Aug 2026 20:01:40 -0700 Subject: [PATCH 03/23] fix: align movement edge cases --- README.md | 4 ++++ block_effects_test.go | 8 ++++---- bubble.go | 39 +++++++++++++++++++++++++++++-------- interfaces.go | 4 +++- liquid.go | 2 -- movement.go | 6 +++++- parity_regressions_test.go | 26 ++++++++++++++++++++----- simulation.go | 40 ++++++++++++-------------------------- 8 files changed, 80 insertions(+), 49 deletions(-) diff --git a/README.md b/README.md index 327f6cb..8bf3ad5 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,10 @@ authoritative events instead of setting their timer fields by hand. Set `MovementState.JumpStrength` for a custom base jump velocity; zero keeps the default. +`MovementState.MovementSpeed` and `DefaultMovementSpeed` are effective movement +attribute values. Include active Speed or Slowness modifiers in those values; +BedSim uses them directly and does not apply the same modifiers a second time. + Riptide input flags are not trusted on their own. Set `MovementState.RiptideReady` for the simulation tick only after validating a charged Riptide-trident release. Set `MovementState.RiptideCollision` after a server-observed entity collision to diff --git a/block_effects_test.go b/block_effects_test.go index b26866b..27313fa 100644 --- a/block_effects_test.go +++ b/block_effects_test.go @@ -215,8 +215,8 @@ func TestScaffoldingDescendSkipsAirGravity(t *testing.T) { state.HasGravity = true state.PressingDescend = true sim.SimulateState(state) - if math32.Abs(state.Vel.Y()-(-0.15*NormalGravityMultiplier)) > 1e-6 { - t.Fatalf("scaffolding descent velocity = %v, want %v", state.Vel.Y(), -0.15*NormalGravityMultiplier) + if math32.Abs(state.Vel.Y()-(-0.15)) > 1e-6 { + t.Fatalf("scaffolding descent velocity = %v, want %v", state.Vel.Y(), -0.15) } } @@ -234,8 +234,8 @@ func TestScaffoldingSupportEnablesDescent(t *testing.T) { state.PressingDescend = true sim.SimulateState(state) - if math32.Abs(state.Vel.Y()-(-0.15*NormalGravityMultiplier)) > 1e-6 { - t.Fatalf("supported scaffolding descent velocity = %v, want %v", state.Vel.Y(), -0.15*NormalGravityMultiplier) + if math32.Abs(state.Vel.Y()-(-0.15)) > 1e-6 { + t.Fatalf("supported scaffolding descent velocity = %v, want %v", state.Vel.Y(), -0.15) } } diff --git a/bubble.go b/bubble.go index 617c2c9..fc33622 100644 --- a/bubble.go +++ b/bubble.go @@ -64,7 +64,7 @@ func (s *Simulator) applyBubbleColumns(state *MovementState) { } } -func (s *Simulator) attemptRiptide(state *MovementState, touchingWater bool) bool { +func (s *Simulator) attemptRiptide(state *MovementState, touchingWater, headInWater bool) bool { if s.Equipment == nil || state.InVehicle || state.RiptideTicks > 0 || !state.RiptideReady || (!touchingWater && !state.RiptideInRain) { return false } @@ -72,21 +72,44 @@ func (s *Simulator) attemptRiptide(state *MovementState, touchingWater bool) boo if level <= 0 || !state.StartingSpinAttack { return false } - force := 1.5 + 0.75*float32(level-1) + state.SetVel(state.Vel.Add(s.riptideImpulse(state, level, touchingWater, headInWater))) + state.RiptideTicks = 20 + state.RiptideCollision = false + state.StartingSpinAttack = false + return true +} + +func (s *Simulator) riptideImpulse(state *MovementState, level int, wasInWater, headInWater bool) mgl32.Vec3 { + force := 0.75 * float32(level+1) pitch := state.Rotation.X() * math32.Pi / 180 yaw := state.Rotation.Z() * math32.Pi / 180 direction := mgl32.Vec3{-MCSin(yaw) * MCCos(pitch), -MCSin(pitch), MCCos(yaw) * MCCos(pitch)} if length := direction.Len(); length > 0 { direction = direction.Mul(force / length) } - state.SetVel(state.Vel.Add(direction)) - state.RiptideTicks = 20 - state.RiptideCollision = false - state.StartingSpinAttack = false - return true + if wasInWater { + if headInWater { + direction[1] = direction[1] / 0.8 * NormalGravityMultiplier + } else { + direction[1] += 0.08 + } + } + return direction } -func (s *Simulator) simulateRiptide(state *MovementState) { +func (s *Simulator) riptideHeadInWater(state *MovementState) bool { + position := state.Pos.Add(mgl32.Vec3{0, DefaultPlayerHeightOffset, 0}) + pos := posFromVec3(position) + liquid, ok := s.liquidAt(pos) + return ok && liquidWater.matches(liquid) && position.Y() < float32(pos.Y())+liquidHeight(liquid) +} + +func (s *Simulator) simulateRiptide(state *MovementState, wasInWater, headInWater bool) { + if s.Equipment != nil { + if level := s.Equipment.EnchantmentLevel(EnchantmentRiptide); level > 0 { + state.SetVel(state.Vel.Add(s.riptideImpulse(state, level, wasInWater, headInWater))) + } + } oldVel := state.Vel oldOnGround := state.OnGround oldY := state.Pos.Y() diff --git a/interfaces.go b/interfaces.go index b53c76a..5930a4e 100644 --- a/interfaces.go +++ b/interfaces.go @@ -16,7 +16,7 @@ type WorldProvider interface { } // MovementAreaProvider can provide a precise loaded/known check for a swept -// movement volume. Worlds that only expose chunk loading use BedSim's +// movement volume in world space. Worlds that only expose chunk loading use BedSim's // conservative chunk-range fallback. type MovementAreaProvider interface { IsMovementAreaLoaded(aabb cube.BBox32) bool @@ -44,12 +44,14 @@ type MovementCollisionProvider interface { } // ClimbableContactProvider resolves orientation-aware ladder and vine contact. +// aabb is in world space. // The built-in fallback scans intersecting block volumes when this is absent. type ClimbableContactProvider interface { HasClimbableContact(aabb cube.BBox32) bool } // MovementSupportProvider resolves the exact support block for dynamic shapes. +// aabb is in world space. // It is optional because a generic collision provider may not retain source // block identities. type MovementSupportProvider interface { diff --git a/liquid.go b/liquid.go index 4ca49c6..3f36d0a 100644 --- a/liquid.go +++ b/liquid.go @@ -100,8 +100,6 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, moveRelativeSpeed += (state.MovementSpeed - moveRelativeSpeed) * depthStriderFraction } } - moveRelativeSpeed *= s.movementEffectMultiplier() - moveRelative(state, moveRelativeSpeed) stuckMovement := applyStuckSpeedMultiplier(state) oldVel := state.Vel diff --git a/movement.go b/movement.go index 1b8e465..9a9455c 100644 --- a/movement.go +++ b/movement.go @@ -48,7 +48,11 @@ type MovementState struct { JumpStrength float32 FallDistance float32 - MovementSpeed float32 + // MovementSpeed is the effective movement attribute used by travel. Include + // movement effects in this value before passing the state to BedSim. + MovementSpeed float32 + // DefaultMovementSpeed is the effective non-sprinting movement attribute + // used when sprinting is toggled. DefaultMovementSpeed float32 AirSpeed float32 UnderwaterMovementSpeed float32 diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 5115ef1..34fd20f 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -8,6 +8,7 @@ import ( "github.com/df-mc/dragonfly/server/block/cube" "github.com/df-mc/dragonfly/server/world" "github.com/go-gl/mathgl/mgl32" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" ) func TestZeroValueStateHasNoSyntheticEvents(t *testing.T) { @@ -65,22 +66,37 @@ func TestSimulateStateLeavesTransientInputForCaller(t *testing.T) { } } -func TestActiveRiptideSkipsOrdinaryPhysics(t *testing.T) { +func TestActiveRiptideAppliesImpulseWithoutOrdinaryPhysics(t *testing.T) { state := newBaseState() state.RiptideTicks = 5 state.Vel = mgl32.Vec3{0, 0.8, 0} state.Impulse = mgl32.Vec2{0, 1} state.Gravity = NormalGravity - (&Simulator{World: mockWorld{}}).SimulateState(state) - if state.Pos != (mgl32.Vec3{0, 0.8, 0}) { - t.Fatalf("riptide tick applied ordinary displacement: %v", state.Pos) + (&Simulator{World: mockWorld{}, Equipment: fixedEquipment{EnchantmentRiptide: 2}}).SimulateState(state) + if math32.Abs(state.Pos.Z()-2.25) > 1e-6 { + t.Fatalf("riptide tick did not apply directional displacement: %v", state.Pos) } - if state.Vel != (mgl32.Vec3{0, 0.8, 0}) { + if math32.Abs(state.Vel.Y()-0.8) > 1e-6 || math32.Abs(state.Vel.Z()-2.25) > 1e-6 { t.Fatalf("riptide tick applied ordinary acceleration: %v", state.Vel) } } +func TestMovementSpeedUsesEffectiveAttribute(t *testing.T) { + withoutEffect := newBaseState() + withoutEffect.MovementSpeed = 0.12 + withoutEffect.DefaultMovementSpeed = 0.12 + withoutEffect.Impulse = mgl32.Vec2{0, 1} + + withEffect := *withoutEffect + + base := (&Simulator{World: mockWorld{}}).SimulateState(withoutEffect) + withSpeedEffect := (&Simulator{World: mockWorld{}, Effects: fixedEffects{packet.EffectSpeed: 0}}).SimulateState(&withEffect) + if base.Velocity != withSpeedEffect.Velocity { + t.Fatalf("effective movement speed was modified by a second effect pass: base=%v with_effect=%v", base.Velocity, withSpeedEffect.Velocity) + } +} + func TestTeleportDoesNotApplyJumpImpulse(t *testing.T) { state := newBaseState() state.OnGround = true diff --git a/simulation.go b/simulation.go index eb1e71c..89d20a1 100644 --- a/simulation.go +++ b/simulation.go @@ -440,12 +440,12 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SwimWaterGraceTicks = grace setSwimmingPoseFlags(state) } - riptideLaunched := !state.Flying && s.attemptRiptide(state, inWater) + riptideLaunched := !state.Flying && s.attemptRiptide(state, inWater, s.riptideHeadInWater(state)) if riptideLaunched { s.debugf("riptide launch applied: %v", state.Vel) } if !state.Flying && state.RiptideTicks > 0 && !riptideLaunched { - s.simulateRiptide(state) + s.simulateRiptide(state, inWater, s.riptideHeadInWater(state)) return } @@ -491,8 +491,6 @@ func (s *Simulator) simulateMovement(state *MovementState) { accelerationFriction := blockFriction * accelerationMultiplier moveRelativeSpeed = mSpeed * (0.16277136 / (accelerationFriction * accelerationFriction * accelerationFriction)) } - moveRelativeSpeed *= s.movementEffectMultiplier() - if state.Gliding && s.Effects != nil { if _, levitating := s.Effects.GetEffect(packet.EffectLevitation); levitating { state.Gliding = false @@ -614,19 +612,19 @@ func (s *Simulator) simulateMovement(state *MovementState) { } newVel := state.Vel - if scaffoldDescend && !state.OnGround { - newVel[1] *= NormalGravityMultiplier - } else if s.Effects != nil { - if amp, ok := s.Effects.GetEffect(packet.EffectLevitation); ok { - levSpeed := LevitationGravityMultiplier * float32(amp+1) - newVel[1] += (levSpeed - newVel[1]) * 0.2 + if !scaffoldDescend { + if s.Effects != nil { + if amp, ok := s.Effects.GetEffect(packet.EffectLevitation); ok { + levSpeed := LevitationGravityMultiplier * float32(amp+1) + newVel[1] += (levSpeed - newVel[1]) * 0.2 + } else if state.HasGravity { + newVel[1] -= effectiveGravity(state, newVel) + newVel[1] *= NormalGravityMultiplier + } } else if state.HasGravity { newVel[1] -= effectiveGravity(state, newVel) newVel[1] *= NormalGravityMultiplier } - } else if state.HasGravity { - newVel[1] -= effectiveGravity(state, newVel) - newVel[1] *= NormalGravityMultiplier } newVel[0] *= blockFriction newVel[2] *= blockFriction @@ -747,7 +745,7 @@ func (s *Simulator) simulateGlide(state *MovementState) { vel[0] += lookX * yAccel / lookHz vel[2] += lookZ * yAccel / lookHz } - if pitch < 0 && math32.Abs(lookHz) > 1e-6 { + if pitch < 0 && lookHz > 0 { yAccel := velHz * -pitchSin * 0.04 vel[1] += yAccel * 3.2 vel[0] -= lookX * yAccel / lookHz @@ -873,20 +871,6 @@ func moveRelative(state *MovementState, moveRelativeSpeed float32) { } } -func (s *Simulator) movementEffectMultiplier() float32 { - if s == nil || s.Effects == nil { - return 1 - } - multiplier := float32(1) - if amplifier, ok := s.Effects.GetEffect(packet.EffectSpeed); ok { - multiplier *= 1 + 0.2*float32(amplifier+1) - } - if amplifier, ok := s.Effects.GetEffect(packet.EffectSlowness); ok { - multiplier *= math32.Max(0, 1-0.15*float32(amplifier+1)) - } - return multiplier -} - func attemptKnockback(state *MovementState) bool { if state.HasKnockback() { state.SetVel(state.Knockback) From 8316c1ac65522452f75c1bec1ba07f15132ca022 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 6 Aug 2026 20:13:39 -0700 Subject: [PATCH 04/23] fix: close remaining movement parity gaps --- README.md | 5 +++- bubble.go | 31 ++++++++++++++++++------ bubble_test.go | 27 +++++++++++++++++++++ movement.go | 8 +++++-- parity_regressions_test.go | 49 ++++++++++++++++++++++++++++++++++++++ simulation.go | 39 ++++++++++++++++++------------ 6 files changed, 134 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 8bf3ad5..a0561fd 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ should affect water movement. `WorldProvider` is the only required world interface. A world may additionally implement `BubbleColumnProvider` for upward/downward columns and -`MovementCollisionProvider` for player-dependent collision shapes such as +`BubbleColumnSurfaceProvider` when it can classify the exact surface variant, +and `MovementCollisionProvider` for player-dependent collision shapes such as scaffolding and powder snow. Dynamic collision resolution receives sneak and descend intent plus leather-boots state. @@ -103,6 +104,8 @@ default. `MovementState.MovementSpeed` and `DefaultMovementSpeed` are effective movement attribute values. Include active Speed or Slowness modifiers in those values; BedSim uses them directly and does not apply the same modifiers a second time. +`JumpHeight` is derived during simulation; set `JumpStrength` when a custom +base jump velocity is needed. Riptide input flags are not trusted on their own. Set `MovementState.RiptideReady` for the simulation tick only after validating a charged Riptide-trident release. diff --git a/bubble.go b/bubble.go index fc33622..585ad50 100644 --- a/bubble.go +++ b/bubble.go @@ -22,6 +22,13 @@ type BubbleColumnProvider interface { BubbleColumn(pos cube.Pos) (BubbleColumnDirection, bool) } +// BubbleColumnSurfaceProvider optionally supplies the exact client-side +// surface variant for a bubble-column cell. The bool reports whether the +// adapter knows the variant; false falls back to the block-above heuristic. +type BubbleColumnSurfaceProvider interface { + BubbleColumnSurface(pos cube.Pos) (surface, known bool) +} + func applyBubbleColumn(state *MovementState, direction BubbleColumnDirection, surface bool) { velocity := state.Vel switch direction { @@ -56,9 +63,16 @@ func (s *Simulator) applyBubbleColumns(state *MovementState) { if !found { continue } - above := pos.Side(cube.FaceUp) - _, liquidAbove := s.liquidAt(above) - applyBubbleColumn(state, direction, !liquidAbove && s.blockAir(s.blockAtPos(above))) + surface, known := false, false + if surfaceProvider, ok := s.World.(BubbleColumnSurfaceProvider); ok { + surface, known = surfaceProvider.BubbleColumnSurface(pos) + } + if !known { + above := pos.Side(cube.FaceUp) + _, liquidAbove := s.liquidAt(above) + surface = !liquidAbove && s.blockAir(s.blockAtPos(above)) + } + applyBubbleColumn(state, direction, surface) } } } @@ -74,6 +88,7 @@ func (s *Simulator) attemptRiptide(state *MovementState, touchingWater, headInWa } state.SetVel(state.Vel.Add(s.riptideImpulse(state, level, touchingWater, headInWater))) state.RiptideTicks = 20 + state.RiptideLevel = level state.RiptideCollision = false state.StartingSpinAttack = false return true @@ -105,10 +120,12 @@ func (s *Simulator) riptideHeadInWater(state *MovementState) bool { } func (s *Simulator) simulateRiptide(state *MovementState, wasInWater, headInWater bool) { - if s.Equipment != nil { - if level := s.Equipment.EnchantmentLevel(EnchantmentRiptide); level > 0 { - state.SetVel(state.Vel.Add(s.riptideImpulse(state, level, wasInWater, headInWater))) - } + level := state.RiptideLevel + if level <= 0 && s.Equipment != nil { + level = s.Equipment.EnchantmentLevel(EnchantmentRiptide) + } + if level > 0 { + state.SetVel(state.Vel.Add(s.riptideImpulse(state, level, wasInWater, headInWater))) } oldVel := state.Vel oldOnGround := state.OnGround diff --git a/bubble_test.go b/bubble_test.go index c6a4402..93573bd 100644 --- a/bubble_test.go +++ b/bubble_test.go @@ -55,6 +55,33 @@ func TestBubbleColumnSurfaceAcceptsRegistryBackedAir(t *testing.T) { } } +type exactBubbleSurfaceWorld struct { + environmentWorld + surface bool +} + +func (w exactBubbleSurfaceWorld) BubbleColumnSurface(cube.Pos) (bool, bool) { + return w.surface, true +} + +func TestBubbleColumnUsesExactSurfaceProvider(t *testing.T) { + w := exactBubbleSurfaceWorld{ + environmentWorld: environmentWorld{ + bubbles: map[cube.Pos]BubbleColumnDirection{{0, 0, 0}: BubbleColumnUp}, + blocks: map[cube.Pos]world.Block{{0, 1, 0}: block.Water{Still: true, Depth: 8}}, + }, + surface: true, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + + (&Simulator{World: w}).applyBubbleColumns(state) + + if state.Vel.Y() != 0.1 { + t.Fatalf("exact surface provider was ignored: %v", state.Vel.Y()) + } +} + func TestBubbleColumnAppliesForEachOccupiedCell(t *testing.T) { w := environmentWorld{ bubbles: map[cube.Pos]BubbleColumnDirection{ diff --git a/movement.go b/movement.go index 9a9455c..2d1ecc1 100644 --- a/movement.go +++ b/movement.go @@ -42,7 +42,8 @@ type MovementState struct { SupportingBlockPos *cube.Pos - Gravity float32 + Gravity float32 + // JumpHeight is derived by Simulate from JumpStrength and active effects. JumpHeight float32 // JumpStrength is the base jump velocity. Zero uses DefaultJumpHeight. JumpStrength float32 @@ -116,7 +117,10 @@ type MovementState struct { Crawling bool TicksSinceCanSlowdown int RiptideTicks int - StartingSpinAttack bool + // RiptideLevel is captured when the spin attack starts. A non-zero value + // preserves the active attack's force if equipment changes mid-attack. + RiptideLevel int + StartingSpinAttack bool // RiptideReady is a one-tick trusted latch set after validating a charged // Riptide trident release. RiptideCollision is set after a server-observed // entity collision and authorizes the matching stop/reversal. diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 34fd20f..0d71e43 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -40,6 +40,15 @@ func TestSimulationRejectsNonFiniteInputAndState(t *testing.T) { } } +func TestPassiveModeDoesNotRequestCorrectionForInvalidInput(t *testing.T) { + result := (&Simulator{Options: SimulationOptions{Mode: SimulationModePassive}}).SimulateState(&MovementState{ + Vel: mgl32.Vec3{math32.NaN(), 0, 0}, + }) + if result.Outcome != SimulationOutcomeInvalidInput || result.NeedsCorrection { + t.Fatalf("passive invalid-input result = %+v", result) + } +} + func TestMountedStateSkipsMovement(t *testing.T) { state := newBaseState() state.InVehicle = true @@ -82,6 +91,30 @@ func TestActiveRiptideAppliesImpulseWithoutOrdinaryPhysics(t *testing.T) { } } +func TestActiveRiptideKeepsLaunchLevel(t *testing.T) { + state := newBaseState() + state.RiptideTicks = 5 + state.RiptideLevel = 2 + + equipment := fixedEquipment{EnchantmentRiptide: 0} + (&Simulator{World: mockWorld{}, Equipment: equipment}).SimulateState(state) + if math32.Abs(state.Vel.Z()-2.25) > 1e-6 { + t.Fatalf("active Riptide did not keep its launch level: %v", state.Vel) + } +} + +func TestActiveRiptideConsumesRetainedWaterGrace(t *testing.T) { + state := newBaseState() + state.RiptideTicks = 5 + state.RiptideLevel = 2 + state.SwimWaterGraceTicks = 2 + + (&Simulator{World: mockWorld{}, Equipment: fixedEquipment{}}).SimulateState(state) + if state.SwimWaterGraceTicks != 1 { + t.Fatalf("active Riptide retained water grace = %d, want 1", state.SwimWaterGraceTicks) + } +} + func TestMovementSpeedUsesEffectiveAttribute(t *testing.T) { withoutEffect := newBaseState() withoutEffect.MovementSpeed = 0.12 @@ -184,6 +217,22 @@ func TestAdjacentClimbableContactIsDetected(t *testing.T) { } } +func TestClimbableBlockBelowIsNotContact(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, -1, 0}: block.Ladder{Facing: cube.West}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.EffectiveJumping = true + state.Gravity = NormalGravity + + (&Simulator{World: w}).SimulateState(state) + if math32.Abs(state.Vel.Y()-ClimbSpeed) < 1e-6 { + t.Fatalf("ladder below the player was treated as climbable contact: %v", state.Vel) + } +} + type selectiveChunkWorld struct{} func (selectiveChunkWorld) Block(cube.Pos) world.Block { return block.Air{} } diff --git a/simulation.go b/simulation.go index 89d20a1..f0de852 100644 --- a/simulation.go +++ b/simulation.go @@ -17,7 +17,7 @@ import ( // Simulate runs a movement simulation tick and returns the resulting state. func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationResult { if state == nil || !finiteMovementState(state) || !finiteInput(input) { - return invalidSimulationResult() + return s.invalidSimulationResult() } pose := movementPoseSnapshot{ @@ -56,10 +56,12 @@ func (p movementPoseSnapshot) restore(state *MovementState) { } // SimulateState runs movement simulation using the current state values, without applying input updates -// or advancing tick counters. This is useful when the caller handles input parsing and ticking externally. +// or advancing tick counters. Callers that use it must advance tick counters +// and clear transient fields such as KnockbackPending, RiptideReady, and +// StoppedSwimmingThisTick themselves. func (s *Simulator) SimulateState(state *MovementState) SimulationResult { if state == nil || !finiteMovementState(state) { - return invalidSimulationResult() + return s.invalidSimulationResult() } reason := s.simulateCore(state, false) return s.resultFromState(state, reason) @@ -77,8 +79,11 @@ func (s *Simulator) debugfIf(cond bool, format string, args ...any) { } } -func invalidSimulationResult() SimulationResult { - return SimulationResult{Outcome: SimulationOutcomeInvalidInput, NeedsCorrection: true} +func (s *Simulator) invalidSimulationResult() SimulationResult { + return SimulationResult{ + Outcome: SimulationOutcomeInvalidInput, + NeedsCorrection: s == nil || s.Options.Mode != SimulationModePassive, + } } func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) SimulationOutcome { @@ -361,6 +366,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.StartingSpinAttack = input.StartSpinAttack if input.StopSpinAttack && state.RiptideTicks > 0 && state.RiptideCollision { state.RiptideTicks = 0 + state.RiptideLevel = 0 state.RiptideCollision = false state.SetVel(state.Vel.Mul(-0.2)) } @@ -412,6 +418,7 @@ func (s *Simulator) tickState(state *MovementState) { state.RiptideTicks-- if state.RiptideTicks == 0 { state.RiptideCollision = false + state.RiptideLevel = 0 } } state.JustDisabledFlight = false @@ -440,6 +447,13 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SwimWaterGraceTicks = grace setSwimmingPoseFlags(state) } + defer func() { + if inWater { + state.SwimWaterGraceTicks = grace + } else if state.SwimWaterGraceTicks > 0 { + state.SwimWaterGraceTicks-- + } + }() riptideLaunched := !state.Flying && s.attemptRiptide(state, inWater, s.riptideHeadInWater(state)) if riptideLaunched { s.debugf("riptide launch applied: %v", state.Vel) @@ -449,14 +463,6 @@ func (s *Simulator) simulateMovement(state *MovementState) { return } - defer func() { - if inWater { - state.SwimWaterGraceTicks = grace - } else if state.SwimWaterGraceTicks > 0 { - state.SwimWaterGraceTicks-- - } - }() - // Observed lava takes precedence over retained water evidence. waterTravel := inWater || (state.Swimming && state.SwimWaterGraceTicks > 0 && len(lavaBlocks) == 0) @@ -531,7 +537,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { if insideSemantics.Traversal == movementblock.TraversalNone && state.SupportingBlockPos != nil { supportingSemantics := s.blockMovementSemantics(s.blockAtPos(*state.SupportingBlockPos)) if supportingSemantics.Traversal != movementblock.TraversalNone { - insideSemantics = supportingSemantics + insideSemantics.Traversal = supportingSemantics.Traversal } } leatherBoots := s.Equipment != nil && s.Equipment.WearingLeatherBoots() @@ -1275,7 +1281,7 @@ func (s *Simulator) hasClimbableContact(state *MovementState) bool { if s.World == nil { return false } - box := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl32.Vec3{0.05, 0.05, 0.05}) + box := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl32.Vec3{0.05, 0, 0.05}) if provider, ok := s.World.(ClimbableContactProvider); ok { return provider.HasClimbableContact(box) } @@ -1358,6 +1364,9 @@ func (s *Simulator) findSupportingBlock(state *MovementState, bb cube.BBox32) { } for _, box := range boxes { + if BBHasZeroVolume(box) { + continue + } if !bb.IntersectsWith(box.Translate(posVec3(pos))) { continue } From 55424608bda6feee1fad2813e41e7f098512100b Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 6 Aug 2026 20:49:48 -0700 Subject: [PATCH 05/23] fix: address movement parity review findings --- README.md | 14 ++-- bubble.go | 2 + bubble_test.go | 12 +++ constants.go | 2 + interfaces.go | 3 + movement.go | 18 ++++- parity_regressions_test.go | 128 +++++++++++++++++++++++++++++++ simulation.go | 153 +++++++++++++++++++++---------------- 8 files changed, 258 insertions(+), 74 deletions(-) diff --git a/README.md b/README.md index a0561fd..a2487ec 100644 --- a/README.md +++ b/README.md @@ -97,15 +97,19 @@ extension remains a fallback when the equipment provider reports no Depth Strider level. `EffectsProvider` also controls Weaving-aware web movement. Use `MovementState.QueueKnockback` and `MovementState.QueueTeleport` for -authoritative events instead of setting their timer fields by hand. Set -`MovementState.JumpStrength` for a custom base jump velocity; zero keeps the -default. +authoritative events instead of setting their timer fields by hand. `Simulate` +consumes those events as part of its tick; callers using `SimulateState` must +clear transient fields such as `KnockbackPending` and +`StoppedSwimmingThisTick` themselves. Set `MovementState.JumpStrength` for a +custom base jump velocity; zero keeps the default. `MovementState.MovementSpeed` and `DefaultMovementSpeed` are effective movement attribute values. Include active Speed or Slowness modifiers in those values; BedSim uses them directly and does not apply the same modifiers a second time. -`JumpHeight` is derived during simulation; set `JumpStrength` when a custom -base jump velocity is needed. +`AirSpeed` is the effective air acceleration speed; `Simulate` derives it from +`MovementSpeed`, while `SimulateState` callers provide it with the current state. +`JumpHeight` is output-only and derived during simulation; set `JumpStrength` +when a custom base jump velocity is needed. Riptide input flags are not trusted on their own. Set `MovementState.RiptideReady` for the simulation tick only after validating a charged Riptide-trident release. diff --git a/bubble.go b/bubble.go index 585ad50..440193e 100644 --- a/bubble.go +++ b/bubble.go @@ -120,6 +120,7 @@ func (s *Simulator) riptideHeadInWater(state *MovementState) bool { } func (s *Simulator) simulateRiptide(state *MovementState, wasInWater, headInWater bool) { + s.debugfIf(attemptKnockback(state), "knockback applied during riptide: %v", state.Vel) level := state.RiptideLevel if level <= 0 && s.Equipment != nil { level = s.Equipment.EnchantmentLevel(EnchantmentRiptide) @@ -143,6 +144,7 @@ func (s *Simulator) simulateRiptide(state *MovementState, wasInWater, headInWate func stopRiptideOnBlockCollision(state *MovementState) { if state.RiptideTicks > 0 && (state.CollideX || state.CollideZ) { state.RiptideTicks = 0 + state.RiptideLevel = 0 state.RiptideCollision = false } } diff --git a/bubble_test.go b/bubble_test.go index 93573bd..b48a295 100644 --- a/bubble_test.go +++ b/bubble_test.go @@ -228,6 +228,18 @@ func TestRiptideStopsOnNormalMovementWallCollision(t *testing.T) { } } +func TestRiptideCollisionClearsLaunchLevel(t *testing.T) { + state := newBaseState() + state.RiptideTicks = 10 + state.RiptideLevel = 3 + state.CollideX = true + + stopRiptideOnBlockCollision(state) + if state.RiptideTicks != 0 || state.RiptideLevel != 0 { + t.Fatalf("riptide collision left active launch state: ticks=%d level=%d", state.RiptideTicks, state.RiptideLevel) + } +} + func TestRiptideStopRequiresValidatedEntityCollision(t *testing.T) { sim := &Simulator{} state := newBaseState() diff --git a/constants.go b/constants.go index 2c4f698..0e1c288 100644 --- a/constants.go +++ b/constants.go @@ -23,6 +23,8 @@ const ( DefaultUnderwaterMovementSpeed = float32(0.02) DefaultLavaMovementSpeed = float32(0.02) DefaultSwimSpeedMultiplier = float32(1) + AirMovementSpeedMultiplier = float32(0.2) + GlideHorizontalLookEpsilon = float32(1e-4) DefaultPlayerHeightOffset = float32(1.62) SneakingPlayerHeightOffset = float32(1.27) diff --git a/interfaces.go b/interfaces.go index 5930a4e..4c7c69a 100644 --- a/interfaces.go +++ b/interfaces.go @@ -19,6 +19,7 @@ type WorldProvider interface { // movement volume in world space. Worlds that only expose chunk loading use BedSim's // conservative chunk-range fallback. type MovementAreaProvider interface { + // IsMovementAreaLoaded receives a world-space movement volume. IsMovementAreaLoaded(aabb cube.BBox32) bool } @@ -47,6 +48,7 @@ type MovementCollisionProvider interface { // aabb is in world space. // The built-in fallback scans intersecting block volumes when this is absent. type ClimbableContactProvider interface { + // HasClimbableContact receives a world-space movement volume. HasClimbableContact(aabb cube.BBox32) bool } @@ -55,6 +57,7 @@ type ClimbableContactProvider interface { // It is optional because a generic collision provider may not retain source // block identities. type MovementSupportProvider interface { + // SupportingBlock receives a world-space movement volume. SupportingBlock(aabb cube.BBox32, context MovementCollisionContext) (cube.Pos, bool) } diff --git a/movement.go b/movement.go index 2d1ecc1..aff5080 100644 --- a/movement.go +++ b/movement.go @@ -43,7 +43,8 @@ type MovementState struct { SupportingBlockPos *cube.Pos Gravity float32 - // JumpHeight is derived by Simulate from JumpStrength and active effects. + // JumpHeight is an output derived by Simulate from JumpStrength and active + // effects; set JumpStrength to customize the base jump velocity. JumpHeight float32 // JumpStrength is the base jump velocity. Zero uses DefaultJumpHeight. JumpStrength float32 @@ -54,7 +55,10 @@ type MovementState struct { MovementSpeed float32 // DefaultMovementSpeed is the effective non-sprinting movement attribute // used when sprinting is toggled. - DefaultMovementSpeed float32 + DefaultMovementSpeed float32 + // AirSpeed is the effective air acceleration speed. Simulate derives it + // from MovementSpeed when the latter is set; SimulateState callers provide + // it as part of the current state. AirSpeed float32 UnderwaterMovementSpeed float32 LavaMovementSpeed float32 @@ -185,7 +189,7 @@ func (s *MovementState) SetRotation(newRot mgl32.Vec3) { } func (s *MovementState) HasKnockback() bool { - return s.KnockbackPending || s.TicksSinceKnockback == 0 && s.Knockback != (mgl32.Vec3{}) + return s.KnockbackPending || (s.TicksSinceKnockback == 0 && s.Knockback != (mgl32.Vec3{})) } func (s *MovementState) HasTeleport() bool { @@ -202,7 +206,12 @@ func (s *MovementState) RemainingTeleportTicks() int { if !s.HasTeleport() || s.TicksSinceTeleport >= s.TeleportCompletionTicks { return 0 } - return int(s.TeleportCompletionTicks - s.TicksSinceTeleport) + remaining := s.TeleportCompletionTicks - s.TicksSinceTeleport + maxInt := uint64(^uint(0) >> 1) + if remaining > maxInt { + return int(maxInt) + } + return int(remaining) } // QueueKnockback schedules one authoritative velocity replacement. @@ -214,6 +223,7 @@ func (s *MovementState) QueueKnockback(velocity mgl32.Vec3) { // QueueTeleport schedules one authoritative teleport. func (s *MovementState) QueueTeleport(pos mgl32.Vec3, smoothed bool, completionTicks uint64) { + s.PendingTeleportPos = pos s.TeleportPos = pos s.TeleportIsSmoothed = smoothed s.TeleportCompletionTicks = completionTicks diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 0d71e43..4fc8a77 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -103,6 +103,18 @@ func TestActiveRiptideKeepsLaunchLevel(t *testing.T) { } } +func TestActiveRiptidePreservesAuthoritativeKnockback(t *testing.T) { + state := newBaseState() + state.RiptideTicks = 5 + state.RiptideLevel = 2 + state.QueueKnockback(mgl32.Vec3{4, 0, 0}) + + (&Simulator{World: mockWorld{}, Equipment: fixedEquipment{}}).SimulateState(state) + if math32.Abs(state.Vel.X()-4) > 1e-6 || math32.Abs(state.Vel.Z()-2.25) > 1e-6 { + t.Fatalf("riptide did not preserve knockback plus impulse: %v", state.Vel) + } +} + func TestActiveRiptideConsumesRetainedWaterGrace(t *testing.T) { state := newBaseState() state.RiptideTicks = 5 @@ -130,6 +142,20 @@ func TestMovementSpeedUsesEffectiveAttribute(t *testing.T) { } } +func TestSimulateDerivesAirSpeedFromEffectiveMovementSpeed(t *testing.T) { + state := newBaseState() + state.MovementSpeed = 0.2 + state.DefaultMovementSpeed = 0.2 + + (&Simulator{World: mockWorld{}}).Simulate(state, InputState{StartSprinting: true}) + if math32.Abs(state.MovementSpeed-0.26) > 1e-6 { + t.Fatalf("sprinting movement speed = %v, want 0.26", state.MovementSpeed) + } + if math32.Abs(state.AirSpeed-0.052) > 1e-6 { + t.Fatalf("sprinting air speed = %v, want 0.052", state.AirSpeed) + } +} + func TestTeleportDoesNotApplyJumpImpulse(t *testing.T) { state := newBaseState() state.OnGround = true @@ -148,6 +174,28 @@ func TestTeleportDoesNotApplyJumpImpulse(t *testing.T) { } } +func TestQueueTeleportCanTargetOrigin(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{10, 20, 30} + state.QueueTeleport(mgl32.Vec3{}, false, 0) + + result := (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport || state.Pos != (mgl32.Vec3{}) { + t.Fatalf("origin teleport result=%+v pos=%v", result, state.Pos) + } +} + +func TestLegacyPendingTeleportKeepsExplicitTarget(t *testing.T) { + state := newBaseState() + state.PendingTeleports = 1 + state.TeleportPos = mgl32.Vec3{10, 20, 30} + + result := (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport || state.Pos != state.TeleportPos { + t.Fatalf("legacy teleport result=%+v pos=%v target=%v", result, state.Pos, state.TeleportPos) + } +} + func TestGlideAtVerticalPitchRemainsFinite(t *testing.T) { state := newBaseState() state.Gliding = true @@ -163,6 +211,21 @@ func TestGlideAtVerticalPitchRemainsFinite(t *testing.T) { } } +func TestGlideNearVerticalPitchDoesNotExplode(t *testing.T) { + state := newBaseState() + state.Gliding = true + state.OnGround = false + state.Rotation = mgl32.Vec3{-89.999, 0, 0} + state.Vel = mgl32.Vec3{1, 0, 0} + + (&Simulator{World: mockWorld{}, Inventory: mockInventory{hasElytra: true}}).SimulateState(state) + for axis, value := range state.Vel { + if !finiteFloat(value) || math32.Abs(value) > 10 { + t.Fatalf("near-vertical glide velocity axis %d = %v", axis, value) + } + } +} + func TestShallowLiquidBelowPlayerIsNotContact(t *testing.T) { w := newLiquidWorld().set(cube.Pos{0, 0, 0}, block.Water{Depth: 0, Still: true}) sim := newLiquidSim(w) @@ -184,6 +247,16 @@ func TestMovementChecksSweptChunks(t *testing.T) { } } +func TestMovementRejectsOutOfRangeSweep(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{math32.MaxFloat32, 0, 0} + + result := (&Simulator{World: selectiveChunkWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("out-of-range sweep outcome = %v, want unloaded chunk", result.Outcome) + } +} + func TestUnloadedTickDoesNotCommitPoseChanges(t *testing.T) { state := newBaseState() state.Pos = mgl32.Vec3{16.5, 0, 0.5} @@ -217,6 +290,22 @@ func TestAdjacentClimbableContactIsDetected(t *testing.T) { } } +func TestStandingOnClimbableBlockDoesNotEnableClimbing(t *testing.T) { + pos := cube.Pos{0, -1, 0} + w := environmentWorld{ + solids: map[cube.Pos]bool{pos: true}, + blocks: map[cube.Pos]world.Block{pos: semanticsNamedBlock{name: "minecraft:ladder"}}, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.EffectiveJumping = true + + (&Simulator{World: w}).SimulateState(state) + if math32.Abs(state.Vel.Y()-ClimbSpeed) < 1e-6 { + t.Fatalf("standing on a climbable block enabled climbing: %v", state.Vel) + } +} + func TestClimbableBlockBelowIsNotContact(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{ {0, -1, 0}: block.Ladder{Facing: cube.West}, @@ -233,6 +322,45 @@ func TestClimbableBlockBelowIsNotContact(t *testing.T) { } } +func TestPowderSnowSupportDoesNotEnableTraversal(t *testing.T) { + pos := cube.Pos{0, 0, 0} + w := &dynamicCollisionWorld{environmentWorld: environmentWorld{ + solids: map[cube.Pos]bool{pos: true}, + blocks: map[cube.Pos]world.Block{pos: semanticsNamedBlock{name: "minecraft:powder_snow"}}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 1, 0.5} + state.OnGround = true + state.PressingAscend = true + + (&Simulator{World: w, Equipment: leatherEquipment{}}).SimulateState(state) + if math32.Abs(state.Vel.Y()-0.2) < 1e-6 { + t.Fatalf("powder snow below the player enabled traversal: %v", state.Vel) + } +} + +func TestDynamicCollisionProviderKeepsStaticSupportFallback(t *testing.T) { + pos := cube.Pos{0, 0, 0} + w := &dynamicCollisionWorld{environmentWorld: environmentWorld{solids: map[cube.Pos]bool{pos: true}}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 1, 0.5} + state.OnGround = true + + (&Simulator{World: w}).checkSupportingBlockPos(state, false, mgl32.Vec3{}) + if state.SupportingBlockPos == nil || *state.SupportingBlockPos != pos { + t.Fatalf("dynamic collision provider lost support block: %v", state.SupportingBlockPos) + } +} + +func TestFilteredCollisionBoxesPreserveProviderOrder(t *testing.T) { + first := cube.Box32(2, 0, 0, 3, 1, 1) + second := cube.Box32(1, 0, 0, 2, 1, 1) + got := filteredCollisionBoxes([]cube.BBox32{first, cube.Box32(0, 0, 0, 0, 1, 1), second}) + if len(got) != 2 || got[0] != first || got[1] != second { + t.Fatalf("collision order changed while filtering: %v", got) + } +} + type selectiveChunkWorld struct{} func (selectiveChunkWorld) Block(cube.Pos) world.Block { return block.Air{} } diff --git a/simulation.go b/simulation.go index f0de852..ef07723 100644 --- a/simulation.go +++ b/simulation.go @@ -2,7 +2,6 @@ package bedsim import ( "iter" - "sort" "github.com/chewxy/math32" @@ -35,6 +34,7 @@ func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationR if s.Options.SprintTiming == SprintTimingLegacy { s.applyLegacySprint(state, input) } + state.AirSpeed = effectiveAirSpeed(state) s.tickState(state) return s.resultFromState(state, reason) } @@ -204,23 +204,18 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { if startFlag && stopFlag { needsSpeedAdjusted = isModernSprint state.Sprinting = false - state.AirSpeed = 0.02 } else if !startFlag && !stopFlag && !state.ServerSprintApplied && state.ServerSprint != state.Sprinting { if state.ServerSprint { state.Sprinting = true - state.AirSpeed = 0.026 } else { state.Sprinting = false - state.AirSpeed = 0.02 } } else if startFlag { state.Sprinting = true needsSpeedAdjusted = isModernSprint - state.AirSpeed = 0.026 } else if stopFlag { state.Sprinting = false needsSpeedAdjusted = isModernSprint && !state.ServerUpdatedSpeed - state.AirSpeed = 0.02 } state.ServerSprintApplied = true @@ -231,6 +226,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.MovementSpeed *= 1.3 } } + state.AirSpeed = effectiveAirSpeed(state) wantSneak := input.SneakDown || input.StartSneaking if input.StopSneaking { @@ -393,6 +389,20 @@ func (s *Simulator) applyLegacySprint(state *MovementState, input InputState) { state.MovementSpeed *= 1.3 } } + state.AirSpeed = effectiveAirSpeed(state) +} + +func effectiveAirSpeed(state *MovementState) float32 { + if state.MovementSpeed > 0 { + return state.MovementSpeed * AirMovementSpeedMultiplier + } + if state.AirSpeed > 0 { + return state.AirSpeed + } + if state.Sprinting { + return 0.026 + } + return 0.02 } func (s *Simulator) tickState(state *MovementState) { @@ -536,7 +546,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { insideSemantics := s.blockMovementSemantics(s.blockAtPos(posFromVec3(state.Pos))) if insideSemantics.Traversal == movementblock.TraversalNone && state.SupportingBlockPos != nil { supportingSemantics := s.blockMovementSemantics(s.blockAtPos(*state.SupportingBlockPos)) - if supportingSemantics.Traversal != movementblock.TraversalNone { + if supportingSemantics.Traversal == movementblock.TraversalScaffolding { insideSemantics.Traversal = supportingSemantics.Traversal } } @@ -681,7 +691,10 @@ func (s *Simulator) resetToClient(state *MovementState) { func (s *Simulator) attemptTeleport(state *MovementState) bool { if state.PendingTeleports > 0 { - if state.TeleportPos == (mgl32.Vec3{}) || state.PendingTeleportPos != (mgl32.Vec3{}) { + // QueueTeleport marks TeleportPending before this path, which keeps an + // explicitly queued origin distinct from legacy callers that only set + // PendingTeleports and TeleportPos. + if state.TeleportPending || state.PendingTeleportPos != (mgl32.Vec3{}) { state.TeleportPos = state.PendingTeleportPos } state.TeleportPending = true @@ -698,25 +711,32 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { if state.PendingTeleports > 0 { state.PendingTeleports-- } + if state.PendingTeleports == 0 { + state.PendingTeleportPos = mgl32.Vec3{} + } state.TicksSinceTeleport = teleportCompleteTick(state.TeleportCompletionTicks) return true } posDelta := state.TeleportPos.Sub(state.Pos) - if remaining := state.RemainingTeleportTicks() + 1; remaining > 0 { - newPos := state.Pos.Add(posDelta.Mul(1.0 / float32(remaining))) - state.SetPos(newPos) - state.JumpDelay = 0 - if remaining == 1 { - state.TeleportPending = false - if state.PendingTeleports > 0 { - state.PendingTeleports-- - } - state.TicksSinceTeleport = teleportCompleteTick(state.TeleportCompletionTicks) + remaining := state.RemainingTeleportTicks() + if remaining < int(^uint(0)>>1) { + remaining++ + } + newPos := state.Pos.Add(posDelta.Mul(1.0 / float32(remaining))) + state.SetPos(newPos) + state.JumpDelay = 0 + if remaining == 1 { + state.TeleportPending = false + if state.PendingTeleports > 0 { + state.PendingTeleports-- } - return true + if state.PendingTeleports == 0 { + state.PendingTeleportPos = mgl32.Vec3{} + } + state.TicksSinceTeleport = teleportCompleteTick(state.TeleportCompletionTicks) } - return false + return true } func teleportCompleteTick(completionTicks uint64) uint64 { @@ -745,19 +765,19 @@ func (s *Simulator) simulateGlide(state *MovementState) { gravity := effectiveGravity(state, vel) vel[1] += -gravity + sqrPitchCos*(gravity*0.75) - if vel[1] < 0 && lookHz > 0 { + if vel[1] < 0 && lookHz > GlideHorizontalLookEpsilon { yAccel := vel[1] * -0.1 * sqrPitchCos vel[1] += yAccel vel[0] += lookX * yAccel / lookHz vel[2] += lookZ * yAccel / lookHz } - if pitch < 0 && lookHz > 0 { + if pitch < 0 && lookHz > GlideHorizontalLookEpsilon { yAccel := velHz * -pitchSin * 0.04 vel[1] += yAccel * 3.2 vel[0] -= lookX * yAccel / lookHz vel[2] -= lookZ * yAccel / lookHz } - if lookHz > 0 { + if lookHz > GlideHorizontalLookEpsilon { vel[0] += (lookX/lookHz*velHz - vel[0]) * 0.1 vel[2] += (lookZ/lookHz*velHz - vel[2]) * 0.1 } @@ -1303,18 +1323,13 @@ func (s *Simulator) movementAreaLoaded(aabb cube.BBox32) bool { if provider, ok := s.World.(MovementAreaProvider); ok { return provider.IsMovementAreaLoaded(aabb) } - min, max := aabb.Min(), aabb.Max() - minX, minZ := int32(math32.Floor(min.X()))>>4, int32(math32.Floor(min.Z()))>>4 - maxX, maxZ := int32(math32.Ceil(max.X())-1)>>4, int32(math32.Ceil(max.Z())-1)>>4 - if maxX < minX { - maxX = minX - } - if maxZ < minZ { - maxZ = minZ + minX, minZ, maxX, maxZ, ok := movementChunkRange(aabb) + if !ok { + return false } - for chunkX := minX; chunkX <= maxX; chunkX++ { - for chunkZ := minZ; chunkZ <= maxZ; chunkZ++ { - if !s.World.IsChunkLoaded(chunkX, chunkZ) { + for chunkX := int64(minX); chunkX <= int64(maxX); chunkX++ { + for chunkZ := int64(minZ); chunkZ <= int64(maxZ); chunkZ++ { + if !s.World.IsChunkLoaded(int32(chunkX), int32(chunkZ)) { return false } } @@ -1322,6 +1337,32 @@ func (s *Simulator) movementAreaLoaded(aabb cube.BBox32) bool { return true } +const ( + maxMovementChunkSpan int64 = 256 + minMovementBlockCoord float32 = -2147483648 + maxMovementBlockCoord float32 = 2147483520 +) + +func movementChunkRange(aabb cube.BBox32) (minX, minZ, maxX, maxZ int32, ok bool) { + min, max := aabb.Min(), aabb.Max() + minBlockX, minBlockZ := math32.Floor(min.X()), math32.Floor(min.Z()) + maxBlockX, maxBlockZ := math32.Ceil(max.X())-1, math32.Ceil(max.Z())-1 + for _, value := range []float32{minBlockX, minBlockZ, maxBlockX, maxBlockZ} { + if !finiteFloat(value) || value < minMovementBlockCoord || value > maxMovementBlockCoord { + return 0, 0, 0, 0, false + } + } + + minX, minZ = int32(minBlockX)>>4, int32(minBlockZ)>>4 + maxX, maxZ = int32(maxBlockX)>>4, int32(maxBlockZ)>>4 + spanX := int64(maxX) - int64(minX) + 1 + spanZ := int64(maxZ) - int64(minZ) + 1 + if spanX <= 0 || spanZ <= 0 || spanX > maxMovementChunkSpan || spanZ > maxMovementChunkSpan { + return 0, 0, 0, 0, false + } + return minX, minZ, maxX, maxZ, true +} + func (s *Simulator) checkSupportingBlockPos(state *MovementState, useSlideOffset bool, vel mgl32.Vec3) { if !state.OnGround { state.SupportingBlockPos = nil @@ -1349,10 +1390,6 @@ func (s *Simulator) findSupportingBlock(state *MovementState, bb cube.BBox32) { } return } - if _, dynamic := w.(MovementCollisionProvider); dynamic { - state.SupportingBlockPos = nil - return - } var blockPos *cube.Pos minDist := float32(math32.MaxFloat32 - 1) centerPos := posVec3(posFromVec3(state.Pos)).Add(mgl32.Vec3{0.5, 0.5, 0.5}) @@ -1395,9 +1432,9 @@ func (s *Simulator) nearbyBBoxes(state *MovementState, aabb cube.BBox32) []cube. return nil } if provider, ok := s.World.(MovementCollisionProvider); ok { - return sortedCollisionBoxes(provider.GetMovementBBoxes(aabb, s.movementCollisionContext(state))) + return filteredCollisionBoxes(provider.GetMovementBBoxes(aabb, s.movementCollisionContext(state))) } - return sortedCollisionBoxes(s.World.GetNearbyBBoxes(aabb)) + return filteredCollisionBoxes(s.World.GetNearbyBBoxes(aabb)) } func (s *Simulator) movementCollisionContext(state *MovementState) MovementCollisionContext { @@ -1410,35 +1447,21 @@ func (s *Simulator) movementCollisionContext(state *MovementState) MovementColli } } -func sortedCollisionBoxes(boxes []cube.BBox32) []cube.BBox32 { - if len(boxes) < 2 { - if len(boxes) == 1 && BBHasZeroVolume(boxes[0]) { - return nil - } - return boxes - } - filtered := make([]cube.BBox32, 0, len(boxes)) - for _, box := range boxes { +func filteredCollisionBoxes(boxes []cube.BBox32) []cube.BBox32 { + for i, box := range boxes { if !BBHasZeroVolume(box) { - filtered = append(filtered, box) - } - } - sort.SliceStable(filtered, func(i, j int) bool { - leftMin, rightMin := filtered[i].Min(), filtered[j].Min() - for axis := range 3 { - if leftMin[axis] != rightMin[axis] { - return leftMin[axis] < rightMin[axis] - } + continue } - leftMax, rightMax := filtered[i].Max(), filtered[j].Max() - for axis := range 3 { - if leftMax[axis] != rightMax[axis] { - return leftMax[axis] < rightMax[axis] + filtered := make([]cube.BBox32, 0, len(boxes)-1) + filtered = append(filtered, boxes[:i]...) + for _, remaining := range boxes[i+1:] { + if !BBHasZeroVolume(remaining) { + filtered = append(filtered, remaining) } } - return false - }) - return filtered + return filtered + } + return boxes } type nearbyBBoxProbe interface { From ff9b26f4893ee529c952f6b2deadf24ca0e46b6d Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Sat, 8 Aug 2026 01:23:49 -0400 Subject: [PATCH 06/23] fix: correct Riptide, air speed, and climbable parity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Riptide's launch is a one-shot impulse. Drop the dedicated per-tick Riptide movement mode, which re-applied the full directional impulse on each of the remaining 20 ticks and skipped gravity, drag, and friction entirely; a level III launch compounded to roughly 60 blocks/tick instead of decaying into an arc. The remaining ticks now decay through ordinary travel, which also restores the swim-water-grace bookkeeping the early return skipped. RiptideLevel existed only to preserve force across those extra applications and goes with it. Gate the launch impulse's vertical adjustment on being grounded rather than on water contact, and take the scaled branch on shallow water (in water, head clear) rather than on submersion. The adjustment exists to pre-compensate for the drag and gravity applied later in the same tick, so it is meaningless while airborne. Air acceleration is a fixed pair selected by the sprint flag; it does not scale with the movement-speed attribute the way ground and liquid speeds do. Deriving it as MovementSpeed * 0.2 happens to land on the right values at the default 0.1/0.13 but drifts for every player under Speed, Slowness, or a server-set movement attribute — on every airborne tick. Climbable contact is the single block cell the player occupies, not any climbable block their hitbox overlaps. Growing the bounding box made a ladder up to 0.35 blocks away count as contact, predicting climb velocity the client never applies. ClimbableContactProvider is retained for adapters whose orientation data lives outside the block registry, but it now replaces the built-in check instead of widening it. --- README.md | 10 ++++--- bubble.go | 36 +++++----------------- bubble_test.go | 7 ++--- constants.go | 8 ++++- movement.go | 5 +--- parity_regressions_test.go | 61 ++++++++++++++++++-------------------- simulation.go | 46 ++++++++++------------------ 7 files changed, 69 insertions(+), 104 deletions(-) diff --git a/README.md b/README.md index a2487ec..1fc0cd0 100644 --- a/README.md +++ b/README.md @@ -88,8 +88,9 @@ For reliable streaming-world simulation, implement `MovementAreaProvider` so a swept movement volume can be checked precisely. Without it, BedSim checks every chunk touched by the current bounding box and velocity. Implement `ClimbableContactProvider` when ladder/vine orientation is resolved outside the -block registry, and `MovementSupportProvider` when dynamic collision shapes -need to identify their supporting block. +block registry — it replaces the built-in single-cell check rather than adding +to it — and `MovementSupportProvider` when dynamic collision shapes need to +identify their supporting block. `MovementEquipmentProvider` supplies Depth Strider, Soul Speed, Swift Sneak, Riptide, and leather-boots checks. The legacy `DepthStriderProvider` inventory @@ -106,8 +107,9 @@ custom base jump velocity; zero keeps the default. `MovementState.MovementSpeed` and `DefaultMovementSpeed` are effective movement attribute values. Include active Speed or Slowness modifiers in those values; BedSim uses them directly and does not apply the same modifiers a second time. -`AirSpeed` is the effective air acceleration speed; `Simulate` derives it from -`MovementSpeed`, while `SimulateState` callers provide it with the current state. +`AirSpeed` is the air acceleration speed. It does not track the movement +attribute: `Simulate` sets it to `WalkAirSpeed` or `SprintAirSpeed` from the +sprint state, and `SimulateState` callers provide it with the current state. `JumpHeight` is output-only and derived during simulation; set `JumpStrength` when a custom base jump velocity is needed. diff --git a/bubble.go b/bubble.go index 440193e..a22b1bc 100644 --- a/bubble.go +++ b/bubble.go @@ -3,7 +3,6 @@ package bedsim import ( "github.com/chewxy/math32" - "github.com/df-mc/dragonfly/server/block" "github.com/df-mc/dragonfly/server/block/cube" "github.com/go-gl/mathgl/mgl32" ) @@ -88,12 +87,14 @@ func (s *Simulator) attemptRiptide(state *MovementState, touchingWater, headInWa } state.SetVel(state.Vel.Add(s.riptideImpulse(state, level, touchingWater, headInWater))) state.RiptideTicks = 20 - state.RiptideLevel = level state.RiptideCollision = false state.StartingSpinAttack = false return true } +// riptideImpulse returns the one-shot launch velocity for a spin attack. The +// grounded adjustment compensates for the drag and gravity the same tick will +// apply afterwards, so it is skipped entirely while airborne. func (s *Simulator) riptideImpulse(state *MovementState, level int, wasInWater, headInWater bool) mgl32.Vec3 { force := 0.75 * float32(level+1) pitch := state.Rotation.X() * math32.Pi / 180 @@ -102,11 +103,11 @@ func (s *Simulator) riptideImpulse(state *MovementState, level int, wasInWater, if length := direction.Len(); length > 0 { direction = direction.Mul(force / length) } - if wasInWater { - if headInWater { - direction[1] = direction[1] / 0.8 * NormalGravityMultiplier + if state.OnGround { + if wasInWater && !headInWater { + direction[1] = direction[1] / WaterDrag * NormalGravityMultiplier } else { - direction[1] += 0.08 + direction[1] += NormalGravity } } return direction @@ -119,32 +120,9 @@ func (s *Simulator) riptideHeadInWater(state *MovementState) bool { return ok && liquidWater.matches(liquid) && position.Y() < float32(pos.Y())+liquidHeight(liquid) } -func (s *Simulator) simulateRiptide(state *MovementState, wasInWater, headInWater bool) { - s.debugfIf(attemptKnockback(state), "knockback applied during riptide: %v", state.Vel) - level := state.RiptideLevel - if level <= 0 && s.Equipment != nil { - level = s.Equipment.EnchantmentLevel(EnchantmentRiptide) - } - if level > 0 { - state.SetVel(state.Vel.Add(s.riptideImpulse(state, level, wasInWater, headInWater))) - } - oldVel := state.Vel - oldOnGround := state.OnGround - oldY := state.Pos.Y() - state.OnGround = false - s.tryCollisions(state, false) - stopRiptideOnBlockCollision(state) - updateFallDistance(state, oldY) - state.SetMov(state.Vel) - s.setPostCollisionMotion(state, oldVel, oldOnGround, block.Air{}) - s.applyInsideBlockEffects(state) - s.applyBubbleColumns(state) -} - func stopRiptideOnBlockCollision(state *MovementState) { if state.RiptideTicks > 0 && (state.CollideX || state.CollideZ) { state.RiptideTicks = 0 - state.RiptideLevel = 0 state.RiptideCollision = false } } diff --git a/bubble_test.go b/bubble_test.go index b48a295..df40566 100644 --- a/bubble_test.go +++ b/bubble_test.go @@ -228,15 +228,14 @@ func TestRiptideStopsOnNormalMovementWallCollision(t *testing.T) { } } -func TestRiptideCollisionClearsLaunchLevel(t *testing.T) { +func TestRiptideCollisionClearsActiveAttack(t *testing.T) { state := newBaseState() state.RiptideTicks = 10 - state.RiptideLevel = 3 state.CollideX = true stopRiptideOnBlockCollision(state) - if state.RiptideTicks != 0 || state.RiptideLevel != 0 { - t.Fatalf("riptide collision left active launch state: ticks=%d level=%d", state.RiptideTicks, state.RiptideLevel) + if state.RiptideTicks != 0 { + t.Fatalf("riptide collision left active attack: ticks=%d", state.RiptideTicks) } } diff --git a/constants.go b/constants.go index 0e1c288..63c74ac 100644 --- a/constants.go +++ b/constants.go @@ -23,8 +23,14 @@ const ( DefaultUnderwaterMovementSpeed = float32(0.02) DefaultLavaMovementSpeed = float32(0.02) DefaultSwimSpeedMultiplier = float32(1) - AirMovementSpeedMultiplier = float32(0.2) GlideHorizontalLookEpsilon = float32(1e-4) + // WalkAirSpeed and SprintAirSpeed are the air acceleration pair vanilla + // selects on the sprint flag alone; neither scales with the movement + // attribute. + WalkAirSpeed = float32(0.02) + SprintAirSpeed = float32(0.026) + // WaterDrag is the ordinary horizontal water drag; sprinting uses 0.9. + WaterDrag = float32(0.8) DefaultPlayerHeightOffset = float32(1.62) SneakingPlayerHeightOffset = float32(1.27) diff --git a/movement.go b/movement.go index aff5080..2d9a1ab 100644 --- a/movement.go +++ b/movement.go @@ -121,10 +121,7 @@ type MovementState struct { Crawling bool TicksSinceCanSlowdown int RiptideTicks int - // RiptideLevel is captured when the spin attack starts. A non-zero value - // preserves the active attack's force if equipment changes mid-attack. - RiptideLevel int - StartingSpinAttack bool + StartingSpinAttack bool // RiptideReady is a one-tick trusted latch set after validating a charged // Riptide trident release. RiptideCollision is set after a server-observed // entity collision and authorizes the matching stop/reversal. diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 4fc8a77..5c4e884 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -75,50 +75,48 @@ func TestSimulateStateLeavesTransientInputForCaller(t *testing.T) { } } -func TestActiveRiptideAppliesImpulseWithoutOrdinaryPhysics(t *testing.T) { +func TestActiveRiptideRunsOrdinaryPhysics(t *testing.T) { state := newBaseState() state.RiptideTicks = 5 state.Vel = mgl32.Vec3{0, 0.8, 0} - state.Impulse = mgl32.Vec2{0, 1} state.Gravity = NormalGravity + state.HasGravity = true (&Simulator{World: mockWorld{}, Equipment: fixedEquipment{EnchantmentRiptide: 2}}).SimulateState(state) - if math32.Abs(state.Pos.Z()-2.25) > 1e-6 { - t.Fatalf("riptide tick did not apply directional displacement: %v", state.Pos) + if math32.Abs(state.Vel.Y()-0.8) <= 1e-6 { + t.Fatalf("riptide tick skipped gravity: %v", state.Vel) } - if math32.Abs(state.Vel.Y()-0.8) > 1e-6 || math32.Abs(state.Vel.Z()-2.25) > 1e-6 { - t.Fatalf("riptide tick applied ordinary acceleration: %v", state.Vel) + if state.Vel.Z() != 0 { + t.Fatalf("riptide tick re-applied its launch impulse: %v", state.Vel) } } -func TestActiveRiptideKeepsLaunchLevel(t *testing.T) { +func TestRiptideLaunchAppliesImpulseOnce(t *testing.T) { + sim := &Simulator{World: mockWorld{}, Equipment: fixedEquipment{EnchantmentRiptide: 2}} state := newBaseState() - state.RiptideTicks = 5 - state.RiptideLevel = 2 + state.RiptideInRain = true + state.RiptideReady = true + state.StartingSpinAttack = true - equipment := fixedEquipment{EnchantmentRiptide: 0} - (&Simulator{World: mockWorld{}, Equipment: equipment}).SimulateState(state) - if math32.Abs(state.Vel.Z()-2.25) > 1e-6 { - t.Fatalf("active Riptide did not keep its launch level: %v", state.Vel) + sim.SimulateState(state) + // The 2.25 impulse for level 2 decays through ordinary air friction the + // same tick, so the launch is observable but never the raw impulse. + launched := state.Vel.Z() + if math32.Abs(launched-2.25*DefaultAirFriction) > 1e-6 { + t.Fatalf("riptide launch velocity = %v, want %v", launched, 2.25*DefaultAirFriction) } -} - -func TestActiveRiptidePreservesAuthoritativeKnockback(t *testing.T) { - state := newBaseState() - state.RiptideTicks = 5 - state.RiptideLevel = 2 - state.QueueKnockback(mgl32.Vec3{4, 0, 0}) - (&Simulator{World: mockWorld{}, Equipment: fixedEquipment{}}).SimulateState(state) - if math32.Abs(state.Vel.X()-4) > 1e-6 || math32.Abs(state.Vel.Z()-2.25) > 1e-6 { - t.Fatalf("riptide did not preserve knockback plus impulse: %v", state.Vel) + state.RiptideReady = false + state.StartingSpinAttack = false + sim.SimulateState(state) + if state.Vel.Z() > launched { + t.Fatalf("riptide gained speed after its launch tick: %v", state.Vel.Z()) } } func TestActiveRiptideConsumesRetainedWaterGrace(t *testing.T) { state := newBaseState() state.RiptideTicks = 5 - state.RiptideLevel = 2 state.SwimWaterGraceTicks = 2 (&Simulator{World: mockWorld{}, Equipment: fixedEquipment{}}).SimulateState(state) @@ -142,7 +140,7 @@ func TestMovementSpeedUsesEffectiveAttribute(t *testing.T) { } } -func TestSimulateDerivesAirSpeedFromEffectiveMovementSpeed(t *testing.T) { +func TestAirSpeedIgnoresTheMovementAttribute(t *testing.T) { state := newBaseState() state.MovementSpeed = 0.2 state.DefaultMovementSpeed = 0.2 @@ -151,8 +149,8 @@ func TestSimulateDerivesAirSpeedFromEffectiveMovementSpeed(t *testing.T) { if math32.Abs(state.MovementSpeed-0.26) > 1e-6 { t.Fatalf("sprinting movement speed = %v, want 0.26", state.MovementSpeed) } - if math32.Abs(state.AirSpeed-0.052) > 1e-6 { - t.Fatalf("sprinting air speed = %v, want 0.052", state.AirSpeed) + if state.AirSpeed != SprintAirSpeed { + t.Fatalf("sprinting air speed = %v, want %v", state.AirSpeed, SprintAirSpeed) } } @@ -273,7 +271,7 @@ func TestUnloadedTickDoesNotCommitPoseChanges(t *testing.T) { } } -func TestAdjacentClimbableContactIsDetected(t *testing.T) { +func TestAdjacentClimbableIsNotContact(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{ {1, 0, 0}: block.Ladder{Facing: cube.West}, }} @@ -283,10 +281,9 @@ func TestAdjacentClimbableContactIsDetected(t *testing.T) { state.EffectiveJumping = true state.Gravity = NormalGravity - sim := &Simulator{World: w} - sim.SimulateState(state) - if state.Vel.Y() <= 0 { - t.Fatalf("adjacent ladder did not provide climb velocity: %v", state.Vel) + (&Simulator{World: w}).SimulateState(state) + if math32.Abs(state.Vel.Y()-ClimbSpeed) < 1e-6 { + t.Fatalf("a ladder the player only overlaps was treated as climbable contact: %v", state.Vel) } } diff --git a/simulation.go b/simulation.go index ef07723..b20c817 100644 --- a/simulation.go +++ b/simulation.go @@ -362,7 +362,6 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.StartingSpinAttack = input.StartSpinAttack if input.StopSpinAttack && state.RiptideTicks > 0 && state.RiptideCollision { state.RiptideTicks = 0 - state.RiptideLevel = 0 state.RiptideCollision = false state.SetVel(state.Vel.Mul(-0.2)) } @@ -392,17 +391,14 @@ func (s *Simulator) applyLegacySprint(state *MovementState, input InputState) { state.AirSpeed = effectiveAirSpeed(state) } +// effectiveAirSpeed returns the air acceleration for the current sprint state. +// Vanilla picks from a fixed pair here, so movement effects must not reach it +// the way they reach the ground and liquid speeds. func effectiveAirSpeed(state *MovementState) float32 { - if state.MovementSpeed > 0 { - return state.MovementSpeed * AirMovementSpeedMultiplier - } - if state.AirSpeed > 0 { - return state.AirSpeed - } if state.Sprinting { - return 0.026 + return SprintAirSpeed } - return 0.02 + return WalkAirSpeed } func (s *Simulator) tickState(state *MovementState) { @@ -428,7 +424,6 @@ func (s *Simulator) tickState(state *MovementState) { state.RiptideTicks-- if state.RiptideTicks == 0 { state.RiptideCollision = false - state.RiptideLevel = 0 } } state.JustDisabledFlight = false @@ -464,14 +459,11 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SwimWaterGraceTicks-- } }() - riptideLaunched := !state.Flying && s.attemptRiptide(state, inWater, s.riptideHeadInWater(state)) - if riptideLaunched { + // The launch is a one-shot impulse; the remaining Riptide ticks decay + // through ordinary travel rather than a dedicated movement mode. + if !state.Flying && s.attemptRiptide(state, inWater, s.riptideHeadInWater(state)) { s.debugf("riptide launch applied: %v", state.Vel) } - if !state.Flying && state.RiptideTicks > 0 && !riptideLaunched { - s.simulateRiptide(state, inWater, s.riptideHeadInWater(state)) - return - } // Observed lava takes precedence over retained water evidence. waterTravel := inWater || @@ -553,7 +545,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { leatherBoots := s.Equipment != nil && s.Equipment.WearingLeatherBoots() scaffoldDescend := applyAscendableMovement(state, insideSemantics.Traversal, leatherBoots) - nearClimbable := insideSemantics.Climbable || s.hasClimbableContact(state) + nearClimbable := s.climbableContact(state, insideSemantics.Climbable) if nearClimbable { newVel := state.Vel negClimbSpeed := -ClimbSpeed @@ -1297,23 +1289,17 @@ func nearbyBlocks(aabb cube.BBox32, w WorldProvider) iter.Seq2[cube.Pos, world.B } } -func (s *Simulator) hasClimbableContact(state *MovementState) bool { +// climbableContact reports ladder/vine contact. Vanilla tests the single block +// cell the player stands in, which insideClimbable already resolves; an adapter +// overrides only when orientation lives outside the block registry. +func (s *Simulator) climbableContact(state *MovementState, insideClimbable bool) bool { if s.World == nil { - return false + return insideClimbable } - box := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl32.Vec3{0.05, 0, 0.05}) if provider, ok := s.World.(ClimbableContactProvider); ok { - return provider.HasClimbableContact(box) + return provider.HasClimbableContact(state.BoundingBox(s.Options.UseSlideOffset)) } - for pos, blockAt := range nearbyBlocks(box, s.World) { - if !s.blockMovementSemantics(blockAt).Climbable { - continue - } - if box.IntersectsWith(cube.Box32(0, 0, 0, 1, 1, 1).Translate(posVec3(pos))) { - return true - } - } - return false + return insideClimbable } func (s *Simulator) movementAreaLoaded(aabb cube.BBox32) bool { From 6056b9c50786f7dae83e5eb0b2133788a680bab1 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Sat, 8 Aug 2026 01:42:01 -0400 Subject: [PATCH 07/23] docs: correct the AirSpeed field contract The struct comment still described the removed MovementSpeed derivation. --- movement.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/movement.go b/movement.go index 2d9a1ab..82f13f2 100644 --- a/movement.go +++ b/movement.go @@ -56,9 +56,9 @@ type MovementState struct { // DefaultMovementSpeed is the effective non-sprinting movement attribute // used when sprinting is toggled. DefaultMovementSpeed float32 - // AirSpeed is the effective air acceleration speed. Simulate derives it - // from MovementSpeed when the latter is set; SimulateState callers provide - // it as part of the current state. + // AirSpeed is the air acceleration speed, which does not track the movement + // attribute. Simulate sets it from the sprint state; SimulateState callers + // provide it as part of the current state. AirSpeed float32 UnderwaterMovementSpeed float32 LavaMovementSpeed float32 From 28f7e798b5edb294badff692ce9b6b3ee082d642 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 17:57:24 +0300 Subject: [PATCH 08/23] fix: align remaining movement parity behavior --- block_effects.go | 16 ++++++++++++++++ block_effects_test.go | 36 ++++++++++++++++++++++++++++++++++++ bubble.go | 3 +++ constants.go | 10 ++++------ liquid.go | 4 ++-- liquid_hardening_test.go | 14 ++++++++++++++ parity_regressions_test.go | 18 ++++++++++++++++++ parity_test.go | 11 +++++------ simulation.go | 14 +++++++++++++- validation.go | 5 +++++ 10 files changed, 116 insertions(+), 15 deletions(-) diff --git a/block_effects.go b/block_effects.go index 5878dd5..6b47dd8 100644 --- a/block_effects.go +++ b/block_effects.go @@ -47,6 +47,8 @@ func applyStuckSpeedMultiplier(state *MovementState) bool { return true } +// applyAscendableMovement applies input-driven vertical block traversal and +// reports whether ordinary vertical travel should be skipped. func applyAscendableMovement(state *MovementState, traversal movementblock.Traversal, leatherBoots bool) bool { velocity := state.Vel switch traversal { @@ -113,8 +115,22 @@ func (s *Simulator) applyHoneyWallSlide(state *MovementState) { velocity[1] = max(-0.12, velocity[1]) velocity[2] *= 0.4 state.SetVel(velocity) + if honeySlideResetsFallDistance(state, pos) { + state.FallDistance = 0 + } } } } } } + +// honeySlideResetsFallDistance reports whether contact is with a honey side +// rather than the top surface. +func honeySlideResetsFallDistance(state *MovementState, pos cube.Pos) bool { + if state.Vel.Y() >= 0 || state.Pos.Y() > float32(pos.Y())+0.9375 { + return false + } + radius := state.Size.X()*state.Size.Z()*0.5 + 0.43125 + centerX, centerZ := float32(pos.X())+0.5, float32(pos.Z())+0.5 + return math32.Abs(centerX-state.Pos.X()) > radius || math32.Abs(centerZ-state.Pos.Z()) > radius +} diff --git a/block_effects_test.go b/block_effects_test.go index 27313fa..26e9885 100644 --- a/block_effects_test.go +++ b/block_effects_test.go @@ -145,6 +145,38 @@ func TestHoneyWallSlideAppliesOnSolidSideContact(t *testing.T) { } } +func TestHoneySideSlideResetsFallDistance(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:honey_block"}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{1.25, 0, 0.5} + state.Vel = mgl32.Vec3{0, -0.2, 0} + state.FallDistance = 4 + + (&Simulator{World: w, BlockSemantics: encodedBlockSemantics{}}).applyHoneyWallSlide(state) + + if state.FallDistance != 0 { + t.Fatalf("honey side slide left fall distance = %v", state.FallDistance) + } +} + +func TestHoneyTopContactPreservesFallDistance(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:honey_block"}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Vel = mgl32.Vec3{0, -0.2, 0} + state.FallDistance = 4 + + (&Simulator{World: w, BlockSemantics: encodedBlockSemantics{}}).applyHoneyWallSlide(state) + + if state.FallDistance != 4 { + t.Fatalf("honey top contact changed fall distance to %v", state.FallDistance) + } +} + func TestScaffoldingAscendAndDescendSpeeds(t *testing.T) { state := newBaseState() state.PressingAscend = true @@ -214,10 +246,14 @@ func TestScaffoldingDescendSkipsAirGravity(t *testing.T) { state.Gravity = NormalGravity state.HasGravity = true state.PressingDescend = true + state.FallDistance = 4 sim.SimulateState(state) if math32.Abs(state.Vel.Y()-(-0.15)) > 1e-6 { t.Fatalf("scaffolding descent velocity = %v, want %v", state.Vel.Y(), -0.15) } + if state.FallDistance != 0 { + t.Fatalf("scaffolding descent left fall distance = %v", state.FallDistance) + } } func TestScaffoldingSupportEnablesDescent(t *testing.T) { diff --git a/bubble.go b/bubble.go index 24cad40..0975a73 100644 --- a/bubble.go +++ b/bubble.go @@ -83,6 +83,7 @@ func (s *Simulator) applyBubbleColumns(state *MovementState) { state.FallDistance = 0 } +// attemptRiptide applies a validated one-shot Riptide launch. func (s *Simulator) attemptRiptide(state *MovementState, touchingWater, headInWater bool) bool { if s.Equipment == nil || state.InVehicle || state.RiptideTicks > 0 || !state.RiptideReady || (!touchingWater && !state.RiptideInRain) { return false @@ -119,6 +120,8 @@ func (s *Simulator) riptideImpulse(state *MovementState, level int, wasInWater, return direction } +// riptideHeadInWater reports whether the player's head is below the local +// water surface. func (s *Simulator) riptideHeadInWater(state *MovementState) bool { position := state.Pos.Add(mgl32.Vec3{0, DefaultPlayerHeightOffset, 0}) pos := posFromVec3(position) diff --git a/constants.go b/constants.go index b807081..cacaf18 100644 --- a/constants.go +++ b/constants.go @@ -11,13 +11,11 @@ const ( StepHeight = float32(0.5625) SlideOffsetMultiplier = float32(0.4) SlimeBounceMultiplier = float32(-1) - BedBounceMultiplier = float32(-0.66) - // BedBounceCap bounds the upward bounce velocity. - BedBounceCap = float32(1) + BedBounceMultiplier = float32(-0.75) // This can be validated in Mob::ascendLadder(). - ClimbSpeed = float32(0.2) - MaxConsumingImpulse = float32(0.1225) - MaxSneakImpulse = float32(0.3) + ClimbSpeed = float32(0.2) + MaxConsumingImpulse = float32(0.1225) + MaxSneakImpulse = float32(0.3) DefaultUnderwaterMovementSpeed = float32(0.02) DefaultLavaMovementSpeed = float32(0.02) DefaultSwimSpeedMultiplier = float32(1) diff --git a/liquid.go b/liquid.go index 3f36d0a..3f1a2d1 100644 --- a/liquid.go +++ b/liquid.go @@ -302,6 +302,7 @@ func liquidHeight(liquid world.Liquid) float32 { return float32(liquid.LiquidDepth()+1) / 9 } +// liquidIntersects reports whether box reaches the liquid surface in pos. func liquidIntersects(box cube.BBox32, pos cube.Pos, liquid world.Liquid) bool { surface := float32(pos[1]) + liquidHeight(liquid) return box.Max().Y() > float32(pos[1]) && box.Min().Y() < surface @@ -384,8 +385,7 @@ func (s *Simulator) liquidFlow(pos cube.Pos, liquid world.Liquid) mgl32.Vec3 { } func (s *Simulator) liquidFlowSideClosed(pos, side cube.Pos) bool { - stairs, ok := s.blockAtPos(pos).(block.Stairs) - return ok && stairs.Model().FaceSolid(pos, pos.Face(side), s.World) + return s.blockAtPos(pos).Model().FaceSolid(pos, pos.Face(side), s.World) } func liquidDecay(liquid world.Liquid) int { diff --git a/liquid_hardening_test.go b/liquid_hardening_test.go index caf3699..5628702 100644 --- a/liquid_hardening_test.go +++ b/liquid_hardening_test.go @@ -507,6 +507,20 @@ func TestStairsSolidFaceBlocksFlow(t *testing.T) { } } +func TestWaterloggedTrapdoorSolidFaceBlocksFlow(t *testing.T) { + pos := cube.Pos{0, 0, 0} + neighbour := cube.Pos{1, 0, 0} + w := newLayeredLiquidWorld() + w.waterlog(pos, block.WoodTrapdoor{Facing: cube.West, Open: true}, block.Water{Depth: 8}) + w.set(neighbour, block.Water{Depth: 4}) + + flow := newLiquidSim(w).liquidFlow(pos, block.Water{Depth: 8}) + + if !approxEqual(flow.X(), 0) { + t.Fatalf("waterlogged trapdoor solid face allowed flow X = %v", flow.X()) + } +} + // A simulator with no world must not panic on any liquid path. func TestNilWorldIsSafe(t *testing.T) { sim := &Simulator{Options: SimulationOptions{PositionCorrectionThreshold: 0.3}} diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 5c4e884..4384d42 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -287,6 +287,24 @@ func TestAdjacentClimbableIsNotContact(t *testing.T) { } } +func TestClimbableContactResetsFallDistance(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: block.Ladder{Facing: cube.West}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl32.Vec3{0, -0.1, 0} + state.FallDistance = 4 + state.HasGravity = false + + (&Simulator{World: w}).SimulateState(state) + + if state.FallDistance != 0 { + t.Fatalf("climbable contact left fall distance = %v", state.FallDistance) + } +} + func TestStandingOnClimbableBlockDoesNotEnableClimbing(t *testing.T) { pos := cube.Pos{0, -1, 0} w := environmentWorld{ diff --git a/parity_test.go b/parity_test.go index fdbf3ea..c3092dd 100644 --- a/parity_test.go +++ b/parity_test.go @@ -61,22 +61,21 @@ func TestBedrockStepHeight(t *testing.T) { } } -func TestBedBounceUsesCorroboratedRestitutionAndCap(t *testing.T) { +func TestBedBounceUsesVanillaRestitutionWithoutCap(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{}) - // -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()) + if want := float32(1.5); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected 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()) + if want := float32(0.75); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected bed bounce %v, got %v", want, state.Vel.Y()) } } diff --git a/simulation.go b/simulation.go index 5cbfa35..6432062 100644 --- a/simulation.go +++ b/simulation.go @@ -39,6 +39,7 @@ func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationR return s.resultFromState(state, reason) } +// movementPoseSnapshot preserves pose fields across an unloaded simulation. type movementPoseSnapshot struct { size mgl32.Vec3 sneaking bool @@ -47,6 +48,7 @@ type movementPoseSnapshot struct { swimAmt float32 } +// restore replaces the state's pose fields with the snapshot. func (p movementPoseSnapshot) restore(state *MovementState) { state.Size = p.size state.Sneaking = p.sneaking @@ -79,6 +81,7 @@ func (s *Simulator) debugfIf(cond bool, format string, args ...any) { } } +// invalidSimulationResult returns the mode-aware result for invalid state. func (s *Simulator) invalidSimulationResult() SimulationResult { return SimulationResult{ Outcome: SimulationOutcomeInvalidInput, @@ -588,6 +591,9 @@ func (s *Simulator) simulateMovement(state *MovementState) { s.tryCollisions(state, clientJumpPrevented) stopRiptideOnBlockCollision(state) updateFallDistance(state, oldY) + if scaffoldDescend || nearClimbable { + state.FallDistance = 0 + } if state.SupportingBlockPos != nil { blockUnder = s.blockAtPos(*state.SupportingBlockPos) @@ -731,6 +737,8 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { return true } +// teleportCompleteTick returns the first tick after a teleport's completion +// window without overflowing the counter. func teleportCompleteTick(completionTicks uint64) uint64 { if completionTicks == math32.MaxUint64 { return completionTicks @@ -826,7 +834,7 @@ func (s *Simulator) landOnBlock(state *MovementState, old mgl32.Vec3, blockUnder newVel[1] = 0.0 } case movementblock.BounceBed: - newVel[1] = math32.Min(BedBounceCap, BedBounceMultiplier*old.Y()) + newVel[1] = BedBounceMultiplier * old.Y() default: newVel[1] = 0 } @@ -1290,6 +1298,7 @@ func (s *Simulator) climbableContact(state *MovementState, insideClimbable bool) return insideClimbable } +// movementAreaLoaded reports whether the complete movement volume is known. func (s *Simulator) movementAreaLoaded(aabb cube.BBox32) bool { if s.World == nil { return true @@ -1317,6 +1326,7 @@ const ( maxMovementBlockCoord float32 = 2147483520 ) +// movementChunkRange returns a bounded chunk range for a movement volume. func movementChunkRange(aabb cube.BBox32) (minX, minZ, maxX, maxZ int32, ok bool) { min, max := aabb.Min(), aabb.Max() minBlockX, minBlockZ := math32.Floor(min.X()), math32.Floor(min.Z()) @@ -1411,6 +1421,7 @@ func (s *Simulator) nearbyBBoxes(state *MovementState, aabb cube.BBox32) []cube. return filteredCollisionBoxes(s.World.GetNearbyBBoxes(aabb)) } +// movementCollisionContext builds the dynamic collision context for state. func (s *Simulator) movementCollisionContext(state *MovementState) MovementCollisionContext { return MovementCollisionContext{ Position: [3]float32(state.Pos), @@ -1421,6 +1432,7 @@ func (s *Simulator) movementCollisionContext(state *MovementState) MovementColli } } +// filteredCollisionBoxes removes invalid boxes without reordering valid ones. func filteredCollisionBoxes(boxes []cube.BBox32) []cube.BBox32 { for i, box := range boxes { if !BBHasZeroVolume(box) { diff --git a/validation.go b/validation.go index 480643e..6a2b10a 100644 --- a/validation.go +++ b/validation.go @@ -5,10 +5,12 @@ import ( "github.com/go-gl/mathgl/mgl32" ) +// finiteFloat reports whether value is neither NaN nor infinite. func finiteFloat(value float32) bool { return !math32.IsNaN(value) && !math32.IsInf(value, 0) } +// finiteVec2 reports whether every component is finite. func finiteVec2(value mgl32.Vec2) bool { for axis := range 2 { if !finiteFloat(value[axis]) { @@ -18,6 +20,7 @@ func finiteVec2(value mgl32.Vec2) bool { return true } +// finiteVec3 reports whether every component is finite. func finiteVec3(value mgl32.Vec3) bool { for axis := range 3 { if !finiteFloat(value[axis]) { @@ -27,6 +30,7 @@ func finiteVec3(value mgl32.Vec3) bool { return true } +// finiteInput reports whether all numeric input fields are finite. func finiteInput(input InputState) bool { return finiteVec2(input.MoveVector) && finiteVec3(input.ClientPos) && @@ -34,6 +38,7 @@ func finiteInput(input InputState) bool { finiteFloat(input.Pitch) && finiteFloat(input.Yaw) && finiteFloat(input.HeadYaw) } +// finiteMovementState reports whether all simulated numeric state is finite. func finiteMovementState(state *MovementState) bool { if state == nil { return false From 40f011e8a35bc4ba378f892cb7c0390e5aede12e Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 18:14:04 +0300 Subject: [PATCH 09/23] fix: close review-found movement state gaps --- parity_regressions_test.go | 30 ++++++++++++++++++ simulation.go | 13 +++++--- validation_test.go | 64 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 validation_test.go diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 4384d42..20f5d7e 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -245,6 +245,36 @@ func TestMovementChecksSweptChunks(t *testing.T) { } } +func TestQueuedKnockbackChecksSweptChunks(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{15.5, 0, 0.5} + state.Client.Pos = state.Pos + state.QueueKnockback(mgl32.Vec3{1, 0, 0}) + + result := (&Simulator{World: selectiveChunkWorld{}}).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for queued knockback", result.Outcome) + } + if state.Pos != state.Client.Pos { + t.Fatalf("queued knockback moved into unloaded area: %v", state.Pos) + } +} + +func TestCompletedTeleportCounterAdvancesOnce(t *testing.T) { + state := newBaseState() + state.QueueTeleport(mgl32.Vec3{10, 20, 30}, false, 0) + + result := (&Simulator{World: mockWorld{}}).Simulate(state, InputState{}) + + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("outcome = %v, want teleport", result.Outcome) + } + if state.TicksSinceTeleport != 1 { + t.Fatalf("completed teleport tick counter = %d, want 1", state.TicksSinceTeleport) + } +} + func TestMovementRejectsOutOfRangeSweep(t *testing.T) { state := newBaseState() state.Pos = mgl32.Vec3{math32.MaxFloat32, 0, 0} diff --git a/simulation.go b/simulation.go index 0443308..285e44c 100644 --- a/simulation.go +++ b/simulation.go @@ -35,7 +35,8 @@ func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationR s.applyLegacySprint(state, input) } state.AirSpeed = effectiveAirSpeed(state) - s.tickState(state) + advanceTeleport := reason != SimulationOutcomeTeleport || state.HasTeleport() + s.tickState(state, advanceTeleport) return s.resultFromState(state, reason) } @@ -109,7 +110,11 @@ func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) Si s.resetToClient(state) return SimulationOutcomeUnreliable } - if s.World != nil && !s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset).Extend(state.Vel)) { + sweepVelocity := state.Vel + if state.HasKnockback() { + sweepVelocity = state.Knockback + } + if s.World != nil && !s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset).Extend(sweepVelocity)) { state.SetVel(mgl32.Vec3{}) state.SwimWaterGraceTicks = 0 state.StuckSpeedMultiplier = mgl32.Vec3{} @@ -394,7 +399,7 @@ func effectiveAirSpeed(state *MovementState) float32 { return WalkAirSpeed } -func (s *Simulator) tickState(state *MovementState) { +func (s *Simulator) tickState(state *MovementState, advanceTeleport bool) { if state.GlideBoostTicks > 0 { state.GlideBoostTicks-- } @@ -407,7 +412,7 @@ func (s *Simulator) tickState(state *MovementState) { } state.TicksSinceKnockback++ state.KnockbackPending = false - if state.TicksSinceTeleport < math32.MaxUint64 { + if advanceTeleport && state.TicksSinceTeleport < math32.MaxUint64 { state.TicksSinceTeleport++ } if state.JumpDelay > 0 { diff --git a/validation_test.go b/validation_test.go new file mode 100644 index 0000000..580c61a --- /dev/null +++ b/validation_test.go @@ -0,0 +1,64 @@ +package bedsim + +import ( + "math" + "reflect" + "testing" + + "github.com/go-gl/mathgl/mgl32" +) + +type numericStateField struct { + name string + index []int + typ reflect.Type +} + +func TestFiniteMovementStateRejectsEveryFloatField(t *testing.T) { + base := newBaseState() + fields := collectNumericStateFields(reflect.TypeOf(*base), nil, "MovementState") + if len(fields) == 0 { + t.Fatal("no numeric movement fields discovered") + } + + for _, field := range fields { + t.Run(field.name, func(t *testing.T) { + state := *base + value := reflect.ValueOf(&state).Elem().FieldByIndex(field.index) + switch field.typ { + case reflect.TypeFor[float32](): + value.SetFloat(math.NaN()) + case reflect.TypeFor[mgl32.Vec2](), reflect.TypeFor[mgl32.Vec3](): + value.Index(0).SetFloat(math.NaN()) + default: + t.Fatalf("unsupported numeric field type %v", field.typ) + } + if finiteMovementState(&state) { + t.Fatalf("finiteMovementState accepted NaN in %s", field.name) + } + }) + } +} + +// collectNumericStateFields returns every float or movement-vector field in a +// movement state, including fields nested in value structs. +func collectNumericStateFields(t reflect.Type, prefix []int, name string) []numericStateField { + floatType := reflect.TypeFor[float32]() + vec2Type := reflect.TypeFor[mgl32.Vec2]() + vec3Type := reflect.TypeFor[mgl32.Vec3]() + fields := make([]numericStateField, 0) + for i := range t.NumField() { + field := t.Field(i) + index := append(append([]int(nil), prefix...), i) + fieldName := name + "." + field.Name + switch field.Type { + case floatType, vec2Type, vec3Type: + fields = append(fields, numericStateField{name: fieldName, index: index, typ: field.Type}) + default: + if field.Type.Kind() == reflect.Struct { + fields = append(fields, collectNumericStateFields(field.Type, index, fieldName)...) + } + } + } + return fields +} From 1c7022e74d303f00d75cdae6a00571a89dff0641 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 18:21:02 +0300 Subject: [PATCH 10/23] fix: enforce loaded movement and safe invalid results --- liquid.go | 6 +++- liquid_test.go | 13 +++++++ parity_regressions_test.go | 61 +++++++++++++++++++++++++++++++++ simulation.go | 70 ++++++++++++++++++++++++++++++-------- 4 files changed, 135 insertions(+), 15 deletions(-) diff --git a/liquid.go b/liquid.go index 162a77d..04aef22 100644 --- a/liquid.go +++ b/liquid.go @@ -44,7 +44,7 @@ var liquidFaces = [...]struct { {cube.Pos{0, 0, 1}, mgl32.Vec3{0, 0, 1}}, } -func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, touchingLiquid bool) { +func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, touchingLiquid bool) bool { initialY := state.Pos.Y() water := kind == liquidWater // Captured before updateSwimTravel, matching the upstream ordering. @@ -102,6 +102,9 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, } moveRelative(state, moveRelativeSpeed) stuckMovement := applyStuckSpeedMultiplier(state) + if !s.movementSweepLoaded(state) { + return false + } oldVel := state.Vel oldOnGround := state.OnGround s.tryCollisions(state, false) @@ -159,6 +162,7 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, s.applyBubbleColumns(state) s.applyInsideBlockEffects(state) state.FallDistance = 0 + return true } func liquidGravity(swimming, water bool) float32 { diff --git a/liquid_test.go b/liquid_test.go index 3e8897a..36f1e47 100644 --- a/liquid_test.go +++ b/liquid_test.go @@ -477,6 +477,19 @@ func TestStopSwimmingUsesFastWaterDragForOneTick(t *testing.T) { } } +func TestStopSwimmingFlagWithoutTransitionUsesNormalDrag(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = false + state.Vel = mgl32.Vec3{0.5, 0, 0} + + sim.Simulate(state, InputState{StopSwimming: true}) + + if !approxEqual(state.Vel.X(), 0.4) { + t.Fatalf("false stop-swimming drag = %v, want 0.4", state.Vel.X()) + } +} + // Gravity is skipped entirely when the state has no gravity. func TestNoGravityInLiquid(t *testing.T) { sim := newLiquidSim(filledColumn(waterSource)) diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 20f5d7e..3fb6db0 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -40,6 +40,22 @@ func TestSimulationRejectsNonFiniteInputAndState(t *testing.T) { } } +func TestInvalidInputResultPreservesAuthoritativeState(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{12, 64, 9} + state.Vel = mgl32.Vec3{0.1, 0.2, 0.3} + state.Mov = mgl32.Vec3{0.2, 0, 0} + + result := (&Simulator{}).Simulate(state, InputState{Pitch: math32.NaN()}) + + if result.Outcome != SimulationOutcomeInvalidInput { + t.Fatalf("outcome = %v, want invalid input", result.Outcome) + } + if result.Position != state.Pos || result.Velocity != state.Vel || result.Movement != state.Mov { + t.Fatalf("invalid input dropped authoritative state: result=%+v state=%+v", result, state) + } +} + func TestPassiveModeDoesNotRequestCorrectionForInvalidInput(t *testing.T) { result := (&Simulator{Options: SimulationOptions{Mode: SimulationModePassive}}).SimulateState(&MovementState{ Vel: mgl32.Vec3{math32.NaN(), 0, 0}, @@ -261,6 +277,36 @@ func TestQueuedKnockbackChecksSweptChunks(t *testing.T) { } } +func TestInputAccelerationChecksSweptChunks(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{15.69, 0, 0.5} + state.Client.Pos = state.Pos + state.HasGravity = false + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{MoveVector: mgl32.Vec2{1, 0}}) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for same-tick acceleration", result.Outcome) + } +} + +func TestRiptideLaunchChecksSweptChunks(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 15.5} + state.Client.Pos = state.Pos + state.RiptideInRain = true + state.RiptideReady = true + + result := (&Simulator{ + World: selectiveChunkWorld{}, + Equipment: fixedEquipment{EnchantmentRiptide: 2}, + }).Simulate(state, InputState{StartSpinAttack: true}) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for Riptide launch", result.Outcome) + } +} + func TestCompletedTeleportCounterAdvancesOnce(t *testing.T) { state := newBaseState() state.QueueTeleport(mgl32.Vec3{10, 20, 30}, false, 0) @@ -406,6 +452,21 @@ func TestFilteredCollisionBoxesPreserveProviderOrder(t *testing.T) { } } +func TestCollisionPresenceFiltersInvalidBoxes(t *testing.T) { + state := newBaseState() + sim := &Simulator{World: invalidCollisionWorld{}} + + if sim.hasNearbyBBoxes(state, state.BoundingBox(false)) { + t.Fatal("zero-volume collision box was reported as present") + } +} + +type invalidCollisionWorld struct{ mockWorld } + +func (invalidCollisionWorld) GetNearbyBBoxes(cube.BBox32) []cube.BBox32 { + return []cube.BBox32{cube.Box32(0, 0, 0, 0, 1, 1)} +} + type selectiveChunkWorld struct{} func (selectiveChunkWorld) Block(cube.Pos) world.Block { return block.Air{} } diff --git a/simulation.go b/simulation.go index 285e44c..4b1d33b 100644 --- a/simulation.go +++ b/simulation.go @@ -15,8 +15,11 @@ import ( // Simulate runs a movement simulation tick and returns the resulting state. func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationResult { - if state == nil || !finiteMovementState(state) || !finiteInput(input) { - return s.invalidSimulationResult() + if state == nil || !finiteMovementState(state) { + return s.invalidSimulationResult(nil) + } + if !finiteInput(input) { + return s.invalidSimulationResult(state) } pose := movementPoseSnapshot{ @@ -64,18 +67,32 @@ func (p movementPoseSnapshot) restore(state *MovementState) { // StoppedSwimmingThisTick themselves. func (s *Simulator) SimulateState(state *MovementState) SimulationResult { if state == nil || !finiteMovementState(state) { - return s.invalidSimulationResult() + return s.invalidSimulationResult(nil) } reason := s.simulateCore(state, false) return s.resultFromState(state, reason) } -// invalidSimulationResult returns the mode-aware result for invalid state. -func (s *Simulator) invalidSimulationResult() SimulationResult { - return SimulationResult{ +// invalidSimulationResult returns the mode-aware result for invalid data and +// preserves state when its numeric fields are safe to expose. +func (s *Simulator) invalidSimulationResult(state *MovementState) SimulationResult { + result := SimulationResult{ Outcome: SimulationOutcomeInvalidInput, NeedsCorrection: s == nil || s.Options.Mode != SimulationModePassive, } + if state == nil || !finiteMovementState(state) { + return result + } + result.Position = state.Pos + result.Velocity = state.Vel + result.Movement = state.Mov + result.OnGround = state.OnGround + result.CollideX = state.CollideX + result.CollideY = state.CollideY + result.CollideZ = state.CollideZ + result.PositionDelta = state.Pos.Sub(state.Client.Pos) + result.VelocityDelta = state.Vel.Sub(state.Client.Vel) + return result } func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) SimulationOutcome { @@ -129,7 +146,12 @@ func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) Si return SimulationOutcomeImmobileOrNotReady } - s.simulateMovement(state) + if !s.simulateMovement(state) { + state.SetVel(mgl32.Vec3{}) + state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} + return SimulationOutcomeUnloadedChunk + } return SimulationOutcomeNormal } @@ -280,7 +302,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } wasSwimming := state.Swimming - state.StoppedSwimmingThisTick = input.StopSwimming + state.StoppedSwimmingThisTick = wasSwimming && input.StopSwimming if input.StopSwimming { state.Swimming = false s.restorePoseAfterSwimming(state, poseCollisionsAvailable) @@ -428,7 +450,7 @@ func (s *Simulator) tickState(state *MovementState, advanceTeleport bool) { state.StoppedSwimmingThisTick = false } -func (s *Simulator) simulateMovement(state *MovementState) { +func (s *Simulator) simulateMovement(state *MovementState) bool { vel := state.Vel for axis := range 3 { if math32.Abs(vel[axis]) < 1e-8 { @@ -479,12 +501,16 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.GlideBoostTicks = 0 } s.applyLiquidFlow(state, waterBlocks, liquidWater) - s.simulateLiquidTravel(state, liquidWater, inWater) + if !s.simulateLiquidTravel(state, liquidWater, inWater) { + return false + } } else { s.applyLiquidFlow(state, lavaBlocks, liquidLava) - s.simulateLiquidTravel(state, liquidLava, true) + if !s.simulateLiquidTravel(state, liquidLava, true) { + return false + } } - return + return true } blockUnder := s.blockAtPos(posFromVec3(state.Pos.Sub(mgl32.Vec3{0, 0.5}))) @@ -512,6 +538,9 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.OnGround = false s.simulateGlide(state) stuckMovement := applyStuckSpeedMultiplier(state) + if !s.movementSweepLoaded(state) { + return false + } oldVel := state.Vel oldY := state.Pos.Y() s.tryCollisions(state, false) @@ -526,7 +555,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { } s.applyInsideBlockEffects(state) s.applyBubbleColumns(state) - return + return true } state.Gliding = false @@ -598,6 +627,9 @@ func (s *Simulator) simulateMovement(state *MovementState) { } stuckMovement := applyStuckSpeedMultiplier(state) + if !s.movementSweepLoaded(state) { + return false + } s.avoidEdge(state) oldVel := state.Vel @@ -664,6 +696,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SetVel(newVel) s.applyInsideBlockEffects(state) s.applyBubbleColumns(state) + return true } func (s *Simulator) simulationIsReliable(state *MovementState) bool { @@ -976,6 +1009,9 @@ func (s *Simulator) isJumpBlocked(state *MovementState, jumpVel mgl32.Vec3) bool } useSlideOffset := s.Options.UseSlideOffset collisionBB := state.BoundingBox(useSlideOffset) + if !s.movementAreaLoaded(collisionBB.Extend(jumpVel)) { + return false + } bbList := s.nearbyBBoxes(state, collisionBB.Extend(jumpVel)) yVel := mgl32.Vec3{0, jumpVel.Y()} @@ -1393,6 +1429,12 @@ func (s *Simulator) movementAreaLoaded(aabb cube.BBox32) bool { return true } +// movementSweepLoaded reports whether the world contains the displacement +// produced after all same-tick acceleration has been applied. +func (s *Simulator) movementSweepLoaded(state *MovementState) bool { + return s.World == nil || s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset).Extend(state.Vel)) +} + const ( maxMovementChunkSpan int64 = 256 minMovementBlockCoord float32 = -2147483648 @@ -1537,7 +1579,7 @@ func (s *Simulator) hasNearbyBBoxes(state *MovementState, aabb cube.BBox32) bool if probe, ok := s.World.(nearbyBBoxProbe); ok { return probe.HasNearbyBBoxes(aabb) } - return len(s.World.GetNearbyBBoxes(aabb)) > 0 + return len(filteredCollisionBoxes(s.World.GetNearbyBBoxes(aabb))) > 0 } func (s *Simulator) canFitHeight(state *MovementState, height float32) bool { From 11a38964927745e4e614f92149b2d4a3632b77e0 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 18:27:53 +0300 Subject: [PATCH 11/23] fix: roll back unloaded physics attempts --- parity_regressions_test.go | 6 ++++++ simulation.go | 26 ++++++++++++++++---------- 2 files changed, 22 insertions(+), 10 deletions(-) diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 3fb6db0..9562eb0 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -305,6 +305,12 @@ func TestRiptideLaunchChecksSweptChunks(t *testing.T) { if result.Outcome != SimulationOutcomeUnloadedChunk { t.Fatalf("outcome = %v, want unloaded chunk for Riptide launch", result.Outcome) } + if state.RiptideTicks != 0 || !state.RiptideReady || !state.StartingSpinAttack { + t.Fatalf("unloaded Riptide consumed launch state: ticks=%d ready=%v starting=%v", state.RiptideTicks, state.RiptideReady, state.StartingSpinAttack) + } + if state.TicksSinceKnockback != 1 { + t.Fatalf("unloaded Riptide advanced tick counters: knockback=%d", state.TicksSinceKnockback) + } } func TestCompletedTeleportCounterAdvancesOnce(t *testing.T) { diff --git a/simulation.go b/simulation.go index 4b1d33b..875b3de 100644 --- a/simulation.go +++ b/simulation.go @@ -33,13 +33,14 @@ func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationR reason := s.simulateCore(state, true) if reason == SimulationOutcomeUnloadedChunk { pose.restore(state) + } else { + if s.Options.SprintTiming == SprintTimingLegacy { + s.applyLegacySprint(state, input) + } + state.AirSpeed = effectiveAirSpeed(state) + advanceTeleport := reason != SimulationOutcomeTeleport || state.HasTeleport() + s.tickState(state, advanceTeleport) } - if s.Options.SprintTiming == SprintTimingLegacy { - s.applyLegacySprint(state, input) - } - state.AirSpeed = effectiveAirSpeed(state) - advanceTeleport := reason != SimulationOutcomeTeleport || state.HasTeleport() - s.tickState(state, advanceTeleport) return s.resultFromState(state, reason) } @@ -97,11 +98,12 @@ func (s *Simulator) invalidSimulationResult(state *MovementState) SimulationResu func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) SimulationOutcome { state.ensurePoseHeights() - if consumeTransient { - defer func() { + clearRiptideReady := consumeTransient + defer func() { + if clearRiptideReady { state.RiptideReady = false - }() - } + } + }() teleported := s.attemptTeleport(state) if teleported { // A teleport relocates the player without observing the destination, @@ -132,6 +134,7 @@ func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) Si sweepVelocity = state.Knockback } if s.World != nil && !s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset).Extend(sweepVelocity)) { + clearRiptideReady = false state.SetVel(mgl32.Vec3{}) state.SwimWaterGraceTicks = 0 state.StuckSpeedMultiplier = mgl32.Vec3{} @@ -146,7 +149,10 @@ func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) Si return SimulationOutcomeImmobileOrNotReady } + prePhysics := *state if !s.simulateMovement(state) { + *state = prePhysics + clearRiptideReady = false state.SetVel(mgl32.Vec3{}) state.SwimWaterGraceTicks = 0 state.StuckSpeedMultiplier = mgl32.Vec3{} From 5f1d6ca664de2424222a279cee4006ef32071277 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 18:35:53 +0300 Subject: [PATCH 12/23] fix: preserve unloaded transition semantics --- parity_regressions_test.go | 42 ++++++++++++++++++++++++++++++++++++++ simulation.go | 24 ++++++++++++++-------- 2 files changed, 58 insertions(+), 8 deletions(-) diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 9562eb0..65ffb55 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -261,6 +261,48 @@ func TestMovementChecksSweptChunks(t *testing.T) { } } +func TestImmobileMovementDoesNotCheckUnappliedSweep(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{15.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl32.Vec3{1, 0, 0} + state.Immobile = true + state.RiptideReady = true + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{ClientPos: state.Pos}) + + if result.Outcome != SimulationOutcomeImmobileOrNotReady { + t.Fatalf("outcome = %v, want immobile/not ready", result.Outcome) + } + if state.Vel != (mgl32.Vec3{}) { + t.Fatalf("immobile state retained stale velocity: %v", state.Vel) + } + if state.RiptideReady || state.TicksSinceKnockback != 2 { + t.Fatalf("immobile tick did not advance transient state: ready=%v knockback=%d", state.RiptideReady, state.TicksSinceKnockback) + } +} + +func TestLegacySprintTransitionUpdatesSpeedOnUnloadedTick(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{16.5, 0, 0.5} + state.Client.Pos = state.Pos + sim := &Simulator{ + World: selectiveChunkWorld{}, + Options: SimulationOptions{ + SprintTiming: SprintTimingLegacy, + }, + } + + result := sim.Simulate(state, InputState{StartSprinting: true, ClientPos: state.Pos}) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk", result.Outcome) + } + if !state.Sprinting || math32.Abs(state.MovementSpeed-0.13) > 1e-6 { + t.Fatalf("legacy sprint transition desynchronized state: sprinting=%v speed=%v", state.Sprinting, state.MovementSpeed) + } +} + func TestQueuedKnockbackChecksSweptChunks(t *testing.T) { state := newBaseState() state.Pos = mgl32.Vec3{15.5, 0, 0.5} diff --git a/simulation.go b/simulation.go index 875b3de..1c4d541 100644 --- a/simulation.go +++ b/simulation.go @@ -31,12 +31,12 @@ func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationR } s.applyInput(state, input) reason := s.simulateCore(state, true) + if s.Options.SprintTiming == SprintTimingLegacy { + s.applyLegacySprint(state, input) + } if reason == SimulationOutcomeUnloadedChunk { pose.restore(state) } else { - if s.Options.SprintTiming == SprintTimingLegacy { - s.applyLegacySprint(state, input) - } state.AirSpeed = effectiveAirSpeed(state) advanceTeleport := reason != SimulationOutcomeTeleport || state.HasTeleport() s.tickState(state, advanceTeleport) @@ -129,11 +129,8 @@ func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) Si s.resetToClient(state) return SimulationOutcomeUnreliable } - sweepVelocity := state.Vel - if state.HasKnockback() { - sweepVelocity = state.Knockback - } - if s.World != nil && !s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset).Extend(sweepVelocity)) { + currentArea := state.BoundingBox(s.Options.UseSlideOffset) + if s.World != nil && !s.movementAreaLoaded(currentArea) { clearRiptideReady = false state.SetVel(mgl32.Vec3{}) state.SwimWaterGraceTicks = 0 @@ -148,6 +145,17 @@ func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) Si state.StuckSpeedMultiplier = mgl32.Vec3{} return SimulationOutcomeImmobileOrNotReady } + sweepVelocity := state.Vel + if state.HasKnockback() { + sweepVelocity = state.Knockback + } + if s.World != nil && !s.movementAreaLoaded(currentArea.Extend(sweepVelocity)) { + clearRiptideReady = false + state.SetVel(mgl32.Vec3{}) + state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} + return SimulationOutcomeUnloadedChunk + } prePhysics := *state if !s.simulateMovement(state) { From ce2d11e8bce8d6fe7f766aa2ebe0cc5850dfd6ff Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 18:43:22 +0300 Subject: [PATCH 13/23] fix: close movement boundary states --- liquid.go | 4 ++- movement.go | 7 ++++++ parity_regressions_test.go | 50 ++++++++++++++++++++++++++++++++++++++ simulation.go | 20 ++++++++++++--- 4 files changed, 76 insertions(+), 5 deletions(-) diff --git a/liquid.go b/liquid.go index 04aef22..8356eb1 100644 --- a/liquid.go +++ b/liquid.go @@ -107,7 +107,9 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, } oldVel := state.Vel oldOnGround := state.OnGround - s.tryCollisions(state, false) + if !s.tryCollisions(state, false) { + return false + } stopRiptideOnBlockCollision(state) if stuckMovement { state.SetMov(state.Vel) diff --git a/movement.go b/movement.go index 82f13f2..e161d60 100644 --- a/movement.go +++ b/movement.go @@ -80,6 +80,9 @@ type MovementState struct { TeleportCompletionTicks uint64 TeleportIsSmoothed bool TeleportPending bool + // TeleportCompleted distinguishes a finished teleport from legacy timer + // state that still falls within its numeric completion window. + TeleportCompleted bool Sprinting, PressingSprint bool ServerSprint, ServerSprintApplied bool @@ -193,6 +196,9 @@ func (s *MovementState) HasTeleport() bool { if s.TeleportPending || s.PendingTeleports > 0 { return true } + if s.TeleportCompleted { + return false + } if s.TeleportCompletionTicks == 0 { return s.TicksSinceTeleport == 0 && s.TeleportPos != (mgl32.Vec3{}) } @@ -226,4 +232,5 @@ func (s *MovementState) QueueTeleport(pos mgl32.Vec3, smoothed bool, completionT s.TeleportCompletionTicks = completionTicks s.TicksSinceTeleport = 0 s.TeleportPending = true + s.TeleportCompleted = false } diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 65ffb55..7b991c2 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -199,6 +199,22 @@ func TestQueueTeleportCanTargetOrigin(t *testing.T) { } } +func TestHardTeleportAtMaximumCompletionTickFinishes(t *testing.T) { + state := newBaseState() + state.QueueTeleport(mgl32.Vec3{10, 20, 30}, false, math32.MaxUint64) + sim := &Simulator{World: mockWorld{}} + + first := sim.SimulateState(state) + second := sim.SimulateState(state) + + if first.Outcome != SimulationOutcomeTeleport { + t.Fatalf("first outcome = %v, want teleport", first.Outcome) + } + if state.HasTeleport() || second.Outcome == SimulationOutcomeTeleport { + t.Fatalf("completed maximum-window teleport remained active: active=%v second=%v", state.HasTeleport(), second.Outcome) + } +} + func TestLegacyPendingTeleportKeepsExplicitTarget(t *testing.T) { state := newBaseState() state.PendingTeleports = 1 @@ -261,6 +277,31 @@ func TestMovementChecksSweptChunks(t *testing.T) { } } +func TestMovementChecksStepProbeArea(t *testing.T) { + w := stepProbeWorld{staticWorld: staticWorld{ + chunkLoaded: true, + boxes: []cube.BBox32{cube.Box32(1, 0, 0, 2, 0.5, 1)}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl32.Vec3{1, 0, 0} + state.OnGround = true + state.HasGravity = false + + result := (&Simulator{ + World: w, + Options: SimulationOptions{IgnoreClientStepTiebreaker: true}, + }).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown step probe", result.Outcome) + } + if state.Pos != (mgl32.Vec3{0.5, 0, 0.5}) { + t.Fatalf("unknown step probe moved state to %v", state.Pos) + } +} + func TestImmobileMovementDoesNotCheckUnappliedSweep(t *testing.T) { state := newBaseState() state.Pos = mgl32.Vec3{15.5, 0, 0.5} @@ -526,3 +567,12 @@ func (selectiveChunkWorld) GetNearbyBBoxes(cube.BBox32) []cube.BBox32 { return n func (selectiveChunkWorld) IsChunkLoaded(chunkX, chunkZ int32) bool { return chunkX == 0 && chunkZ == 0 } + +type stepProbeWorld struct { + staticWorld +} + +// IsMovementAreaLoaded rejects collision probes above the ordinary movement sweep. +func (stepProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Max().Y() <= 1.81 +} diff --git a/simulation.go b/simulation.go index 1c4d541..0928c4d 100644 --- a/simulation.go +++ b/simulation.go @@ -557,7 +557,9 @@ func (s *Simulator) simulateMovement(state *MovementState) bool { } oldVel := state.Vel oldY := state.Pos.Y() - s.tryCollisions(state, false) + if !s.tryCollisions(state, false) { + return false + } stopRiptideOnBlockCollision(state) updateFallDistance(state, oldY) if debugf := s.Options.Debugf; debugf != nil { @@ -649,7 +651,9 @@ func (s *Simulator) simulateMovement(state *MovementState) bool { oldVel := state.Vel oldOnGround := state.OnGround oldY := state.Pos.Y() - s.tryCollisions(state, clientJumpPrevented) + if !s.tryCollisions(state, clientJumpPrevented) { + return false + } stopRiptideOnBlockCollision(state) updateFallDistance(state, oldY) if scaffoldDescend || nearClimbable { @@ -762,6 +766,7 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { state.TeleportPos = state.PendingTeleportPos } state.TeleportPending = true + state.TeleportCompleted = false } if !state.HasTeleport() { return false @@ -779,6 +784,7 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { state.PendingTeleportPos = mgl32.Vec3{} } state.TicksSinceTeleport = teleportCompleteTick(state.TeleportCompletionTicks) + state.TeleportCompleted = true return true } @@ -799,6 +805,7 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { state.PendingTeleportPos = mgl32.Vec3{} } state.TicksSinceTeleport = teleportCompleteTick(state.TeleportCompletionTicks) + state.TeleportCompleted = true } return true } @@ -1071,10 +1078,10 @@ func (s *Simulator) isJumpBlocked(state *MovementState, jumpVel mgl32.Vec3) bool return yVel[1] != jumpVel[1] && xVel[0] == jumpVel[0] && zVel[2] == jumpVel[2] } -func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool) { +func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool) bool { w := s.World if w == nil { - return + return true } useSlideOffset := s.Options.UseSlideOffset correctionThreshold := s.Options.PositionCorrectionThreshold @@ -1138,6 +1145,10 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool onGround := state.OnGround || (yCollision && currVel.Y() < 0.0) if onGround && (xCollision || zCollision) { + stepProbeBB := state.BoundingBox(useSlideOffset).Extend(currVel).ExtendTowards(cube.FaceUp, StepHeight) + if !s.movementAreaLoaded(stepProbeBB) { + return false + } stepYVel := mgl32.Vec3{0, StepHeight} stepXVel := mgl32.Vec3{currVel.X()} stepZVel := mgl32.Vec3{0, 0, currVel.Z()} @@ -1274,6 +1285,7 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool if debugf := s.Options.Debugf; debugf != nil { debugf("(server) xCollision=%v yCollision=%v zCollision=%v", state.CollideX, state.CollideY, state.CollideZ) } + return true } func (s *Simulator) avoidEdge(state *MovementState) { From d63c8918fc222a227574f5e9a0935ee8844bd3d4 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 18:52:45 +0300 Subject: [PATCH 14/23] fix: preserve legacy teleport rearming --- movement.go | 7 ------- parity_regressions_test.go | 20 ++++++++++++++++++++ simulation.go | 20 +++++++++----------- 3 files changed, 29 insertions(+), 18 deletions(-) diff --git a/movement.go b/movement.go index e161d60..82f13f2 100644 --- a/movement.go +++ b/movement.go @@ -80,9 +80,6 @@ type MovementState struct { TeleportCompletionTicks uint64 TeleportIsSmoothed bool TeleportPending bool - // TeleportCompleted distinguishes a finished teleport from legacy timer - // state that still falls within its numeric completion window. - TeleportCompleted bool Sprinting, PressingSprint bool ServerSprint, ServerSprintApplied bool @@ -196,9 +193,6 @@ func (s *MovementState) HasTeleport() bool { if s.TeleportPending || s.PendingTeleports > 0 { return true } - if s.TeleportCompleted { - return false - } if s.TeleportCompletionTicks == 0 { return s.TicksSinceTeleport == 0 && s.TeleportPos != (mgl32.Vec3{}) } @@ -232,5 +226,4 @@ func (s *MovementState) QueueTeleport(pos mgl32.Vec3, smoothed bool, completionT s.TeleportCompletionTicks = completionTicks s.TicksSinceTeleport = 0 s.TeleportPending = true - s.TeleportCompleted = false } diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 7b991c2..7b4ccd3 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -215,6 +215,26 @@ func TestHardTeleportAtMaximumCompletionTickFinishes(t *testing.T) { } } +func TestLegacyTeleportFieldsCanBeRearmed(t *testing.T) { + state := newBaseState() + sim := &Simulator{World: mockWorld{}} + state.TeleportPos = mgl32.Vec3{1, 2, 3} + state.TicksSinceTeleport = 0 + state.TeleportCompletionTicks = 0 + + if result := sim.SimulateState(state); result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("first outcome = %v, want teleport", result.Outcome) + } + + state.TeleportPos = mgl32.Vec3{4, 5, 6} + state.TicksSinceTeleport = 0 + state.TeleportCompletionTicks = 0 + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport || state.Pos != state.TeleportPos { + t.Fatalf("rearmed teleport result=%+v pos=%v target=%v", result, state.Pos, state.TeleportPos) + } +} + func TestLegacyPendingTeleportKeepsExplicitTarget(t *testing.T) { state := newBaseState() state.PendingTeleports = 1 diff --git a/simulation.go b/simulation.go index 0928c4d..0788452 100644 --- a/simulation.go +++ b/simulation.go @@ -766,7 +766,6 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { state.TeleportPos = state.PendingTeleportPos } state.TeleportPending = true - state.TeleportCompleted = false } if !state.HasTeleport() { return false @@ -783,8 +782,7 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { if state.PendingTeleports == 0 { state.PendingTeleportPos = mgl32.Vec3{} } - state.TicksSinceTeleport = teleportCompleteTick(state.TeleportCompletionTicks) - state.TeleportCompleted = true + completeTeleport(state) return true } @@ -804,19 +802,19 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { if state.PendingTeleports == 0 { state.PendingTeleportPos = mgl32.Vec3{} } - state.TicksSinceTeleport = teleportCompleteTick(state.TeleportCompletionTicks) - state.TeleportCompleted = true + completeTeleport(state) } return true } -// teleportCompleteTick returns the first tick after a teleport's completion -// window without overflowing the counter. -func teleportCompleteTick(completionTicks uint64) uint64 { - if completionTicks == math32.MaxUint64 { - return completionTicks +// completeTeleport moves the timer beyond its active window without overflow. +func completeTeleport(state *MovementState) { + if state.TeleportCompletionTicks == math32.MaxUint64 { + state.TeleportCompletionTicks = 0 + state.TicksSinceTeleport = 1 + return } - return completionTicks + 1 + state.TicksSinceTeleport = state.TeleportCompletionTicks + 1 } func (s *Simulator) simulateGlide(state *MovementState) { From 35e222ab47098a9639a55465c5cd37ba20bff0fb Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 18:59:02 +0300 Subject: [PATCH 15/23] fix: preserve bounce API and pose-aware Riptide --- bubble.go | 6 +++++- bubble_test.go | 13 +++++++++++++ constants.go | 3 +++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/bubble.go b/bubble.go index 0975a73..3014c79 100644 --- a/bubble.go +++ b/bubble.go @@ -123,7 +123,11 @@ func (s *Simulator) riptideImpulse(state *MovementState, level int, wasInWater, // riptideHeadInWater reports whether the player's head is below the local // water surface. func (s *Simulator) riptideHeadInWater(state *MovementState) bool { - position := state.Pos.Add(mgl32.Vec3{0, DefaultPlayerHeightOffset, 0}) + heightOffset := DefaultPlayerHeightOffset + if state.Sneaking { + heightOffset = SneakingPlayerHeightOffset + } + position := state.Pos.Add(mgl32.Vec3{0, heightOffset, 0}) pos := posFromVec3(position) liquid, ok := s.liquidAt(pos) return ok && liquidWater.matches(liquid) && position.Y() < float32(pos.Y())+liquidHeight(liquid) diff --git a/bubble_test.go b/bubble_test.go index a6f4cd8..a4d7d7a 100644 --- a/bubble_test.go +++ b/bubble_test.go @@ -144,6 +144,19 @@ func TestRiptideLaunchesInWaterAndStartsSpinAttack(t *testing.T) { } } +func TestRiptideHeadWaterUsesSneakingOffset(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 1, 0}: block.Water{Depth: 2}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Sneaking = true + + if !(&Simulator{World: w}).riptideHeadInWater(state) { + t.Fatal("sneaking head below the partial water surface was reported dry") + } +} + func TestRiptideDoesNotLaunchInLava(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Lava{Still: true, Depth: 8}}} sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} diff --git a/constants.go b/constants.go index cacaf18..fbe289e 100644 --- a/constants.go +++ b/constants.go @@ -12,6 +12,9 @@ const ( SlideOffsetMultiplier = float32(0.4) SlimeBounceMultiplier = float32(-1) BedBounceMultiplier = float32(-0.75) + // Deprecated: BedBounceCap is retained for source compatibility. Bed + // bounces are not capped. + BedBounceCap = float32(1) // This can be validated in Mob::ascendLadder(). ClimbSpeed = float32(0.2) MaxConsumingImpulse = float32(0.1225) From 5545f24b7613657756e7360f2cb638d2283ae1b1 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 19:09:25 +0300 Subject: [PATCH 16/23] fix: guard auxiliary movement probes --- liquid.go | 3 +++ parity_regressions_test.go | 49 ++++++++++++++++++++++++++++++++++++++ simulation.go | 17 +++++++++---- 3 files changed, 65 insertions(+), 4 deletions(-) diff --git a/liquid.go b/liquid.go index 8356eb1..241a40f 100644 --- a/liquid.go +++ b/liquid.go @@ -151,6 +151,9 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, if state.CollideX || state.CollideZ { raised := mgl32.Vec3{vel.X(), vel.Y() + 0.6 + initialY - state.Pos.Y(), vel.Z()} raisedBox := state.BoundingBox(s.Options.UseSlideOffset).Translate(raised) + if !s.movementAreaLoaded(raisedBox) { + return false + } hasCollision := s.hasNearbyBBoxes(state, raisedBox) hasLiquid := s.containsAnyLiquid(raisedBox) if debugf := s.Options.Debugf; debugf != nil { diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 7b4ccd3..3794e24 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -322,6 +322,37 @@ func TestMovementChecksStepProbeArea(t *testing.T) { } } +func TestMovementChecksSneakEdgeProbeArea(t *testing.T) { + state := newBaseState() + state.Sneaking = true + state.OnGround = true + state.HasGravity = false + state.Vel = mgl32.Vec3{0.2, 0, 0} + + result := (&Simulator{World: edgeProbeWorld{}}).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown sneak-edge probe", result.Outcome) + } +} + +func TestMovementChecksLiquidExitProbeArea(t *testing.T) { + base := newLiquidWorld().fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 2, 1}, waterSource) + for y := range 3 { + base.set(cube.Pos{1, y, 0}, block.Stone{}) + } + w := liquidExitProbeWorld{liquidWorld: base} + state := submergedState() + state.HasGravity = false + state.Vel = mgl32.Vec3{1, 0, 0} + + result := newLiquidSim(w).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown liquid-exit probe", result.Outcome) + } +} + func TestImmobileMovementDoesNotCheckUnappliedSweep(t *testing.T) { state := newBaseState() state.Pos = mgl32.Vec3{15.5, 0, 0.5} @@ -596,3 +627,21 @@ type stepProbeWorld struct { func (stepProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { return aabb.Max().Y() <= 1.81 } + +type edgeProbeWorld struct { + mockWorld +} + +// IsMovementAreaLoaded rejects the downward sneak-edge support probe. +func (edgeProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Min().Y() >= -0.1 +} + +type liquidExitProbeWorld struct { + *liquidWorld +} + +// IsMovementAreaLoaded rejects the raised liquid-exit probe. +func (liquidExitProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Max().Y() <= 2.5 +} diff --git a/simulation.go b/simulation.go index 0788452..2578c28 100644 --- a/simulation.go +++ b/simulation.go @@ -646,7 +646,9 @@ func (s *Simulator) simulateMovement(state *MovementState) bool { if !s.movementSweepLoaded(state) { return false } - s.avoidEdge(state) + if !s.avoidEdge(state) { + return false + } oldVel := state.Vel oldOnGround := state.OnGround @@ -1286,10 +1288,12 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool return true } -func (s *Simulator) avoidEdge(state *MovementState) { +// avoidEdge limits sneaking movement to supported ground and reports whether +// the complete support-probe volume is loaded. +func (s *Simulator) avoidEdge(state *MovementState) bool { w := s.World if w == nil { - return + return true } if !state.Sneaking || !state.OnGround || state.Vel.Y() > 0 { if debugf := s.Options.Debugf; debugf != nil { @@ -1300,7 +1304,7 @@ func (s *Simulator) avoidEdge(state *MovementState) { state.Vel.Y(), ) } - return + return true } edgeBoundry := float32(0.025) @@ -1314,6 +1318,10 @@ func (s *Simulator) avoidEdge(state *MovementState) { useSlideOffset := s.Options.UseSlideOffset bb := state.BoundingBox(useSlideOffset).GrowVec3(mgl32.Vec3{-edgeBoundry, 0, -edgeBoundry}) xMov, zMov := newVel.X(), newVel.Z() + probeVolume := bb.Extend(mgl32.Vec3{xMov, -StepHeight * 1.01, zMov}) + if !s.movementAreaLoaded(probeVolume) { + return false + } i := 0 for i = 0; i < maxIter && xMov != 0.0 && !s.hasNearbyBBoxes(state, bb.Translate(mgl32.Vec3{xMov, -StepHeight * 1.01, 0})); i++ { @@ -1370,6 +1378,7 @@ func (s *Simulator) avoidEdge(state *MovementState) { if debugf := s.Options.Debugf; debugf != nil { debugf("(avoidEdge): oldVel=%v newVel=%v", oldVel, newVel) } + return true } func (s *Simulator) isInsideCobweb(state *MovementState) bool { From 59154264e90c379a4828804c7632c8434ec3db01 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 19:18:59 +0300 Subject: [PATCH 17/23] fix: bound provider movement volumes --- bubble.go | 2 +- bubble_test.go | 11 +++++++++++ parity_regressions_test.go | 23 +++++++++++++++++++++++ simulation.go | 16 +++++++++------- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/bubble.go b/bubble.go index 3014c79..51c701d 100644 --- a/bubble.go +++ b/bubble.go @@ -110,7 +110,7 @@ func (s *Simulator) riptideImpulse(state *MovementState, level int, wasInWater, if length := direction.Len(); length > 0 { direction = direction.Mul(force / length) } - if state.OnGround { + if state.OnGround && state.HasGravity { if wasInWater && !headInWater { direction[1] = direction[1] / WaterDrag * NormalGravityMultiplier } else { diff --git a/bubble_test.go b/bubble_test.go index a4d7d7a..8fbe104 100644 --- a/bubble_test.go +++ b/bubble_test.go @@ -157,6 +157,17 @@ func TestRiptideHeadWaterUsesSneakingOffset(t *testing.T) { } } +func TestRiptideDoesNotCompensateDisabledGravity(t *testing.T) { + state := newBaseState() + state.OnGround = true + state.HasGravity = false + + impulse := (&Simulator{}).riptideImpulse(state, 2, false, false) + if impulse.Y() != 0 { + t.Fatalf("gravity-disabled Riptide impulse gained vertical motion: %v", impulse) + } +} + func TestRiptideDoesNotLaunchInLava(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Lava{Still: true, Depth: 8}}} sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 3794e24..7f7afe0 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -471,6 +471,20 @@ func TestMovementRejectsOutOfRangeSweep(t *testing.T) { } } +func TestMovementAreaProviderCannotApproveUnsafeVolume(t *testing.T) { + sim := &Simulator{World: approvingAreaWorld{}} + for name, aabb := range map[string]cube.BBox32{ + "coordinate": cube.Box32(0, 0, 0, math32.MaxFloat32, 1, 1), + "height": cube.Box32(0, 0, 0, 1, math32.MaxFloat32, 1), + } { + t.Run(name, func(t *testing.T) { + if sim.movementAreaLoaded(aabb) { + t.Fatalf("provider approved unsafe %s volume: %v", name, aabb) + } + }) + } +} + func TestUnloadedTickDoesNotCommitPoseChanges(t *testing.T) { state := newBaseState() state.Pos = mgl32.Vec3{16.5, 0, 0.5} @@ -645,3 +659,12 @@ type liquidExitProbeWorld struct { func (liquidExitProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { return aabb.Max().Y() <= 2.5 } + +type approvingAreaWorld struct { + mockWorld +} + +// IsMovementAreaLoaded approves every volume so BedSim's own bounds are tested. +func (approvingAreaWorld) IsMovementAreaLoaded(cube.BBox32) bool { + return true +} diff --git a/simulation.go b/simulation.go index 2578c28..1472477 100644 --- a/simulation.go +++ b/simulation.go @@ -1445,13 +1445,13 @@ func (s *Simulator) movementAreaLoaded(aabb cube.BBox32) bool { if s.World == nil { return true } - if provider, ok := s.World.(MovementAreaProvider); ok { - return provider.IsMovementAreaLoaded(aabb) - } minX, minZ, maxX, maxZ, ok := movementChunkRange(aabb) if !ok { return false } + if provider, ok := s.World.(MovementAreaProvider); ok { + return provider.IsMovementAreaLoaded(aabb) + } for chunkX := int64(minX); chunkX <= int64(maxX); chunkX++ { for chunkZ := int64(minZ); chunkZ <= int64(maxZ); chunkZ++ { if !s.World.IsChunkLoaded(int32(chunkX), int32(chunkZ)) { @@ -1470,6 +1470,7 @@ func (s *Simulator) movementSweepLoaded(state *MovementState) bool { const ( maxMovementChunkSpan int64 = 256 + maxMovementBlockSpan = maxMovementChunkSpan << 4 minMovementBlockCoord float32 = -2147483648 maxMovementBlockCoord float32 = 2147483520 ) @@ -1477,9 +1478,9 @@ const ( // movementChunkRange returns a bounded chunk range for a movement volume. func movementChunkRange(aabb cube.BBox32) (minX, minZ, maxX, maxZ int32, ok bool) { min, max := aabb.Min(), aabb.Max() - minBlockX, minBlockZ := math32.Floor(min.X()), math32.Floor(min.Z()) - maxBlockX, maxBlockZ := math32.Ceil(max.X())-1, math32.Ceil(max.Z())-1 - for _, value := range []float32{minBlockX, minBlockZ, maxBlockX, maxBlockZ} { + minBlockX, minBlockY, minBlockZ := math32.Floor(min.X()), math32.Floor(min.Y()), math32.Floor(min.Z()) + maxBlockX, maxBlockY, maxBlockZ := math32.Ceil(max.X())-1, math32.Ceil(max.Y())-1, math32.Ceil(max.Z())-1 + for _, value := range []float32{minBlockX, minBlockY, minBlockZ, maxBlockX, maxBlockY, maxBlockZ} { if !finiteFloat(value) || value < minMovementBlockCoord || value > maxMovementBlockCoord { return 0, 0, 0, 0, false } @@ -1489,7 +1490,8 @@ func movementChunkRange(aabb cube.BBox32) (minX, minZ, maxX, maxZ int32, ok bool maxX, maxZ = int32(maxBlockX)>>4, int32(maxBlockZ)>>4 spanX := int64(maxX) - int64(minX) + 1 spanZ := int64(maxZ) - int64(minZ) + 1 - if spanX <= 0 || spanZ <= 0 || spanX > maxMovementChunkSpan || spanZ > maxMovementChunkSpan { + spanY := int64(int32(maxBlockY)) - int64(int32(minBlockY)) + 1 + if spanX <= 0 || spanZ <= 0 || spanY <= 0 || spanX > maxMovementChunkSpan || spanZ > maxMovementChunkSpan || spanY > maxMovementBlockSpan { return 0, 0, 0, 0, false } return minX, minZ, maxX, maxZ, true From c12689212d6ba245fc8470dc870c29b1ef30b20e Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 19:27:42 +0300 Subject: [PATCH 18/23] fix: guard remaining world-dependent probes --- liquid.go | 9 +++- parity_regressions_test.go | 63 ++++++++++++++++++++++++++++ simulation.go | 84 ++++++++++++++++++++++++++------------ 3 files changed, 129 insertions(+), 27 deletions(-) diff --git a/liquid.go b/liquid.go index 241a40f..00588c7 100644 --- a/liquid.go +++ b/liquid.go @@ -336,7 +336,13 @@ func (s *Simulator) containsAnyLiquid(box cube.BBox32) bool { return false } -func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, kind liquidKind) { +func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, kind liquidKind) bool { + if len(positions) == 0 { + return true + } + if !s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset).Grow(1)) { + return false + } flow := mgl32.Vec3{} for _, pos := range positions { liquid, ok := s.liquidAt(pos) @@ -355,6 +361,7 @@ func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, debugf("%s flow applied strength=%.6f flow=%v vel=%v", kind.typeName(), strength, flow, state.Vel) } } + return true } func (s *Simulator) liquidFlow(pos cube.Pos, liquid world.Liquid) mgl32.Vec3 { diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 7f7afe0..c661ea3 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -174,6 +174,8 @@ func TestTeleportDoesNotApplyJumpImpulse(t *testing.T) { state := newBaseState() state.OnGround = true state.Jumping = true + support := cube.Pos{7, 8, 9} + state.SupportingBlockPos = &support state.QueueTeleport(mgl32.Vec3{10, 20, 30}, false, 0) result := (&Simulator{World: mockWorld{}}).SimulateState(state) @@ -186,6 +188,9 @@ func TestTeleportDoesNotApplyJumpImpulse(t *testing.T) { if state.HasTeleport() { t.Fatal("completed hard teleport remained active") } + if state.SupportingBlockPos != nil { + t.Fatalf("teleport retained stale support block: %v", *state.SupportingBlockPos) + } } func TestQueueTeleportCanTargetOrigin(t *testing.T) { @@ -353,6 +358,41 @@ func TestMovementChecksLiquidExitProbeArea(t *testing.T) { } } +func TestMovementChecksLiquidFlowProbeArea(t *testing.T) { + base := newLiquidWorld().set(cube.Pos{15, 0, 0}, waterSource) + w := liquidFlowProbeWorld{liquidWorld: base} + state := submergedState() + state.Pos = mgl32.Vec3{15.5, 0.5, 0.5} + state.Client.Pos = state.Pos + state.HasGravity = false + + result := newLiquidSim(w).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown liquid-flow probe", result.Outcome) + } +} + +func TestMovementChecksTargetPoseArea(t *testing.T) { + w := poseProbeWorld{staticWorld: staticWorld{ + chunkLoaded: true, + boxes: []cube.BBox32{cube.Box32(-1, 0.7, -1, 1, 1.8, 1)}, + }} + state := newBaseState() + state.CrawlingHeight = 0.6 + state.Crawling = true + state.Size[1] = state.CrawlingHeight + + result := (&Simulator{World: w}).Simulate(state, InputState{StopCrawling: true}) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown target pose", result.Outcome) + } + if !state.Crawling || state.Size[1] != state.CrawlingHeight { + t.Fatalf("unknown target pose was committed: crawling=%v size=%v", state.Crawling, state.Size) + } +} + func TestImmobileMovementDoesNotCheckUnappliedSweep(t *testing.T) { state := newBaseState() state.Pos = mgl32.Vec3{15.5, 0, 0.5} @@ -621,6 +661,11 @@ func (invalidCollisionWorld) GetNearbyBBoxes(cube.BBox32) []cube.BBox32 { return []cube.BBox32{cube.Box32(0, 0, 0, 0, 1, 1)} } +// HasNearbyBBoxes reports the invalid box to exercise the unfilterable fast path. +func (invalidCollisionWorld) HasNearbyBBoxes(cube.BBox32) bool { + return true +} + type selectiveChunkWorld struct{} func (selectiveChunkWorld) Block(cube.Pos) world.Block { return block.Air{} } @@ -660,6 +705,24 @@ func (liquidExitProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { return aabb.Max().Y() <= 2.5 } +type liquidFlowProbeWorld struct { + *liquidWorld +} + +// IsMovementAreaLoaded rejects liquid-flow reads across the chunk boundary. +func (liquidFlowProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Max().X() < 16 +} + +type poseProbeWorld struct { + staticWorld +} + +// IsMovementAreaLoaded accepts the current crawl pose but rejects standing. +func (poseProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Max().Y() <= 0.7 +} + type approvingAreaWorld struct { mockWorld } diff --git a/simulation.go b/simulation.go index 1472477..6ec2a18 100644 --- a/simulation.go +++ b/simulation.go @@ -29,8 +29,15 @@ func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationR swimming: state.Swimming, swimAmt: state.SwimAmount, } - s.applyInput(state, input) - reason := s.simulateCore(state, true) + inputWorldKnown := s.applyInput(state, input) + reason := SimulationOutcomeUnloadedChunk + if inputWorldKnown { + reason = s.simulateCore(state, true) + } else { + state.SetVel(mgl32.Vec3{}) + state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} + } if s.Options.SprintTiming == SprintTimingLegacy { s.applyLegacySprint(state, input) } @@ -199,9 +206,17 @@ func (s *Simulator) resultFromState(state *MovementState, outcome SimulationOutc return result } -func (s *Simulator) applyInput(state *MovementState, input InputState) { +func (s *Simulator) applyInput(state *MovementState, input InputState) bool { state.ensurePoseHeights() poseCollisionsAvailable := s.poseCollisionsAvailable(state) + poseWorldKnown := poseCollisionsAvailable + canFitHeight := func(height float32) bool { + fits, known := s.canFitHeightKnown(state, height) + if !known { + poseWorldKnown = false + } + return known && fits + } state.Client.HorizontalCollision = input.HorizontalCollision state.Client.VerticalCollision = input.VerticalCollision @@ -274,7 +289,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } else if input.StopSneaking { if state.Crawling { state.Sneaking = false - } else if poseCollisionsAvailable && s.canFitHeight(state, state.StandingHeight) { + } else if poseCollisionsAvailable && canFitHeight(state.StandingHeight) { state.Sneaking = false state.Size[1] = state.StandingHeight } else { @@ -287,7 +302,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } else if input.SneakDown { state.Sneaking = true state.Size[1] = state.SneakingHeight - } else if state.Sneaking && (!poseCollisionsAvailable || !s.canFitHeight(state, state.StandingHeight)) { + } else if state.Sneaking && (!poseCollisionsAvailable || !canFitHeight(state.StandingHeight)) { state.Size[1] = state.SneakingHeight } else { state.Sneaking = false @@ -295,7 +310,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } } if input.StartCrawling { - if poseCollisionsAvailable && !s.canFitHeight(state, state.StandingHeight) { + if poseCollisionsAvailable && !canFitHeight(state.StandingHeight) { state.Crawling = true state.Sneaking = false state.Size[1] = state.CrawlingHeight @@ -305,7 +320,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { if wantSneak { targetHeight = state.SneakingHeight } - if poseCollisionsAvailable && s.canFitHeight(state, targetHeight) { + if poseCollisionsAvailable && canFitHeight(targetHeight) { state.Crawling = false state.Sneaking = wantSneak state.Size[1] = targetHeight @@ -319,10 +334,12 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.StoppedSwimmingThisTick = wasSwimming && input.StopSwimming if input.StopSwimming { state.Swimming = false - s.restorePoseAfterSwimming(state, poseCollisionsAvailable) + if !s.restorePoseAfterSwimming(state, poseCollisionsAvailable) { + poseWorldKnown = false + } } else if input.StartSwimming { state.Swimming = true - if state.SwimPose() || poseCollisionsAvailable && s.canFitHeight(state, state.StandingHeight) { + if state.SwimPose() || poseCollisionsAvailable && canFitHeight(state.StandingHeight) { setSwimmingPoseFlags(state) } } @@ -401,6 +418,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } state.Impulse = moveVector.Mul(0.98) + return poseWorldKnown } func (s *Simulator) applyLegacySprint(state *MovementState, input InputState) { @@ -514,12 +532,16 @@ func (s *Simulator) simulateMovement(state *MovementState) bool { state.Gliding = false state.GlideBoostTicks = 0 } - s.applyLiquidFlow(state, waterBlocks, liquidWater) + if !s.applyLiquidFlow(state, waterBlocks, liquidWater) { + return false + } if !s.simulateLiquidTravel(state, liquidWater, inWater) { return false } } else { - s.applyLiquidFlow(state, lavaBlocks, liquidLava) + if !s.applyLiquidFlow(state, lavaBlocks, liquidLava) { + return false + } if !s.simulateLiquidTravel(state, liquidLava, true) { return false } @@ -775,6 +797,7 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { if !state.TeleportIsSmoothed { state.SetPos(state.TeleportPos) + state.SupportingBlockPos = nil state.SetVel(mgl32.Vec3{}) state.JumpDelay = 0 state.TeleportPending = false @@ -795,6 +818,7 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { } newPos := state.Pos.Add(posDelta.Mul(1.0 / float32(remaining))) state.SetPos(newPos) + state.SupportingBlockPos = nil state.JumpDelay = 0 if remaining == 1 { state.TeleportPending = false @@ -1600,10 +1624,6 @@ func filteredCollisionBoxes(boxes []cube.BBox32) []cube.BBox32 { return boxes } -type nearbyBBoxProbe interface { - HasNearbyBBoxes(aabb cube.BBox32) bool -} - func (s *Simulator) hasNearbyBBoxes(state *MovementState, aabb cube.BBox32) bool { if s.World == nil { return false @@ -1611,15 +1631,18 @@ func (s *Simulator) hasNearbyBBoxes(state *MovementState, aabb cube.BBox32) bool if _, dynamic := s.World.(MovementCollisionProvider); dynamic { return len(s.nearbyBBoxes(state, aabb)) > 0 } - if probe, ok := s.World.(nearbyBBoxProbe); ok { - return probe.HasNearbyBBoxes(aabb) - } return len(filteredCollisionBoxes(s.World.GetNearbyBBoxes(aabb))) > 0 } func (s *Simulator) canFitHeight(state *MovementState, height float32) bool { + fits, known := s.canFitHeightKnown(state, height) + return known && fits +} + +// canFitHeightKnown reports whether the target pose is known and collision-free. +func (s *Simulator) canFitHeightKnown(state *MovementState, height float32) (fits, known bool) { if s.World == nil { - return true + return true, true } standing := *state standing.Size[1] = height @@ -1628,7 +1651,11 @@ func (s *Simulator) canFitHeight(state *MovementState, height float32) bool { standing.SwimWaterGraceTicks = 0 standing.PressingDescend = false standing.WantDown = false - return len(s.nearbyBBoxes(&standing, standing.BoundingBox(s.Options.UseSlideOffset))) == 0 + aabb := standing.BoundingBox(s.Options.UseSlideOffset) + if !s.movementAreaLoaded(aabb) { + return false, false + } + return len(s.nearbyBBoxes(&standing, aabb)) == 0, true } func (s *Simulator) poseCollisionsAvailable(state *MovementState) bool { @@ -1644,23 +1671,28 @@ func setSwimmingPoseFlags(state *MovementState) { state.Size[1] = state.StandingHeight } -func (s *Simulator) restorePoseAfterSwimming(state *MovementState, collisionsAvailable bool) { +func (s *Simulator) restorePoseAfterSwimming(state *MovementState, collisionsAvailable bool) bool { if !collisionsAvailable { - return + return false } - if s.canFitHeight(state, state.StandingHeight) { + if fits, known := s.canFitHeightKnown(state, state.StandingHeight); !known { + return false + } else if fits { state.Sneaking = false state.Crawling = false state.Size[1] = state.StandingHeight - return + return true } - if s.canFitHeight(state, state.SneakingHeight) { + if fits, known := s.canFitHeightKnown(state, state.SneakingHeight); !known { + return false + } else if fits { state.Sneaking = true state.Crawling = false state.Size[1] = state.SneakingHeight - return + return true } state.Sneaking = false state.Crawling = true state.Size[1] = state.CrawlingHeight + return true } From 523bfe2dc424c4b4d88e562f86741ca9bcf45455 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 19:43:26 +0300 Subject: [PATCH 19/23] guard teleport and riptide world boundaries --- bubble.go | 36 ++++++++++++++---- bubble_test.go | 12 ++++++ parity_regressions_test.go | 78 ++++++++++++++++++++++++++++++++++++++ simulation.go | 16 ++++++-- 4 files changed, 131 insertions(+), 11 deletions(-) diff --git a/bubble.go b/bubble.go index 51c701d..126bb3b 100644 --- a/bubble.go +++ b/bubble.go @@ -83,20 +83,28 @@ func (s *Simulator) applyBubbleColumns(state *MovementState) { state.FallDistance = 0 } -// attemptRiptide applies a validated one-shot Riptide launch. -func (s *Simulator) attemptRiptide(state *MovementState, touchingWater, headInWater bool) bool { +// attemptRiptide applies a validated one-shot Riptide launch and reports +// whether every world probe needed for the decision was known. +func (s *Simulator) attemptRiptide(state *MovementState, touchingWater bool) (launched, known bool) { if s.Equipment == nil || state.InVehicle || state.RiptideTicks > 0 || !state.RiptideReady || (!touchingWater && !state.RiptideInRain) { - return false + return false, true } level := s.Equipment.EnchantmentLevel(EnchantmentRiptide) if level <= 0 || !state.StartingSpinAttack { - return false + return false, true + } + headInWater := false + if state.OnGround && state.HasGravity && touchingWater { + headInWater, known = s.riptideHeadInWaterKnown(state) + if !known { + return false, false + } } state.SetVel(state.Vel.Add(s.riptideImpulse(state, level, touchingWater, headInWater))) state.RiptideTicks = 20 state.RiptideCollision = false state.StartingSpinAttack = false - return true + return true, true } // riptideImpulse returns the one-shot launch velocity for a spin attack. The @@ -114,7 +122,7 @@ func (s *Simulator) riptideImpulse(state *MovementState, level int, wasInWater, if wasInWater && !headInWater { direction[1] = direction[1] / WaterDrag * NormalGravityMultiplier } else { - direction[1] += NormalGravity + direction[1] += state.Gravity } } return direction @@ -123,14 +131,28 @@ func (s *Simulator) riptideImpulse(state *MovementState, level int, wasInWater, // riptideHeadInWater reports whether the player's head is below the local // water surface. func (s *Simulator) riptideHeadInWater(state *MovementState) bool { + inWater, _ := s.riptideHeadInWaterKnown(state) + return inWater +} + +// riptideHeadInWaterKnown reports whether the head is submerged and whether +// the block containing it is loaded. +func (s *Simulator) riptideHeadInWaterKnown(state *MovementState) (inWater, known bool) { heightOffset := DefaultPlayerHeightOffset if state.Sneaking { heightOffset = SneakingPlayerHeightOffset } position := state.Pos.Add(mgl32.Vec3{0, heightOffset, 0}) pos := posFromVec3(position) + probe := cube.Box32( + float32(pos.X()), float32(pos.Y()), float32(pos.Z()), + float32(pos.X()+1), float32(pos.Y()+1), float32(pos.Z()+1), + ) + if !s.movementAreaLoaded(probe) { + return false, false + } liquid, ok := s.liquidAt(pos) - return ok && liquidWater.matches(liquid) && position.Y() < float32(pos.Y())+liquidHeight(liquid) + return ok && liquidWater.matches(liquid) && position.Y() < float32(pos.Y())+liquidHeight(liquid), true } func stopRiptideOnBlockCollision(state *MovementState) { diff --git a/bubble_test.go b/bubble_test.go index 8fbe104..3b98d7b 100644 --- a/bubble_test.go +++ b/bubble_test.go @@ -168,6 +168,18 @@ func TestRiptideDoesNotCompensateDisabledGravity(t *testing.T) { } } +func TestRiptideUsesConfiguredGravityForDryCompensation(t *testing.T) { + state := newBaseState() + state.OnGround = true + state.HasGravity = true + state.Gravity = 0.04 + + impulse := (&Simulator{}).riptideImpulse(state, 2, false, false) + if math32.Abs(impulse.Y()-state.Gravity) > 1e-6 { + t.Fatalf("Riptide vertical compensation = %v, want configured gravity %v", impulse.Y(), state.Gravity) + } +} + func TestRiptideDoesNotLaunchInLava(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Lava{Still: true, Depth: 8}}} sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} diff --git a/parity_regressions_test.go b/parity_regressions_test.go index c661ea3..4bc0a7c 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -193,6 +193,22 @@ func TestTeleportDoesNotApplyJumpImpulse(t *testing.T) { } } +func TestQueuedTeleportEscapesUnloadedOrigin(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{16.5, 0, 0.5} + state.Client.Pos = state.Pos + state.QueueTeleport(mgl32.Vec3{0.5, 0, 0.5}, false, 0) + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{ClientPos: state.Client.Pos}) + + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("outcome = %v, want teleport from unloaded origin", result.Outcome) + } + if state.Pos != state.TeleportPos || state.HasTeleport() { + t.Fatalf("queued teleport was not completed: pos=%v target=%v pending=%v", state.Pos, state.TeleportPos, state.HasTeleport()) + } +} + func TestQueueTeleportCanTargetOrigin(t *testing.T) { state := newBaseState() state.Pos = mgl32.Vec3{10, 20, 30} @@ -487,6 +503,54 @@ func TestRiptideLaunchChecksSweptChunks(t *testing.T) { } } +func TestRiptideHeadProbeRequiresLoadedArea(t *testing.T) { + base := newLiquidWorld().set(cube.Pos{0, 0, 0}, waterSource) + w := &riptideHeadProbeWorld{liquidWorld: base} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.CrawlingHeight = 0.6 + state.Crawling = true + state.Size[1] = state.CrawlingHeight + state.OnGround = true + state.HasGravity = true + state.RiptideReady = true + state.StartingSpinAttack = true + + result := (&Simulator{ + World: w, + Equipment: fixedEquipment{EnchantmentRiptide: 2}, + }).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown Riptide head probe", result.Outcome) + } + if w.headProbes != 1 { + t.Fatalf("Riptide head probe checks = %d, want 1", w.headProbes) + } + if state.RiptideTicks != 0 || !state.RiptideReady || !state.StartingSpinAttack { + t.Fatalf("unknown head probe consumed launch state: ticks=%d ready=%v starting=%v", state.RiptideTicks, state.RiptideReady, state.StartingSpinAttack) + } +} + +func TestIneligibleRiptideSkipsHeadProbe(t *testing.T) { + w := &riptideHeadProbeWorld{liquidWorld: newLiquidWorld()} + state := newBaseState() + state.CrawlingHeight = 0.6 + state.Crawling = true + state.Size[1] = state.CrawlingHeight + state.HasGravity = false + + result := (&Simulator{World: w}).SimulateState(state) + + if result.Outcome != SimulationOutcomeNormal { + t.Fatalf("outcome = %v, want normal movement without Riptide", result.Outcome) + } + if w.headProbes != 0 { + t.Fatalf("ineligible Riptide performed %d head probes", w.headProbes) + } +} + func TestCompletedTeleportCounterAdvancesOnce(t *testing.T) { state := newBaseState() state.QueueTeleport(mgl32.Vec3{10, 20, 30}, false, 0) @@ -718,6 +782,20 @@ type poseProbeWorld struct { staticWorld } +type riptideHeadProbeWorld struct { + *liquidWorld + headProbes int +} + +// IsMovementAreaLoaded rejects the block containing a crawling player's head. +func (w *riptideHeadProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + if aabb.Min().Y() == 1 && aabb.Max().Y() == 2 && aabb.Min().X() == 0 && aabb.Max().X() == 1 && aabb.Min().Z() == 0 && aabb.Max().Z() == 1 { + w.headProbes++ + return false + } + return true +} + // IsMovementAreaLoaded accepts the current crawl pose but rejects standing. func (poseProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { return aabb.Max().Y() <= 0.7 diff --git a/simulation.go b/simulation.go index 6ec2a18..858371c 100644 --- a/simulation.go +++ b/simulation.go @@ -31,7 +31,9 @@ func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationR } inputWorldKnown := s.applyInput(state, input) reason := SimulationOutcomeUnloadedChunk - if inputWorldKnown { + // Teleports are authoritative and run before world-dependent simulation, so + // an unknown origin pose must not prevent one from reaching its destination. + if inputWorldKnown || state.HasTeleport() { reason = s.simulateCore(state, true) } else { state.SetVel(mgl32.Vec3{}) @@ -513,9 +515,15 @@ func (s *Simulator) simulateMovement(state *MovementState) bool { }() // The launch is a one-shot impulse; the remaining Riptide ticks decay // through ordinary travel rather than a dedicated movement mode. - if !state.Flying && s.attemptRiptide(state, inWater, s.riptideHeadInWater(state)) { - if debugf := s.Options.Debugf; debugf != nil { - debugf("riptide launch applied: %v", state.Vel) + if !state.Flying { + launched, known := s.attemptRiptide(state, inWater) + if !known { + return false + } + if launched { + if debugf := s.Options.Debugf; debugf != nil { + debugf("riptide launch applied: %v", state.Vel) + } } } From a3f90d38924f1d7510e65b41b80f899c37191dc5 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 19:52:36 +0300 Subject: [PATCH 20/23] preflight movement probes and retry riptide --- parity_regressions_test.go | 64 ++++++++++++++++++++++++++++++++++++++ simulation.go | 23 ++++++++++++-- 2 files changed, 84 insertions(+), 3 deletions(-) diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 4bc0a7c..7f2b0d0 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -318,6 +318,21 @@ func TestMovementChecksSweptChunks(t *testing.T) { } } +func TestMovementPreflightsAuxiliaryWorldProbes(t *testing.T) { + w := &auxiliaryProbeWorld{} + state := newBaseState() + state.HasGravity = false + + result := (&Simulator{World: w}).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown auxiliary probes", result.Outcome) + } + if w.blockReads != 0 { + t.Fatalf("unknown auxiliary area was read %d times", w.blockReads) + } +} + func TestMovementChecksStepProbeArea(t *testing.T) { w := stepProbeWorld{staticWorld: staticWorld{ chunkLoaded: true, @@ -503,6 +518,34 @@ func TestRiptideLaunchChecksSweptChunks(t *testing.T) { } } +func TestRiptideLaunchRetriesAfterUnloadedTick(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 15.5} + state.Client.Pos = state.Pos + state.RiptideInRain = true + state.RiptideReady = true + equipment := fixedEquipment{EnchantmentRiptide: 2} + + first := (&Simulator{ + World: selectiveChunkWorld{}, + Equipment: equipment, + }).Simulate(state, InputState{ClientPos: state.Client.Pos, StartSpinAttack: true}) + if first.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("first outcome = %v, want unloaded chunk", first.Outcome) + } + + second := (&Simulator{ + World: mockWorld{}, + Equipment: equipment, + }).Simulate(state, InputState{ClientPos: state.Client.Pos}) + if second.Outcome != SimulationOutcomeNormal || state.RiptideTicks == 0 { + t.Fatalf("retried launch outcome=%v ticks=%d", second.Outcome, state.RiptideTicks) + } + if state.RiptideReady || state.StartingSpinAttack { + t.Fatalf("successful retry left launch pending: ready=%v starting=%v", state.RiptideReady, state.StartingSpinAttack) + } +} + func TestRiptideHeadProbeRequiresLoadedArea(t *testing.T) { base := newLiquidWorld().set(cube.Pos{0, 0, 0}, waterSource) w := &riptideHeadProbeWorld{liquidWorld: base} @@ -742,6 +785,27 @@ func (selectiveChunkWorld) IsChunkLoaded(chunkX, chunkZ int32) bool { return chunkX == 0 && chunkZ == 0 } +type auxiliaryProbeWorld struct { + blockReads int +} + +func (w *auxiliaryProbeWorld) Block(cube.Pos) world.Block { + w.blockReads++ + return block.Air{} +} + +func (*auxiliaryProbeWorld) BlockCollisions(cube.Pos) []cube.BBox32 { return nil } + +func (*auxiliaryProbeWorld) GetNearbyBBoxes(cube.BBox32) []cube.BBox32 { return nil } + +func (*auxiliaryProbeWorld) IsChunkLoaded(int32, int32) bool { return true } + +// IsMovementAreaLoaded accepts the actor box but rejects surrounding probes. +func (*auxiliaryProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Min().X() >= -0.3 && aabb.Min().Y() >= 0 && aabb.Min().Z() >= -0.3 && + aabb.Max().X() <= 0.3 && aabb.Max().Y() <= 1.8 && aabb.Max().Z() <= 0.3 +} + type stepProbeWorld struct { staticWorld } diff --git a/simulation.go b/simulation.go index 858371c..2feadac 100644 --- a/simulation.go +++ b/simulation.go @@ -158,7 +158,7 @@ func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) Si if state.HasKnockback() { sweepVelocity = state.Knockback } - if s.World != nil && !s.movementAreaLoaded(currentArea.Extend(sweepVelocity)) { + if s.World != nil && !s.movementAreaLoaded(movementProbeArea(currentArea.Extend(sweepVelocity))) { clearRiptideReady = false state.SetVel(mgl32.Vec3{}) state.SwimWaterGraceTicks = 0 @@ -412,7 +412,9 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) bool { state.Gliding = true } - state.StartingSpinAttack = input.StartSpinAttack + // Keep a validated launch edge pending when an unloaded tick could not + // consume it. A fresh validated event or successful simulation clears it. + state.StartingSpinAttack = input.StartSpinAttack || state.RiptideReady && state.StartingSpinAttack if input.StopSpinAttack && state.RiptideTicks > 0 && state.RiptideCollision { state.RiptideTicks = 0 state.RiptideCollision = false @@ -1497,7 +1499,22 @@ func (s *Simulator) movementAreaLoaded(aabb cube.BBox32) bool { // movementSweepLoaded reports whether the world contains the displacement // produced after all same-tick acceleration has been applied. func (s *Simulator) movementSweepLoaded(state *MovementState) bool { - return s.World == nil || s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset).Extend(state.Vel)) + if s.World == nil { + return true + } + sweep := state.BoundingBox(s.Options.UseSlideOffset).Extend(state.Vel) + return s.movementAreaLoaded(movementProbeArea(sweep)) +} + +// movementProbeArea returns the block-aligned volume containing normal +// movement's surrounding block, support, web, and bubble-column lookups. +func movementProbeArea(aabb cube.BBox32) cube.BBox32 { + grown := aabb.Grow(1) + min, max := grown.Min(), grown.Max() + return cube.Box32( + math32.Floor(min.X()), math32.Floor(min.Y()), math32.Floor(min.Z()), + math32.Ceil(max.X())+1, math32.Ceil(max.Y())+1, math32.Ceil(max.Z())+1, + ) } const ( From 4439c9603ed973e5fa8bc66176db501996fac99c Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 20:41:33 +0300 Subject: [PATCH 21/23] preserve legacy smoothed teleport targets --- parity_regressions_test.go | 24 ++++++++++++++++++++++++ simulation.go | 1 - 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 7f2b0d0..875c216 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -267,6 +267,30 @@ func TestLegacyPendingTeleportKeepsExplicitTarget(t *testing.T) { } } +func TestLegacySmoothedPendingTeleportKeepsExplicitTarget(t *testing.T) { + state := newBaseState() + state.PendingTeleports = 1 + state.TeleportPos = mgl32.Vec3{8, 0, 0} + state.TeleportCompletionTicks = 2 + state.TicksSinceTeleport = 0 + state.TeleportIsSmoothed = true + target := state.TeleportPos + sim := &Simulator{World: mockWorld{}} + + for tick := range 3 { + result := sim.Simulate(state, InputState{}) + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("tick %d outcome = %v, want teleport", tick, result.Outcome) + } + if state.TeleportPos != target { + t.Fatalf("tick %d changed target to %v, want %v", tick, state.TeleportPos, target) + } + } + if state.Pos != target || state.HasTeleport() { + t.Fatalf("smoothed teleport did not complete: pos=%v target=%v pending=%v", state.Pos, target, state.HasTeleport()) + } +} + func TestGlideAtVerticalPitchRemainsFinite(t *testing.T) { state := newBaseState() state.Gliding = true diff --git a/simulation.go b/simulation.go index 2feadac..0f441d3 100644 --- a/simulation.go +++ b/simulation.go @@ -799,7 +799,6 @@ func (s *Simulator) attemptTeleport(state *MovementState) bool { if state.TeleportPending || state.PendingTeleportPos != (mgl32.Vec3{}) { state.TeleportPos = state.PendingTeleportPos } - state.TeleportPending = true } if !state.HasTeleport() { return false From 5dbb55cf7aa341d704fa9c1c3ea48f1dac43cc91 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 20:50:58 +0300 Subject: [PATCH 22/23] close mounted state boundary transitions --- parity_regressions_test.go | 39 ++++++++++++++++++++++++++++++++++++++ simulation.go | 3 ++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/parity_regressions_test.go b/parity_regressions_test.go index 875c216..e606d43 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -82,6 +82,45 @@ func TestMountedStateSkipsMovement(t *testing.T) { } } +func TestMountedStateIgnoresUnknownOriginArea(t *testing.T) { + state := newBaseState() + state.InVehicle = true + state.Pos = mgl32.Vec3{16.5, 0, 0.5} + state.Client.Pos = state.Pos + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{ClientPos: state.Client.Pos}) + + if result.Outcome != SimulationOutcomeMounted { + t.Fatalf("outcome = %v, want mounted despite unknown origin area", result.Outcome) + } +} + +func TestMountedResetClearsStaleSupport(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:scaffolding"}, + }} + support := cube.Pos{0, 0, 0} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 1, 0.5} + state.Client.Pos = mgl32.Vec3{100.5, 1, 0.5} + state.SupportingBlockPos = &support + state.InVehicle = true + state.HasGravity = false + sim := &Simulator{World: w} + + sim.SimulateState(state) + if state.SupportingBlockPos != nil { + t.Fatalf("mounted reset retained stale support %v", *state.SupportingBlockPos) + } + + state.InVehicle = false + state.PressingDescend = true + sim.SimulateState(state) + if state.Vel.Y() != 0 { + t.Fatalf("stale support affected later movement: %v", state.Vel) + } +} + func TestSimulateStateLeavesTransientInputForCaller(t *testing.T) { state := newBaseState() state.RiptideReady = true diff --git a/simulation.go b/simulation.go index 0f441d3..f7ec038 100644 --- a/simulation.go +++ b/simulation.go @@ -33,7 +33,7 @@ func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationR reason := SimulationOutcomeUnloadedChunk // Teleports are authoritative and run before world-dependent simulation, so // an unknown origin pose must not prevent one from reaching its destination. - if inputWorldKnown || state.HasTeleport() { + if inputWorldKnown || state.HasTeleport() || state.InVehicle { reason = s.simulateCore(state, true) } else { state.SetVel(mgl32.Vec3{}) @@ -776,6 +776,7 @@ func (s *Simulator) resetToClient(state *MovementState) { state.Vel = state.Client.Vel state.LastMov = state.Client.LastMov state.Mov = state.Client.Mov + state.SupportingBlockPos = nil if state.Flying || state.NoClip { state.OnGround = false } From 99aa8738f10a9061cdc15c0f22615afca8497fec Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Thu, 20 Aug 2026 21:02:11 +0300 Subject: [PATCH 23/23] guard support fallback and mounted contacts --- parity_regressions_test.go | 47 ++++++++++++++++++++++++++++++++++++++ simulation.go | 22 +++++++++++++++--- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/parity_regressions_test.go b/parity_regressions_test.go index e606d43..ef687fc 100644 --- a/parity_regressions_test.go +++ b/parity_regressions_test.go @@ -72,6 +72,10 @@ func TestMountedStateSkipsMovement(t *testing.T) { state.Vel = mgl32.Vec3{1, 2, 3} state.Client.Pos = mgl32.Vec3{4, 5, 6} state.Client.Vel = mgl32.Vec3{0.1, 0.2, 0.3} + state.OnGround = true + state.CollideX = true + state.CollideY = true + state.CollideZ = true result := (&Simulator{World: mockWorld{}}).SimulateState(state) if result.Outcome != SimulationOutcomeMounted { @@ -80,6 +84,9 @@ func TestMountedStateSkipsMovement(t *testing.T) { if state.Pos != state.Client.Pos || state.Vel != state.Client.Vel { t.Fatalf("mounted state was simulated: pos=%v vel=%v", state.Pos, state.Vel) } + if state.OnGround || state.CollideX || state.CollideY || state.CollideZ { + t.Fatalf("mounted reset retained contact flags: ground=%v collisions=%v/%v/%v", state.OnGround, state.CollideX, state.CollideY, state.CollideZ) + } } func TestMountedStateIgnoresUnknownOriginArea(t *testing.T) { @@ -396,6 +403,28 @@ func TestMovementPreflightsAuxiliaryWorldProbes(t *testing.T) { } } +func TestMovementPreflightsTranslatedSupportFallback(t *testing.T) { + w := &supportFallbackProbeWorld{staticWorld: staticWorld{ + chunkLoaded: true, + boxes: []cube.BBox32{cube.Box32(1, -1, -1, 2, 2, 1)}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl32.Vec3{20, 0, 0} + state.OnGround = true + state.HasGravity = false + + result := (&Simulator{World: w}).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown support fallback", result.Outcome) + } + if w.unknownReads != 0 { + t.Fatalf("translated support fallback made %d unknown reads", w.unknownReads) + } +} + func TestMovementChecksStepProbeArea(t *testing.T) { w := stepProbeWorld{staticWorld: staticWorld{ chunkLoaded: true, @@ -852,6 +881,24 @@ type auxiliaryProbeWorld struct { blockReads int } +type supportFallbackProbeWorld struct { + staticWorld + unknownReads int +} + +// BlockCollisions records reads behind the approved support-probe boundary. +func (w *supportFallbackProbeWorld) BlockCollisions(pos cube.Pos) []cube.BBox32 { + if pos.X() < -2 { + w.unknownReads++ + } + return nil +} + +// IsMovementAreaLoaded rejects the translated high-velocity support fallback. +func (*supportFallbackProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Min().X() >= -2 +} + func (w *auxiliaryProbeWorld) Block(cube.Pos) world.Block { w.blockReads++ return block.Air{} diff --git a/simulation.go b/simulation.go index f7ec038..b61036f 100644 --- a/simulation.go +++ b/simulation.go @@ -123,6 +123,10 @@ func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) Si } if state.InVehicle { s.resetToClient(state) + state.OnGround = false + state.CollideX = false + state.CollideY = false + state.CollideZ = false return SimulationOutcomeMounted } @@ -1305,7 +1309,9 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool state.OnGround = (yCollision && currVel.Y() < 0) || (onGround && !yCollision && math32.Abs(currVel.Y()) <= 1e-5) || (clientJumpPrevented && onGround) || completedStep - s.checkSupportingBlockPos(state, useSlideOffset, currVel) + if !s.checkSupportingBlockPos(state, useSlideOffset, currVel) { + return false + } state.SetVel(collisionVel) if debugf := s.Options.Debugf; debugf != nil { debugf("clientVel=%v clientPos=%v", state.Client.Mov, state.Client.Pos) @@ -1546,17 +1552,27 @@ func movementChunkRange(aabb cube.BBox32) (minX, minZ, maxX, maxZ int32, ok bool return minX, minZ, maxX, maxZ, true } -func (s *Simulator) checkSupportingBlockPos(state *MovementState, useSlideOffset bool, vel mgl32.Vec3) { +// checkSupportingBlockPos refreshes the support block and reports whether both +// possible support probes are in known world data. +func (s *Simulator) checkSupportingBlockPos(state *MovementState, useSlideOffset bool, vel mgl32.Vec3) bool { if !state.OnGround { state.SupportingBlockPos = nil - return + return true } decBB := state.BoundingBox(useSlideOffset).ExtendTowards(cube.FaceDown, 1e-3) + if !s.movementAreaLoaded(decBB) { + state.SupportingBlockPos = nil + return false + } s.findSupportingBlock(state, decBB) if state.SupportingBlockPos == nil { decBB = decBB.Translate(mgl32.Vec3{-vel[0], 0, -vel[2]}) + if !s.movementAreaLoaded(decBB) { + return false + } s.findSupportingBlock(state, decBB) } + return true } func (s *Simulator) findSupportingBlock(state *MovementState, bb cube.BBox32) {