From 626469eee31d9d93fc348e85df7749fe4b2ac644 Mon Sep 17 00:00:00 2001 From: NopeNotDark <61342696+NopeNotDark@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:17:03 -0400 Subject: [PATCH 1/7] feat(simulation): add liquid movement --- README.md | 7 +- constants.go | 11 +- input.go | 11 +- interfaces.go | 10 ++ liquid.go | 314 ++++++++++++++++++++++++++++++++++++++++++++++++++ movement.go | 17 ++- simulation.go | 49 ++++++-- 7 files changed, 396 insertions(+), 23 deletions(-) create mode 100644 liquid.go diff --git a/README.md b/README.md index 52e8c1e..ac12517 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Server-side Minecraft Bedrock movement simulation library for Go. -`bedsim` replicates the Bedrock client's movement physics (collisions, stepping, edge-avoidance, gliding, teleportation) on the server, producing authoritative position and velocity values that can be compared against client-reported state. +`bedsim` replicates the Bedrock client's movement physics (collisions, stepping, edge-avoidance, liquids, gliding, teleportation) on the server, producing authoritative position and velocity values that can be compared against client-reported state. Original code was written by [ethaniccc](https://github.com/ethaniccc) in [oomph](https://github.com/oomph-ac/oomph) and has been ported over into this library. @@ -70,6 +70,11 @@ registry or custom block data instead of bedsim's Dragonfly-backed defaults. Custom friction values must be finite and positive; invalid values fall back to Dragonfly defaults. +Implement `LiquidProvider` on the world adapter to expose liquids from both +block layers, including waterlogged blocks. Without it, bedsim falls back to +liquids returned by `WorldProvider.Block`. Implement `DepthStriderProvider` on +the inventory adapter when Depth Strider should affect water movement. + ### Simulation modes - `Simulate` — applies client input, runs physics, advances tick counters, and returns the result. Use this when bedsim owns the full tick lifecycle. diff --git a/constants.go b/constants.go index 6c44dcf..128ec0c 100644 --- a/constants.go +++ b/constants.go @@ -13,10 +13,13 @@ const ( SlimeBounceMultiplier = -1.0 BedBounceMultiplier = -0.66 // This can be validated in Mob::ascendLadder(). - ClimbSpeed = 0.2 - MaxConsumingImpulse = 0.1225 - MaxSneakImpulse = 0.3 - MaxNormalizedImpulse = 0.70710678118 // 1/sqrt(2) + ClimbSpeed = 0.2 + MaxConsumingImpulse = 0.1225 + MaxSneakImpulse = 0.3 + MaxNormalizedImpulse = 0.70710678118 // 1/sqrt(2) + DefaultUnderwaterMovementSpeed = 0.02 + DefaultLavaMovementSpeed = 0.02 + DefaultSwimSpeedMultiplier = 1.0 DefaultPlayerHeightOffset = 1.62 SneakingPlayerHeightOffset = 1.27 diff --git a/input.go b/input.go index d41f3ad..8d20f53 100644 --- a/input.go +++ b/input.go @@ -28,8 +28,15 @@ type InputState struct { SneakDown bool Sneaking bool - StartJumping bool - Jumping bool + StartJumping bool + Jumping bool + AutoJumpingInWater bool + AscendBlock bool + + StartSwimming bool + StopSwimming bool + WantDown bool + WantDownSlow bool StopGliding bool StartGliding bool diff --git a/interfaces.go b/interfaces.go index 01c5bc9..20e5d2e 100644 --- a/interfaces.go +++ b/interfaces.go @@ -13,6 +13,11 @@ type WorldProvider interface { IsChunkLoaded(chunkX, chunkZ int32) bool } +// LiquidProvider returns liquids from either block layer at a position. +type LiquidProvider interface { + Liquid(pos cube.Pos) (world.Liquid, bool) +} + // BlockSemanticsProvider resolves movement-relevant block behavior. Implement // this when names, friction, or climbability come from a per-world registry or // custom block data instead of Dragonfly's default block types. @@ -34,3 +39,8 @@ type EffectsProvider interface { type InventoryProvider interface { HasElytra() bool } + +// DepthStriderProvider exposes the equipped Depth Strider level. +type DepthStriderProvider interface { + DepthStriderLevel() int +} diff --git a/liquid.go b/liquid.go new file mode 100644 index 0000000..dbe0c22 --- /dev/null +++ b/liquid.go @@ -0,0 +1,314 @@ +package bedsim + +import ( + "math" + + "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/mgl64" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +var liquidFaces = [...]struct { + delta cube.Pos + vec mgl64.Vec3 +}{ + {cube.Pos{-1, 0, 0}, mgl64.Vec3{-1, 0, 0}}, + {cube.Pos{1, 0, 0}, mgl64.Vec3{1, 0, 0}}, + {cube.Pos{0, 0, -1}, mgl64.Vec3{0, 0, -1}}, + {cube.Pos{0, 0, 1}, mgl64.Vec3{0, 0, 1}}, +} + +func (s *Simulator) simulateLiquidTravel(state *MovementState, water bool) { + initialY := state.Pos.Y() + if water { + if state.WantDown || state.WantDownSlow { + vel := state.Vel + vel[1] -= 0.04 + state.SetVel(vel) + } + s.updateSwimTravel(state) + } + + if state.EffectiveJumping { + vel := state.Vel + if state.SwimAmount > 0 && state.SwimAmount < 1 { + vel[1] = 0 + } else { + vel[1] += 0.04 + } + state.SetVel(vel) + } + + moveRelativeSpeed := state.LavaMovementSpeed + if moveRelativeSpeed == 0 { + moveRelativeSpeed = DefaultLavaMovementSpeed + } + depthStriderLevel := 0.0 + swimSpeedMultiplier := DefaultSwimSpeedMultiplier + if water { + moveRelativeSpeed = state.UnderwaterMovementSpeed + if moveRelativeSpeed == 0 { + moveRelativeSpeed = DefaultUnderwaterMovementSpeed + } + if state.Swimming && state.SwimSpeedMultiplier != 0 { + swimSpeedMultiplier = state.SwimSpeedMultiplier + } + if inventory, ok := s.Inventory.(DepthStriderProvider); ok { + depthStriderLevel = math.Min(math.Max(float64(inventory.DepthStriderLevel()), 0), 3) + if !state.OnGround { + depthStriderLevel *= 0.5 + } + } + depthStriderFraction := depthStriderLevel / 3 + if swimSpeedMultiplier > 1 { + moveRelativeSpeed *= (0.7 + depthStriderFraction*0.3) * swimSpeedMultiplier + } else { + moveRelativeSpeed += (state.MovementSpeed - moveRelativeSpeed) * depthStriderFraction + } + } + + moveRelative(state, moveRelativeSpeed) + oldVel := state.Vel + oldOnGround := state.OnGround + s.tryCollisions(state, false) + s.setPostCollisionMotion(state, oldVel, oldOnGround, block.Air{}) + state.SetMov(state.Vel) + + vel := state.Vel + if water { + drag := 0.8 + if state.Sprinting { + drag = 0.9 + } + if depthStriderLevel > 0 && swimSpeedMultiplier <= 1 { + drag += (0.54600006 - drag) * (depthStriderLevel / 3) + } + vel[0] *= drag + vel[1] *= 0.8 + vel[2] *= drag + } else { + vel = vel.Mul(0.5) + } + + if s.Effects != nil { + if amplifier, ok := s.Effects.GetEffect(packet.EffectLevitation); ok { + target := LevitationGravityMultiplier * float64(amplifier+1) + vel[1] += (target - vel[1]) * 0.2 + } else if state.HasGravity { + vel[1] -= liquidGravity(state.Swimming, water) + } + } else if state.HasGravity { + vel[1] -= liquidGravity(state.Swimming, water) + } + + if state.CollideX || state.CollideZ { + raised := mgl64.Vec3{vel.X(), vel.Y() + 0.6 + initialY - state.Pos.Y(), vel.Z()} + raisedBox := state.BoundingBox(s.Options.UseSlideOffset).Translate(raised) + hasCollision := hasNearbyBBoxes(s.World, raisedBox) + hasLiquid := s.containsAnyLiquid(raisedBox) + s.debugf("liquid exit probe collision=%t liquid=%t box=%v", hasCollision, hasLiquid, raisedBox) + if !hasCollision && !hasLiquid { + vel[1] = 0.3 + } + } + state.SetVel(vel) + state.FallDistance = 0 +} + +func liquidGravity(swimming, water bool) float64 { + if !water { + return 0.02 + } + if swimming { + return 0 + } + return 0.005 +} + +func (s *Simulator) updateSwimTravel(state *MovementState) { + if !state.Swimming || state.EffectiveJumping { + return + } + targetY := -MCSin(state.Rotation.X() * math.Pi / 180) + rate := 0.06 + if targetY < -0.2 { + rate = 0.085 + } + + if targetY > 0 && !state.WantDownSlow { + belowPos := cube.PosFromVec3(state.Pos.Add(mgl64.Vec3{0, DefaultPlayerHeightOffset - 1.1})) + if _, belowAir := s.liquidMovementBlock(belowPos).(block.Air); belowAir { + liquidPos := cube.PosFromVec3(state.Pos.Add(mgl64.Vec3{0, DefaultPlayerHeightOffset - 1.2})) + if _, liquid := s.liquidAt(liquidPos); !liquid { + vel := state.Vel + vel[1] = 0 + state.SetVel(vel) + return + } + } + } + vel := state.Vel + vel[1] += (targetY - vel[1]) * rate + state.SetVel(vel) +} + +func (s *Simulator) touchingLiquidBlocks(state *MovementState, liquidType string) []cube.Pos { + box := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl64.Vec3{1e-4, 0, 1e-4}) + offset := mgl64.Vec3{0.001, 0.401, 0.001} + if liquidType == "lava" { + offset = mgl64.Vec3{0.1, 0.4, 0.1} + } + box = shrinkLiquidBox(box, offset) + + min, max := box.Min(), box.Max() + minX, minY, minZ := int(math.Floor(min.X())), int(math.Floor(min.Y())), int(math.Floor(min.Z())) + maxX, maxY, maxZ := int(math.Floor(max.X()+1)), int(math.Floor(max.Y()+1)), int(math.Floor(max.Z()+1)) + positions := make([]cube.Pos, 0, 4) + for x := minX; x < maxX; x++ { + for y := minY; y < maxY; y++ { + for z := minZ; z < maxZ; z++ { + pos := cube.Pos{x, y, z} + liquid, ok := s.liquidAt(pos) + if !ok || liquid.LiquidType() != liquidType { + continue + } + if s.Options.Debugf != nil { + height := liquidHeight(liquid) + surface := float64(pos[1]) + height + s.debugf( + "liquid block type=%s pos=%v depth=%d falling=%t height=%.6f surface=%.6f boxY=[%.6f %.6f] immersion=%.6f", + liquid.LiquidType(), pos, liquid.LiquidDepth(), liquid.LiquidFalling(), height, surface, + box.Min().Y(), box.Max().Y(), surface-box.Min().Y(), + ) + } + positions = append(positions, pos) + } + } + } + return positions +} + +func shrinkLiquidBox(box cube.BBox, offset mgl64.Vec3) cube.BBox { + min, max := box.Min().Add(offset), box.Max().Sub(offset) + originalMin, originalMax := box.Min(), box.Max() + for axis := range 3 { + if min[axis] > max[axis] { + mid := (originalMin[axis] + originalMax[axis]) * 0.5 + min[axis], max[axis] = mid, mid + } + } + return cube.Box(min.X(), min.Y(), min.Z(), max.X(), max.Y(), max.Z()) +} + +func (s *Simulator) liquidMovementBlock(pos cube.Pos) world.Block { + if liquid, ok := s.liquidAt(pos); ok { + return liquid + } + return s.blockAtPos(pos) +} + +func (s *Simulator) liquidAt(pos cube.Pos) (world.Liquid, bool) { + if provider, ok := s.World.(LiquidProvider); ok { + if liquid, found := provider.Liquid(pos); found { + return liquid, true + } + } + liquid, ok := s.blockAtPos(pos).(world.Liquid) + return liquid, ok +} + +func liquidHeight(liquid world.Liquid) float64 { + if liquid.LiquidFalling() { + return 1 + } + return float64(liquid.LiquidDepth()+1) / 9 +} + +func (s *Simulator) containsAnyLiquid(box cube.BBox) bool { + min, max := box.Min(), box.Max() + minX, minY, minZ := int(math.Floor(min.X())), int(math.Floor(min.Y())), int(math.Floor(min.Z())) + maxX, maxY, maxZ := int(math.Ceil(max.X())), int(math.Ceil(max.Y())), int(math.Ceil(max.Z())) + 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 { + return true + } + } + } + } + return false +} + +func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, liquidType string) { + flow := mgl64.Vec3{} + for _, pos := range positions { + liquid, ok := s.liquidAt(pos) + if !ok || liquid.LiquidType() != liquidType { + continue + } + flow = flow.Add(s.liquidFlow(pos, liquid)) + } + if length := flow.Len(); length >= 1e-4 { + strength := 0.014 + if liquidType == "lava" { + strength = 0.007 + } + state.SetVel(state.Vel.Add(flow.Mul(strength / length))) + s.debugf("%s flow applied strength=%.6f flow=%v vel=%v", liquidType, strength, flow, state.Vel) + } +} + +func (s *Simulator) liquidFlow(pos cube.Pos, liquid world.Liquid) mgl64.Vec3 { + currentDecay := liquidDecay(liquid) + flow := mgl64.Vec3{} + for _, face := range liquidFaces { + neighbourPos := pos.Add(face.delta) + if neighbour, ok := s.liquidAt(neighbourPos); ok { + if neighbour.LiquidType() == liquid.LiquidType() { + if !s.liquidFlowSideClosed(pos, neighbourPos) && !s.liquidFlowSideClosed(neighbourPos, pos) { + flow = flow.Add(face.vec.Mul(float64(liquidDecay(neighbour) - currentDecay))) + } + continue + } + } + if len(s.World.BlockCollisions(neighbourPos)) != 0 { + continue + } + below := neighbourPos.Side(cube.FaceDown) + if lower, ok := s.liquidAt(below); ok && lower.LiquidType() == liquid.LiquidType() { + flow = flow.Add(face.vec.Mul(float64(liquidDecay(lower) - currentDecay + 8))) + } + } + if liquid.LiquidFalling() { + for _, face := range liquidFaces { + neighbourPos := pos.Add(face.delta) + aboveNeighbour := neighbourPos.Side(cube.FaceUp) + if len(s.World.BlockCollisions(neighbourPos)) != 0 || len(s.World.BlockCollisions(aboveNeighbour)) != 0 { + if length := flow.Len(); length > 1e-4 { + flow = flow.Mul(1 / length) + } + flow[1] -= 6 + break + } + } + } + if length := flow.Len(); length > 1e-4 { + return flow.Mul(1 / length) + } + return mgl64.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) +} + +func liquidDecay(liquid world.Liquid) int { + if liquid.LiquidFalling() { + return 0 + } + return 8 - liquid.LiquidDepth() +} diff --git a/movement.go b/movement.go index e8fa77f..6a2156f 100644 --- a/movement.go +++ b/movement.go @@ -36,10 +36,13 @@ type MovementState struct { JumpHeight float64 FallDistance float64 - MovementSpeed float64 - DefaultMovementSpeed float64 - AirSpeed float64 - ServerUpdatedSpeed bool + MovementSpeed float64 + DefaultMovementSpeed float64 + AirSpeed float64 + UnderwaterMovementSpeed float64 + LavaMovementSpeed float64 + SwimSpeedMultiplier float64 + ServerUpdatedSpeed bool Knockback mgl64.Vec3 TicksSinceKnockback uint64 @@ -58,8 +61,14 @@ type MovementState struct { Sneaking, PressingSneak bool Jumping, PressingJump bool + EffectiveJumping bool JumpDelay uint64 + Swimming bool + SwimAmount float64 + AutoJumpingInWater bool + WantDown, WantDownSlow bool + CollideX, CollideY, CollideZ bool OnGround bool diff --git a/simulation.go b/simulation.go index a5b2241..92147d9 100644 --- a/simulation.go +++ b/simulation.go @@ -173,21 +173,37 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.Sneaking = input.SneakDown } + wasSwimming := state.Swimming + if input.StopSwimming { + state.Swimming = false + } else if input.StartSwimming { + state.Swimming = true + state.Sneaking = false + } + if wasSwimming { + state.SwimAmount = ClampFloat(state.SwimAmount+0.1, 0, 1) + } else { + state.SwimAmount = ClampFloat(state.SwimAmount-0.1, 0, 1) + } + state.AutoJumpingInWater = input.AutoJumpingInWater + state.WantDown = input.WantDown + state.WantDownSlow = input.WantDownSlow + maxImpulse := 1.0 if input.UsingConsumable { maxImpulse *= MaxConsumingImpulse } - if state.Sneaking { + if state.Sneaking && !state.WantDown && !state.WantDownSlow { maxImpulse *= MaxSneakImpulse } - moveVector := mgl64.Vec2{ ClampFloat(input.MoveVector[0], -maxImpulse, maxImpulse), ClampFloat(input.MoveVector[1], -maxImpulse, maxImpulse), } - state.Jumping = input.StartJumping + state.Jumping = input.StartJumping || input.Jumping state.PressingJump = input.Jumping + state.EffectiveJumping = input.Jumping || input.AutoJumpingInWater || input.AscendBlock state.JumpHeight = DefaultJumpHeight if s.Effects != nil { if amp, ok := s.Effects.GetEffect(packet.EffectJumpBoost); ok { @@ -255,6 +271,22 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SetVel(mgl64.Vec3{}) } + waterBlocks := s.touchingLiquidBlocks(state, "water") + lavaBlocks := s.touchingLiquidBlocks(state, "lava") + if !state.Flying && (len(waterBlocks) != 0 || len(lavaBlocks) != 0) { + s.debugfIf(attemptKnockback(state), "knockback applied in liquid: %v", state.Vel) + if len(waterBlocks) != 0 { + state.Gliding = false + state.GlideBoostTicks = 0 + s.applyLiquidFlow(state, waterBlocks, "water") + s.simulateLiquidTravel(state, true) + } else { + s.applyLiquidFlow(state, lavaBlocks, "lava") + s.simulateLiquidTravel(state, false) + } + return + } + blockUnder := s.blockAtPos(cube.PosFromVec3(state.Pos.Sub(mgl64.Vec3{0, 0.5}))) blockFriction := DefaultAirFriction moveRelativeSpeed := state.AirSpeed @@ -299,7 +331,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { if newVel[1] < negClimbSpeed { newVel[1] = negClimbSpeed } - if state.PressingJump || state.CollideX || state.CollideZ { + if state.EffectiveJumping || state.CollideX || state.CollideZ { newVel[1] = ClimbSpeed } if state.Sneaking && newVel[1] < 0 { @@ -379,17 +411,10 @@ func (s *Simulator) simulationIsReliable(state *MovementState) bool { stateBB := state.BoundingBox(s.Options.UseSlideOffset) isReliable := true - for pos, b := range nearbyBlocks(stateBB.Grow(1), s.World) { + for _, b := range nearbyBlocks(stateBB.Grow(1), s.World) { if _, isAir := b.(block.Air); isAir { continue } - if _, isLiquid := b.(world.Liquid); isLiquid { - blockBB := cube.Box(0, 0, 0, 1, 1, 1).Translate(pos.Vec3()) - if stateBB.IntersectsWith(blockBB) { - isReliable = false - break - } - } if s.blockName(b) == "minecraft:bamboo" { isReliable = false break From c6c785becaed9a772fdcd8aa84901cf85400529c Mon Sep 17 00:00:00 2001 From: NopeNotDark <61342696+NopeNotDark@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:19:01 -0400 Subject: [PATCH 2/7] fix(simulation): match client lava current --- liquid.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/liquid.go b/liquid.go index dbe0c22..40f3067 100644 --- a/liquid.go +++ b/liquid.go @@ -254,7 +254,7 @@ func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, if length := flow.Len(); length >= 1e-4 { strength := 0.014 if liquidType == "lava" { - strength = 0.007 + strength = 0.0035 } state.SetVel(state.Vel.Add(flow.Mul(strength / length))) s.debugf("%s flow applied strength=%.6f flow=%v vel=%v", liquidType, strength, flow, state.Vel) From 2c62650b320e786d132063297bfe959f2d187983 Mon Sep 17 00:00:00 2001 From: NopeNotDark <61342696+NopeNotDark@users.noreply.github.com> Date: Sun, 19 Jul 2026 19:29:29 -0400 Subject: [PATCH 3/7] fix(simulation): preserve water travel while swimming --- liquid.go | 4 ++-- simulation.go | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/liquid.go b/liquid.go index 40f3067..5ef2242 100644 --- a/liquid.go +++ b/liquid.go @@ -20,7 +20,7 @@ var liquidFaces = [...]struct { {cube.Pos{0, 0, 1}, mgl64.Vec3{0, 0, 1}}, } -func (s *Simulator) simulateLiquidTravel(state *MovementState, water bool) { +func (s *Simulator) simulateLiquidTravel(state *MovementState, water, touchingLiquid bool) { initialY := state.Pos.Y() if water { if state.WantDown || state.WantDownSlow { @@ -33,7 +33,7 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, water bool) { if state.EffectiveJumping { vel := state.Vel - if state.SwimAmount > 0 && state.SwimAmount < 1 { + if state.SwimAmount > 0 && state.SwimAmount < 1 || water && state.Swimming && !touchingLiquid { vel[1] = 0 } else { vel[1] += 0.04 diff --git a/simulation.go b/simulation.go index 92147d9..d0b996e 100644 --- a/simulation.go +++ b/simulation.go @@ -273,16 +273,17 @@ func (s *Simulator) simulateMovement(state *MovementState) { waterBlocks := s.touchingLiquidBlocks(state, "water") lavaBlocks := s.touchingLiquidBlocks(state, "lava") - if !state.Flying && (len(waterBlocks) != 0 || len(lavaBlocks) != 0) { + waterTravel := len(waterBlocks) != 0 || state.Swimming + if !state.Flying && (waterTravel || len(lavaBlocks) != 0) { s.debugfIf(attemptKnockback(state), "knockback applied in liquid: %v", state.Vel) - if len(waterBlocks) != 0 { + if waterTravel { state.Gliding = false state.GlideBoostTicks = 0 s.applyLiquidFlow(state, waterBlocks, "water") - s.simulateLiquidTravel(state, true) + s.simulateLiquidTravel(state, true, len(waterBlocks) != 0) } else { s.applyLiquidFlow(state, lavaBlocks, "lava") - s.simulateLiquidTravel(state, false) + s.simulateLiquidTravel(state, false, true) } return } From ab720582550955ff26fd4d47f516de8222ca0442 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Sun, 19 Jul 2026 23:59:23 -0700 Subject: [PATCH 4/7] fix(simulation): complete liquid port and add regression suite Audits the liquid-simulation branch against oomph-ac/oomph PR #145 at its head commit 0bcbb8be25593f836a66ee6a4e302d4fb81fd2bb and closes the gaps found, then covers the whole feature with tests. Implementation fixes: - Add the swimming hitbox. While Swimming, BoundingBox and ClientBoundingBox collapse the height to the entity width, matching the client's swim pose. This was missing entirely and affects collisions, liquid detection and the liquid exit probe, not just liquid travel. - Restore edge-triggered jumping. state.Jumping was widened to StartJumping || Jumping, which re-armed a ground jump on every tick the jump key was held. Upstream is StartJumping only; the held-key and automatic ascent cases are already carried by EffectiveJumping. This was a regression in non-liquid movement. - Add DolphinBoostTicks and count it down in tickState, resetting SwimSpeedMultiplier to its default on expiry, matching upstream. Callers previously had to manage boost expiry themselves. - Only clear Gliding and GlideBoostTicks when actually gliding, so a pending glide boost survives water contact as it does upstream. - Revert the sneak impulse clamp to its v0.1.3 form. The WantDown/WantDownSlow exception had no basis in the source and changed non-liquid movement. - Route liquid flow's collision lookups through a nil-safe helper so an absent world reads as empty space rather than panicking. - Fix the climb debug label to report effectiveJumping, matching the branch condition. Documents in liquid.go the deliberate divergences from the source: float64 arithmetic, LiquidType matching, the LiquidProvider layer-1 adaptation, zero-means-default speed fields, nil effects tolerance, and why the impulse clamps are retained. Adds liquid_test.go: 107 tests covering water and lava detection across both layers, the swim hitbox, effective jump/ascend/descend inputs, acceleration and drag, gravity, levitation, depth strider, dolphin boost, flow including falling liquid and solid faces, collision and exit probing, gliding, ladder climbing, reliability, determinism, and the provider fallback paths. Co-Authored-By: Claude Opus 4.8 --- README.md | 41 ++ bbox.go | 8 + liquid.go | 40 +- liquid_test.go | 1371 ++++++++++++++++++++++++++++++++++++++++++++++++ movement.go | 9 +- simulation.go | 22 +- 6 files changed, 1482 insertions(+), 9 deletions(-) create mode 100644 liquid_test.go diff --git a/README.md b/README.md index ac12517..bf3ffda 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,7 @@ Server-side Minecraft Bedrock movement simulation library for Go. `bedsim` replicates the Bedrock client's movement physics (collisions, stepping, edge-avoidance, liquids, gliding, teleportation) on the server, producing authoritative position and velocity values that can be compared against client-reported state. Original code was written by [ethaniccc](https://github.com/ethaniccc) in [oomph](https://github.com/oomph-ac/oomph) and has been ported over into this library. +The liquid movement physics were ported from [oomph#145](https://github.com/oomph-ac/oomph/pull/145) by [NopeNotDark](https://github.com/NopeNotDark). ## Installation @@ -75,6 +76,46 @@ block layers, including waterlogged blocks. Without it, bedsim falls back to liquids returned by `WorldProvider.Block`. Implement `DepthStriderProvider` on the inventory adapter when Depth Strider should affect water movement. +### Liquid movement + +When the player's hitbox touches water or lava (and the player is not flying), +bedsim runs liquid travel instead of the normal ground/air step. Water takes +priority when both are touched, and a player whose client reports the swimming +pose keeps water travel even after leaving the water blocks. + +Liquid travel covers: per-liquid acceleration and drag, liquid gravity, +levitation, Depth Strider, the dolphin-boost swim multiplier, pitch-steered +swim travel with a surface clamp, flow from depth gradients including falling +liquid and solid faces, and an exit probe that boosts the player over a ledge. + +Callers feed these `InputState` fields from the client's input flags: + +| Field | Client input flag | +| --- | --- | +| `StartSwimming` / `StopSwimming` | `StartSwimming` / `StopSwimming` | +| `WantDown` / `WantDownSlow` | `WantDown` / `WantDownSlow` | +| `AutoJumpingInWater` | `AutoJumpingInWater` | +| `AscendBlock` | `AscendBlock` | + +`Jumping` remains edge-triggered from `StartJumping` and arms a ground jump. +`EffectiveJumping` is derived from the held jump key, `AutoJumpingInWater`, and +`AscendBlock`, and drives liquid ascent and ladder climbing. + +These `MovementState` fields tune liquid physics; each is optional and falls +back to a documented default when left at its zero value: + +- `UnderwaterMovementSpeed` (default `0.02`) +- `LavaMovementSpeed` (default `0.02`) +- `SwimSpeedMultiplier` (default `1`; a dolphin boost sets it to `2`) + +Set `DolphinBoostTicks` when the client receives a dolphin boost. `Simulate` +counts it down and restores `SwimSpeedMultiplier` to its default on expiry; +callers using `SimulateState` must manage that lifecycle themselves. + +While `Swimming` is set, the bounding box collapses to a width-sized cube, +matching the client's swim pose. This affects collisions and liquid detection, +not just liquid travel. + ### Simulation modes - `Simulate` — applies client input, runs physics, advances tick counters, and returns the result. Use this when bedsim owns the full tick lifecycle. diff --git a/bbox.go b/bbox.go index 97ca97b..1ff53ab 100644 --- a/bbox.go +++ b/bbox.go @@ -10,6 +10,10 @@ func (s *MovementState) BoundingBox(useSlideOffset bool) cube.BBox { scale := s.Size[2] width := (s.Size[0] * 0.5) * scale height := s.Size[1] * scale + if s.Swimming { + // The swim pose collapses the hitbox to a width-sized cube. + height = s.Size[0] * scale + } yOffset := 0.0 if useSlideOffset { yOffset = s.SlideOffset.Y() @@ -30,6 +34,10 @@ func (s *MovementState) ClientBoundingBox(useSlideOffset bool) cube.BBox { scale := s.Size[2] width := (s.Size[0] * 0.5) * scale height := s.Size[1] * scale + if s.Swimming { + // The swim pose collapses the hitbox to a width-sized cube. + height = s.Size[0] * scale + } yOffset := 0.0 if useSlideOffset { yOffset = s.SlideOffset.Y() diff --git a/liquid.go b/liquid.go index 5ef2242..00047ef 100644 --- a/liquid.go +++ b/liquid.go @@ -10,6 +10,33 @@ import ( "github.com/sandertv/gophertunnel/minecraft/protocol/packet" ) +// Liquid movement physics, ported from oomph-ac/oomph PR #145 at commit +// 0bcbb8be25593f836a66ee6a4e302d4fb81fd2bb (anticheat/player/simulation/movement.go). +// +// The port is behaviorally 1:1 with that source apart from the following +// deliberate adaptations, which exist because bedsim is a standalone library +// rather than a component of a proxy: +// +// - Arithmetic is float64/mgl64 throughout, matching the rest of bedsim. +// Upstream is float32/mgl32, so results can differ in the low bits. +// - Liquids are matched by LiquidType() rather than by concrete Go type, so +// that custom world.Liquid implementations behave like water and lava. +// - The second block layer is read through the optional LiquidProvider +// instead of a hardcoded BlockLayer(pos, 1) call. Because that interface +// only exposes liquids, liquidMovementBlock treats a layer-1 entry as +// occupied only when it is a liquid; upstream treats any non-air layer-1 +// block as occupied. In Bedrock that layer only ever holds liquids. +// - Zero-valued speed and multiplier fields on MovementState mean "unset" +// and fall back to the Default* constants, so callers that do not track +// those attributes still get correct physics. +// - A nil EffectsProvider is tolerated and falls back to gravity. +// +// Upstream also removed the sneak and consumable impulse clamps outright in +// this PR, clamping the move vector to [-1, 1] instead. bedsim deliberately +// does not follow that: MaxSneakImpulse and MaxConsumingImpulse are public API +// and apply to all movement, so removing them is a breaking change well +// outside liquid scope. Impulse handling is therefore unchanged from v0.1.3. + var liquidFaces = [...]struct { delta cube.Pos vec mgl64.Vec3 @@ -209,6 +236,15 @@ func (s *Simulator) liquidMovementBlock(pos cube.Pos) world.Block { return s.blockAtPos(pos) } +// blockCollisions returns the collision boxes at pos, treating an absent world +// as empty space so liquid flow never dereferences a nil provider. +func (s *Simulator) blockCollisions(pos cube.Pos) []cube.BBox { + if s.World == nil { + return nil + } + return s.World.BlockCollisions(pos) +} + func (s *Simulator) liquidAt(pos cube.Pos) (world.Liquid, bool) { if provider, ok := s.World.(LiquidProvider); ok { if liquid, found := provider.Liquid(pos); found { @@ -274,7 +310,7 @@ func (s *Simulator) liquidFlow(pos cube.Pos, liquid world.Liquid) mgl64.Vec3 { continue } } - if len(s.World.BlockCollisions(neighbourPos)) != 0 { + if len(s.blockCollisions(neighbourPos)) != 0 { continue } below := neighbourPos.Side(cube.FaceDown) @@ -286,7 +322,7 @@ func (s *Simulator) liquidFlow(pos cube.Pos, liquid world.Liquid) mgl64.Vec3 { for _, face := range liquidFaces { neighbourPos := pos.Add(face.delta) aboveNeighbour := neighbourPos.Side(cube.FaceUp) - if len(s.World.BlockCollisions(neighbourPos)) != 0 || len(s.World.BlockCollisions(aboveNeighbour)) != 0 { + if len(s.blockCollisions(neighbourPos)) != 0 || len(s.blockCollisions(aboveNeighbour)) != 0 { if length := flow.Len(); length > 1e-4 { flow = flow.Mul(1 / length) } diff --git a/liquid_test.go b/liquid_test.go new file mode 100644 index 0000000..60234a0 --- /dev/null +++ b/liquid_test.go @@ -0,0 +1,1371 @@ +package bedsim + +import ( + "math" + "testing" + + "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/mgl64" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +// waterSource and lavaSource are full still source blocks, the most common +// liquid a player is submerged in. +var ( + waterSource = block.Water{Depth: 8, Still: true} + lavaSource = block.Lava{Depth: 8, Still: true} +) + +// liquidWorld is a WorldProvider backed by explicit block placements. It +// deliberately does not implement LiquidProvider, so simulations against it +// exercise the fallback path through WorldProvider.Block. +type liquidWorld struct { + blocks map[cube.Pos]world.Block + chunkLoaded bool +} + +func newLiquidWorld() *liquidWorld { + return &liquidWorld{blocks: map[cube.Pos]world.Block{}, chunkLoaded: true} +} + +func (w *liquidWorld) set(pos cube.Pos, b world.Block) *liquidWorld { + w.blocks[pos] = b + return w +} + +// fill places b in the inclusive cuboid between min and max. +func (w *liquidWorld) fill(min, max cube.Pos, b world.Block) *liquidWorld { + for x := min[0]; x <= max[0]; x++ { + for y := min[1]; y <= max[1]; y++ { + for z := min[2]; z <= max[2]; z++ { + w.blocks[cube.Pos{x, y, z}] = b + } + } + } + return w +} + +func (w *liquidWorld) Block(pos cube.Pos) world.Block { + if b, ok := w.blocks[pos]; ok { + return b + } + return block.Air{} +} + +func (w *liquidWorld) BlockCollisions(pos cube.Pos) []cube.BBox { + b := w.Block(pos) + if _, air := b.(block.Air); air { + return nil + } + if _, liquid := b.(world.Liquid); liquid { + return nil + } + return []cube.BBox{cube.Box(0, 0, 0, 1, 1, 1).Translate(pos.Vec3())} +} + +func (w *liquidWorld) GetNearbyBBoxes(aabb cube.BBox) []cube.BBox { + min, max := aabb.Min(), aabb.Max() + var out []cube.BBox + for x := int(math.Floor(min.X())); x <= int(math.Floor(max.X())); x++ { + for y := int(math.Floor(min.Y())); y <= int(math.Floor(max.Y())); y++ { + for z := int(math.Floor(min.Z())); z <= int(math.Floor(max.Z())); z++ { + for _, bb := range w.BlockCollisions(cube.Pos{x, y, z}) { + if bb.IntersectsWith(aabb) { + out = append(out, bb) + } + } + } + } + } + return out +} + +func (w *liquidWorld) IsChunkLoaded(chunkX, chunkZ int32) bool { + return w.chunkLoaded +} + +// layeredLiquidWorld implements LiquidProvider, exposing liquids stored in the +// second block layer (waterlogged blocks) as well as the main layer. +type layeredLiquidWorld struct { + *liquidWorld + layer map[cube.Pos]world.Liquid +} + +func newLayeredLiquidWorld() *layeredLiquidWorld { + return &layeredLiquidWorld{liquidWorld: newLiquidWorld(), layer: map[cube.Pos]world.Liquid{}} +} + +func (w *layeredLiquidWorld) waterlog(pos cube.Pos, b world.Block, liquid world.Liquid) *layeredLiquidWorld { + w.blocks[pos] = b + w.layer[pos] = liquid + return w +} + +func (w *layeredLiquidWorld) Liquid(pos cube.Pos) (world.Liquid, bool) { + if liquid, ok := w.layer[pos]; ok { + return liquid, true + } + liquid, ok := w.liquidWorld.Block(pos).(world.Liquid) + return liquid, ok +} + +type levitationEffects struct { + amplifier int32 +} + +func (e levitationEffects) GetEffect(effectID int32) (int32, bool) { + if effectID == packet.EffectLevitation { + return e.amplifier, true + } + return 0, false +} + +type depthStriderInventory struct { + level int +} + +func (depthStriderInventory) HasElytra() bool { return false } + +func (i depthStriderInventory) DepthStriderLevel() int { return i.level } + +func newLiquidSim(w WorldProvider) *Simulator { + return &Simulator{ + World: w, + Effects: mockEffects{}, + Options: SimulationOptions{PositionCorrectionThreshold: 0.3}, + } +} + +// submergedState returns a state standing inside a liquid column at 0.5/0.5/0.5. +func submergedState() *MovementState { + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 0.5, 0.5} + state.Client.Pos = state.Pos + return state +} + +// filledColumn returns a world where the 3x3 column around the origin is filled +// with the given liquid from y=0 to y=3, so the player is fully submerged and +// the liquid gradient is uniform (no flow). +func filledColumn(b world.Block) *liquidWorld { + return newLiquidWorld().fill(cube.Pos{-2, 0, -2}, cube.Pos{2, 3, 2}, b) +} + +func approxEqual(a, b float64) bool { + return math.Abs(a-b) < 1e-9 +} + +func assertVec(t *testing.T, got, want mgl64.Vec3) { + t.Helper() + if !approxEqual(got.X(), want.X()) || !approxEqual(got.Y(), want.Y()) || !approxEqual(got.Z(), want.Z()) { + t.Fatalf("velocity = %v, want %v", got, want) + } +} + +// --- swimming hitbox ------------------------------------------------------- + +// A swimming player's hitbox collapses to a width-sized cube, matching the +// client's swim pose. This drives collision, liquid detection and exit probing. +func TestSwimmingBoundingBoxUsesWidthAsHeight(t *testing.T) { + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 10, 0.5} + + standing := state.BoundingBox(false) + if height := standing.Height(); !approxEqual(height, 1.8) { + t.Fatalf("standing height = %v, want 1.8", height) + } + + state.Swimming = true + swimming := state.BoundingBox(false) + if height := swimming.Height(); !approxEqual(height, 0.6) { + t.Fatalf("swimming height = %v, want 0.6", height) + } + if width := swimming.Width(); !approxEqual(width, standing.Width()) { + t.Fatalf("swimming width = %v, want unchanged %v", width, standing.Width()) + } +} + +func TestSwimmingClientBoundingBoxUsesWidthAsHeight(t *testing.T) { + state := newBaseState() + state.Client.Pos = mgl64.Vec3{0.5, 10, 0.5} + + if height := state.ClientBoundingBox(false).Height(); !approxEqual(height, 1.8) { + t.Fatalf("standing client height = %v, want 1.8", height) + } + state.Swimming = true + if height := state.ClientBoundingBox(false).Height(); !approxEqual(height, 0.6) { + t.Fatalf("swimming client height = %v, want 0.6", height) + } +} + +// The swim hitbox must scale with the entity size, not use a hardcoded 0.6. +func TestSwimmingBoundingBoxRespectsScale(t *testing.T) { + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 10, 0.5} + state.Size = mgl64.Vec3{0.6, 1.8, 2} + state.Swimming = true + + if height := state.BoundingBox(false).Height(); !approxEqual(height, 1.2) { + t.Fatalf("scaled swimming height = %v, want 1.2", height) + } +} + +// --- jump / ascend inputs -------------------------------------------------- + +// Jumping is edge-triggered by StartJumping alone. A held jump key sets +// PressingJump and EffectiveJumping, but must not re-arm the ground jump. +func TestJumpingIsEdgeTriggeredByStartJumping(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + + sim.applyInput(state, InputState{Jumping: true}) + if state.Jumping { + t.Fatal("held jump must not set Jumping without StartJumping") + } + if !state.PressingJump { + t.Fatal("held jump must set PressingJump") + } + if !state.EffectiveJumping { + t.Fatal("held jump must set EffectiveJumping") + } + + sim.applyInput(state, InputState{StartJumping: true}) + if !state.Jumping { + t.Fatal("StartJumping must set Jumping") + } +} + +func TestEffectiveJumpingSources(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + for name, input := range map[string]InputState{ + "jumping": {Jumping: true}, + "autoJumpingInWater": {AutoJumpingInWater: true}, + "ascendBlock": {AscendBlock: true}, + } { + state := newBaseState() + sim.applyInput(state, input) + if !state.EffectiveJumping { + t.Fatalf("%s must set EffectiveJumping", name) + } + } + + state := newBaseState() + sim.applyInput(state, InputState{StartJumping: true}) + if state.EffectiveJumping { + t.Fatal("StartJumping alone must not set EffectiveJumping") + } +} + +// --- swim state ------------------------------------------------------------ + +// SwimAmount interpolates by 0.1 per tick, using the swimming state as it was +// at the start of the tick. +func TestSwimAmountInterpolation(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + + // The first StartSwimming tick still decays, because wasSwimming is false. + sim.applyInput(state, InputState{StartSwimming: true}) + if !state.Swimming { + t.Fatal("StartSwimming must set Swimming") + } + if !approxEqual(state.SwimAmount, 0) { + t.Fatalf("SwimAmount = %v, want 0", state.SwimAmount) + } + + for i := 1; i <= 3; i++ { + sim.applyInput(state, InputState{}) + if want := float64(i) * 0.1; !approxEqual(state.SwimAmount, want) { + t.Fatalf("tick %d: SwimAmount = %v, want %v", i, state.SwimAmount, want) + } + } + + sim.applyInput(state, InputState{StopSwimming: true}) + if state.Swimming { + t.Fatal("StopSwimming must clear Swimming") + } + if !approxEqual(state.SwimAmount, 0.4) { + t.Fatalf("SwimAmount = %v, want 0.4 (still rising on the stop tick)", state.SwimAmount) + } + sim.applyInput(state, InputState{}) + if !approxEqual(state.SwimAmount, 0.3) { + t.Fatalf("SwimAmount = %v, want 0.3", state.SwimAmount) + } +} + +func TestSwimAmountClampedToUnitRange(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + state.Swimming = true + for range 30 { + sim.applyInput(state, InputState{}) + } + if !approxEqual(state.SwimAmount, 1) { + t.Fatalf("SwimAmount = %v, want clamped to 1", state.SwimAmount) + } + + state.Swimming = false + for range 30 { + sim.applyInput(state, InputState{}) + } + if !approxEqual(state.SwimAmount, 0) { + t.Fatalf("SwimAmount = %v, want clamped to 0", state.SwimAmount) + } +} + +// StartSwimming cancels sneaking, since the two poses are mutually exclusive. +func TestStartSwimmingClearsSneaking(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + sim.applyInput(state, InputState{SneakDown: true, StartSneaking: true, StartSwimming: true}) + if state.Sneaking { + t.Fatal("StartSwimming must clear Sneaking") + } +} + +// StopSwimming wins when both flags arrive in the same tick. +func TestStopSwimmingTakesPriority(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := newBaseState() + state.Swimming = true + sim.applyInput(state, InputState{StartSwimming: true, StopSwimming: true}) + if state.Swimming { + t.Fatal("StopSwimming must take priority over StartSwimming") + } +} + +// --- water physics --------------------------------------------------------- + +// A player resting in still water sinks at the water gravity rate, with water +// drag applied before gravity. +func TestWaterDragAndGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) + + // Second tick: previous velocity is dragged by 0.8, then gravity applies. + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005*0.8 - 0.005, 0}) +} + +// Sprinting in water raises horizontal drag from 0.8 to 0.9. +func TestWaterSprintDrag(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + + normal := submergedState() + normal.Vel = mgl64.Vec3{0.5, 0, 0} + sim.SimulateState(normal) + + sprinting := submergedState() + sprinting.Vel = mgl64.Vec3{0.5, 0, 0} + sprinting.Sprinting = true + sim.SimulateState(sprinting) + + if !approxEqual(normal.Vel.X(), 0.5*0.8) { + t.Fatalf("walking drag: X = %v, want %v", normal.Vel.X(), 0.5*0.8) + } + if !approxEqual(sprinting.Vel.X(), 0.5*0.9) { + t.Fatalf("sprinting drag: X = %v, want %v", sprinting.Vel.X(), 0.5*0.9) + } +} + +// Vertical drag in water is always 0.8, independent of sprinting. +func TestWaterVerticalDragIndependentOfSprint(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Vel = mgl64.Vec3{0, 0.5, 0} + state.Sprinting = true + sim.SimulateState(state) + + assertVec(t, state.Vel, mgl64.Vec3{0, 0.5*0.8 - 0.005, 0}) +} + +// Lava uses a flat 0.5 drag on every axis and a heavier 0.02 gravity. +func TestLavaDragAndGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + state.Vel = mgl64.Vec3{0.4, 0.4, 0.4} + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0.2, 0.4*0.5 - 0.02, 0.2}) +} + +// Swimming removes water gravity entirely. +func TestSwimmingCancelsWaterGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{0, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("swimming vertical velocity = %v, want 0", state.Vel.Y()) + } +} + +// Gravity is skipped entirely when the state has no gravity. +func TestNoGravityInLiquid(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.HasGravity = false + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{}) +} + +// Levitation replaces liquid gravity with a pull toward the levitation target. +func TestLevitationOverridesLiquidGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Effects = levitationEffects{amplifier: 0} + state := submergedState() + + sim.SimulateState(state) + // target = 0.05 * (0+1); vel += (target - vel) * 0.2 + assertVec(t, state.Vel, mgl64.Vec3{0, 0.05 * 0.2, 0}) +} + +func TestLevitationAmplifierScales(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Effects = levitationEffects{amplifier: 3} + state := submergedState() + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, (LevitationGravityMultiplier * 4) * 0.2, 0}) +} + +// A nil effects provider must not panic and must fall back to gravity. +func TestNilEffectsProviderFallsBackToGravity(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Effects = nil + state := submergedState() + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// Falling into liquid clears accumulated fall distance. +func TestLiquidResetsFallDistance(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.FallDistance = 12 + + sim.SimulateState(state) + if state.FallDistance != 0 { + t.Fatalf("FallDistance = %v, want 0", state.FallDistance) + } +} + +// --- jump / descend inputs in liquid --------------------------------------- + +// Holding jump underwater adds a fixed 0.04 ascent impulse. +func TestEffectiveJumpingAscendsInWater(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.EffectiveJumping = true + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, 0.04*0.8 - 0.005, 0}) +} + +// Mid-transition into the swim pose zeroes the ascent instead of applying it. +func TestSwimTransitionZeroesJumpAscent(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.EffectiveJumping = true + state.SwimAmount = 0.5 + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// A fully-transitioned swimmer still ascends normally. +func TestFullSwimAmountStillAscends(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.EffectiveJumping = true + state.SwimAmount = 1 + + sim.SimulateState(state) + if state.Vel.Y() <= 0 { + t.Fatalf("vertical velocity = %v, want positive ascent", state.Vel.Y()) + } +} + +// WantDown and WantDownSlow each sink the player by 0.04 before drag. +func TestWantDownSinksInWater(t *testing.T) { + for name, apply := range map[string]func(*MovementState){ + "wantDown": func(s *MovementState) { s.WantDown = true }, + "wantDownSlow": func(s *MovementState) { s.WantDownSlow = true }, + } { + t.Run(name, func(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + apply(state) + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.04*0.8 - 0.005, 0}) + }) + } +} + +// The descend inputs are water-only; lava ignores them. +func TestWantDownIgnoredInLava(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + state.WantDown = true + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.02, 0}) +} + +// The descend inputs must not alter the sneak impulse clamp. Upstream dropped +// the clamp entirely in this PR, but that is a breaking, non-liquid API change +// that bedsim deliberately does not follow, so behavior stays as in v0.1.3. +func TestDescendInputsDoNotChangeSneakImpulseClamp(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + + sneaking := newBaseState() + sim.applyInput(sneaking, InputState{SneakDown: true, MoveVector: mgl64.Vec2{0, 1}}) + + descending := newBaseState() + sim.applyInput(descending, InputState{SneakDown: true, WantDown: true, MoveVector: mgl64.Vec2{0, 1}}) + + if !approxEqual(descending.Impulse.Y(), sneaking.Impulse.Y()) { + t.Fatalf("descending impulse %v must match sneaking impulse %v", + descending.Impulse.Y(), sneaking.Impulse.Y()) + } + if !approxEqual(sneaking.Impulse.Y(), MaxSneakImpulse*0.98) { + t.Fatalf("sneaking impulse = %v, want %v", sneaking.Impulse.Y(), MaxSneakImpulse*0.98) + } +} + +// --- swim travel (pitch steering) ------------------------------------------ + +// While swimming, pitch steers vertical velocity toward -sin(pitch). +func TestSwimTravelFollowsPitch(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{-90, 0, 0} // looking straight up + + sim.SimulateState(state) + // targetY = -sin(-90deg) = 1; vel += (1 - 0) * 0.06, then drag 0.8. + assertVec(t, state.Vel, mgl64.Vec3{0, 0.06 * 0.8, 0}) +} + +// A steep downward pitch uses the faster 0.085 interpolation rate. +func TestSwimTravelUsesFasterRateWhenDivingSteeply(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{90, 0, 0} // looking straight down + + sim.SimulateState(state) + // targetY = -sin(90deg) = -1, below -0.2 so rate is 0.085. + assertVec(t, state.Vel, mgl64.Vec3{0, -0.085 * 0.8, 0}) +} + +// Swim travel is suppressed while jumping, letting the jump impulse win. +func TestSwimTravelSkippedWhileJumping(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.EffectiveJumping = true + state.SwimAmount = 1 + state.Rotation = mgl64.Vec3{90, 0, 0} + + sim.SimulateState(state) + // Pitch steering skipped, so only the 0.04 jump impulse applies. + assertVec(t, state.Vel, mgl64.Vec3{0, 0.04 * 0.8, 0}) +} + +// Swimming upward at the surface stops the climb once the head clears the +// liquid, preventing the player from swimming out into open air. +func TestSwimTravelStopsAtSurface(t *testing.T) { + // Liquid only below the player's head-check probes. + w := newLiquidWorld().fill(cube.Pos{-2, -4, -2}, cube.Pos{2, 0, 2}, waterSource) + sim := newLiquidSim(w) + state := submergedState() + // Both head probes (+0.52 and +0.42) clear the liquid surface at y=1. + state.Pos = mgl64.Vec3{0.5, 1.5, 0.5} + state.Swimming = true + state.Rotation = mgl64.Vec3{-90, 0, 0} + state.Vel = mgl64.Vec3{0, 0.5, 0} + + sim.SimulateState(state) + // Vertical velocity is zeroed at the surface, and swimming has no gravity. + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("surface vertical velocity = %v, want 0", state.Vel.Y()) + } +} + +// While the head is still submerged the climb continues normally. +func TestSwimTravelContinuesWhileHeadSubmerged(t *testing.T) { + w := newLiquidWorld().fill(cube.Pos{-2, -4, -2}, cube.Pos{2, 0, 2}, waterSource) + sim := newLiquidSim(w) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{-90, 0, 0} + state.Vel = mgl64.Vec3{0, 0.5, 0} + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0) { + t.Fatal("a submerged head must not trigger the surface clamp") + } +} + +// WantDownSlow suppresses the surface clamp so the player can hover. +func TestSwimTravelSurfaceClampSkippedWhenWantDownSlow(t *testing.T) { + w := newLiquidWorld().fill(cube.Pos{-2, -4, -2}, cube.Pos{2, 0, 2}, waterSource) + sim := newLiquidSim(w) + state := submergedState() + state.Pos = mgl64.Vec3{0.5, 1.5, 0.5} + state.Swimming = true + state.Rotation = mgl64.Vec3{-90, 0, 0} + state.WantDownSlow = true + state.Vel = mgl64.Vec3{0, 0.5, 0} + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0) { + t.Fatal("WantDownSlow must skip the surface clamp") + } +} + +// --- depth strider --------------------------------------------------------- + +// Depth Strider lowers the horizontal drag coefficient toward 0.546, so +// existing momentum decays faster rather than slower. +func TestDepthStriderLowersDragCoefficient(t *testing.T) { + base := newLiquidSim(filledColumn(waterSource)) + baseState := submergedState() + baseState.Vel = mgl64.Vec3{0.5, 0, 0} + base.SimulateState(baseState) + + strider := newLiquidSim(filledColumn(waterSource)) + strider.Inventory = depthStriderInventory{level: 3} + striderState := submergedState() + striderState.Vel = mgl64.Vec3{0.5, 0, 0} + striderState.OnGround = true + strider.SimulateState(striderState) + + if !approxEqual(baseState.Vel.X(), 0.5*0.8) { + t.Fatalf("base drag X = %v, want %v", baseState.Vel.X(), 0.5*0.8) + } + // Level 3 on the ground: drag = 0.8 + (0.54600006 - 0.8) * 1. + if !approxEqual(striderState.Vel.X(), 0.5*0.54600006) { + t.Fatalf("depth strider drag X = %v, want %v", striderState.Vel.X(), 0.5*0.54600006) + } + if !(striderState.Vel.X() < baseState.Vel.X()) { + t.Fatalf("depth strider X = %v must decay faster than base X = %v", + striderState.Vel.X(), baseState.Vel.X()) + } +} + +// Depth Strider raises water acceleration from the underwater speed toward the +// player's full movement speed. +func TestDepthStriderIncreasesAcceleration(t *testing.T) { + base := newLiquidSim(filledColumn(waterSource)) + baseState := submergedState() + baseState.Impulse = mgl64.Vec2{0, 0.98} + base.SimulateState(baseState) + + strider := newLiquidSim(filledColumn(waterSource)) + strider.Inventory = depthStriderInventory{level: 3} + striderState := submergedState() + striderState.Impulse = mgl64.Vec2{0, 0.98} + striderState.OnGround = true + strider.SimulateState(striderState) + + if !(math.Abs(striderState.Vel.Z()) > math.Abs(baseState.Vel.Z())) { + t.Fatalf("depth strider Z = %v must exceed base Z = %v", + striderState.Vel.Z(), baseState.Vel.Z()) + } +} + +// Depth Strider is halved while airborne (not standing on the floor). +func TestDepthStriderHalvedWhenAirborne(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Inventory = depthStriderInventory{level: 3} + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + state.OnGround = false + + sim.SimulateState(state) + // level 1.5 -> fraction 0.5 -> drag = 0.8 + (0.54600006 - 0.8) * 0.5. + want := 0.5 * (0.8 + (0.54600006-0.8)*0.5) + if !approxEqual(state.Vel.X(), want) { + t.Fatalf("airborne depth strider X = %v, want %v", state.Vel.X(), want) + } +} + +// Depth Strider levels above the enchantment maximum are clamped to 3. +func TestDepthStriderClampedToMaxLevel(t *testing.T) { + clamped := newLiquidSim(filledColumn(waterSource)) + clamped.Inventory = depthStriderInventory{level: 99} + clampedState := submergedState() + clampedState.Vel = mgl64.Vec3{0.5, 0, 0} + clampedState.OnGround = true + clamped.SimulateState(clampedState) + + if !approxEqual(clampedState.Vel.X(), 0.5*0.54600006) { + t.Fatalf("clamped depth strider X = %v, want level-3 behavior", clampedState.Vel.X()) + } +} + +// A negative level must not amplify movement. +func TestDepthStriderNegativeLevelIgnored(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Inventory = depthStriderInventory{level: -5} + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.X(), 0.5*0.8) { + t.Fatalf("negative depth strider X = %v, want plain water drag", state.Vel.X()) + } +} + +// Depth Strider only applies in water, never in lava. +func TestDepthStriderIgnoredInLava(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + sim.Inventory = depthStriderInventory{level: 3} + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.X(), 0.5*0.5) { + t.Fatalf("lava X = %v, want flat 0.5 drag", state.Vel.X()) + } +} + +// An inventory that does not implement DepthStriderProvider must be ignored. +func TestInventoryWithoutDepthStriderProvider(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Inventory = mockInventory{} + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.X(), 0.5*0.8) { + t.Fatalf("X = %v, want plain water drag", state.Vel.X()) + } +} + +// --- dolphin boost / swim speed multiplier --------------------------------- + +// A dolphin boost raises the swim speed multiplier, which only takes effect +// while actually swimming. +func TestSwimSpeedMultiplierRequiresSwimming(t *testing.T) { + boosted := newLiquidSim(filledColumn(waterSource)) + boostedState := submergedState() + boostedState.Swimming = true + boostedState.SwimSpeedMultiplier = 2 + boostedState.Impulse = mgl64.Vec2{0, 0.98} + boosted.SimulateState(boostedState) + + plain := newLiquidSim(filledColumn(waterSource)) + plainState := submergedState() + plainState.Swimming = true + plainState.SwimSpeedMultiplier = 1 + plainState.Impulse = mgl64.Vec2{0, 0.98} + plain.SimulateState(plainState) + + if !(math.Abs(boostedState.Vel.Z()) > math.Abs(plainState.Vel.Z())) { + t.Fatalf("boosted Z = %v must exceed plain Z = %v", boostedState.Vel.Z(), plainState.Vel.Z()) + } + + notSwimming := newLiquidSim(filledColumn(waterSource)) + notSwimmingState := submergedState() + notSwimmingState.SwimSpeedMultiplier = 2 + notSwimmingState.Impulse = mgl64.Vec2{0, 0.98} + notSwimming.SimulateState(notSwimmingState) + + if !approxEqual(notSwimmingState.Vel.Z(), plainState.Vel.Z()) { + t.Fatalf("non-swimming Z = %v, want unboosted %v", notSwimmingState.Vel.Z(), plainState.Vel.Z()) + } +} + +// The dolphin boost expires on its own tick counter and restores the default +// multiplier when it runs out. +func TestDolphinBoostTicksExpire(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.DolphinBoostTicks = 2 + state.SwimSpeedMultiplier = 2 + + sim.Simulate(state, InputState{}) + if state.DolphinBoostTicks != 1 { + t.Fatalf("DolphinBoostTicks = %d, want 1", state.DolphinBoostTicks) + } + if !approxEqual(state.SwimSpeedMultiplier, 2) { + t.Fatalf("SwimSpeedMultiplier = %v, want still boosted", state.SwimSpeedMultiplier) + } + + sim.Simulate(state, InputState{}) + if state.DolphinBoostTicks != 0 { + t.Fatalf("DolphinBoostTicks = %d, want 0", state.DolphinBoostTicks) + } + if !approxEqual(state.SwimSpeedMultiplier, DefaultSwimSpeedMultiplier) { + t.Fatalf("SwimSpeedMultiplier = %v, want reset to %v", state.SwimSpeedMultiplier, DefaultSwimSpeedMultiplier) + } +} + +// An unset multiplier must not zero out water acceleration. +func TestZeroSwimSpeedMultiplierTreatedAsDefault(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.SwimSpeedMultiplier = 0 + state.Impulse = mgl64.Vec2{0, 0.98} + sim.SimulateState(state) + + explicit := newLiquidSim(filledColumn(waterSource)) + explicitState := submergedState() + explicitState.Swimming = true + explicitState.SwimSpeedMultiplier = DefaultSwimSpeedMultiplier + explicitState.Impulse = mgl64.Vec2{0, 0.98} + explicit.SimulateState(explicitState) + + assertVec(t, state.Vel, explicitState.Vel) +} + +// Unset movement speeds fall back to the documented defaults. +func TestZeroMovementSpeedsUseDefaults(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.UnderwaterMovementSpeed = 0 + state.Impulse = mgl64.Vec2{0, 0.98} + sim.SimulateState(state) + + explicit := newLiquidSim(filledColumn(waterSource)) + explicitState := submergedState() + explicitState.UnderwaterMovementSpeed = DefaultUnderwaterMovementSpeed + explicitState.Impulse = mgl64.Vec2{0, 0.98} + explicit.SimulateState(explicitState) + + assertVec(t, state.Vel, explicitState.Vel) +} + +// --- liquid detection ------------------------------------------------------ + +// Water is detected through a shallow vertical offset, so a player standing on +// top of a water block is still considered to be in water. +func TestWaterDetectedAtFeet(t *testing.T) { + sim := newLiquidSim(newLiquidWorld().set(cube.Pos{0, 0, 0}, waterSource)) + state := submergedState() + + if got := len(sim.touchingLiquidBlocks(state, "water")); got != 1 { + t.Fatalf("water blocks = %d, want 1", got) + } +} + +// Lava uses a wider horizontal shrink than water, so a player at the very edge +// of a lava block touches water but not lava. +func TestLavaUsesWiderHorizontalMargin(t *testing.T) { + w := newLiquidWorld().set(cube.Pos{0, 0, 0}, waterSource).set(cube.Pos{1, 0, 0}, lavaSource) + sim := newLiquidSim(w) + state := submergedState() + // Position the player so the box only just reaches into x=1. + state.Pos = mgl64.Vec3{0.75, 0.5, 0.5} + + water := sim.touchingLiquidBlocks(state, "water") + lava := sim.touchingLiquidBlocks(state, "lava") + if len(water) == 0 { + t.Fatal("expected water contact") + } + if len(lava) != 0 { + t.Fatalf("lava blocks = %d, want 0 due to the wider lava margin", len(lava)) + } +} + +// Liquid type filtering must not mix water and lava. +func TestLiquidTypeFiltering(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + + if got := len(sim.touchingLiquidBlocks(state, "water")); got != 0 { + t.Fatalf("water blocks = %d, want 0 in a lava column", got) + } + if got := len(sim.touchingLiquidBlocks(state, "lava")); got == 0 { + t.Fatal("expected lava contact") + } +} + +// Water travel takes priority when a player touches both liquids. +func TestWaterTakesPriorityOverLava(t *testing.T) { + w := newLiquidWorld(). + fill(cube.Pos{-2, 0, -2}, cube.Pos{2, 3, 2}, waterSource). + set(cube.Pos{0, 0, 0}, lavaSource) + sim := newLiquidSim(w) + state := submergedState() + + sim.SimulateState(state) + // Water gravity (0.005), not lava gravity (0.02). + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// --- provider fallbacks ---------------------------------------------------- + +// Without a LiquidProvider, liquids are read from WorldProvider.Block. +func TestLiquidFallsBackToBlockProvider(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// A LiquidProvider exposes waterlogged blocks whose main layer is a solid. +func TestLiquidProviderDetectsWaterloggedBlocks(t *testing.T) { + w := newLayeredLiquidWorld() + for y := range 4 { + w.waterlog(cube.Pos{0, y, 0}, block.Air{}, waterSource) + } + sim := newLiquidSim(w) + state := submergedState() + + if got := len(sim.touchingLiquidBlocks(state, "water")); got == 0 { + t.Fatal("expected waterlogged blocks to register as water") + } + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// A world with no liquids at all must run normal (non-liquid) physics. +func TestNoLiquidRunsNormalPhysics(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := submergedState() + // Gravity is normally seeded by applyInput; SimulateState uses state as-is. + state.Gravity = NormalGravity + + sim.SimulateState(state) + // Normal air gravity subtracts first and then applies the 0.98 multiplier, + // unlike the liquid path which applies drag before gravity. + if approxEqual(state.Vel.Y(), -0.005) { + t.Fatal("dry world must not use liquid gravity") + } + if !approxEqual(state.Vel.Y(), -NormalGravity*NormalGravityMultiplier) { + t.Fatalf("vertical velocity = %v, want normal gravity", state.Vel.Y()) + } +} + +// Unloaded chunks must fail safe and cancel movement rather than simulating. +func TestUnloadedChunkCancelsLiquidSimulation(t *testing.T) { + w := filledColumn(waterSource) + w.chunkLoaded = false + sim := newLiquidSim(w) + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0.5, 0.5} + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk", result.Outcome) + } + assertVec(t, state.Vel, mgl64.Vec3{}) +} + +// Being inside a liquid is a reliable scenario; v0.1.3 bailed out here. +func TestLiquidIsReliableScenario(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + result := sim.SimulateState(state) + if result.Outcome == SimulationOutcomeUnreliable { + t.Fatal("liquid contact must not mark the simulation unreliable") + } +} + +// --- flying and gliding ---------------------------------------------------- + +// Flying players ignore liquid physics entirely. +func TestFlyingSkipsLiquidPhysics(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Flying = true + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), -0.005) { + t.Fatal("flying must not apply liquid gravity") + } +} + +// Entering water cancels an active glide and its boost. +func TestWaterCancelsGliding(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Gliding = true + state.GlideBoostTicks = 15 + + sim.SimulateState(state) + if state.Gliding { + t.Fatal("water must cancel gliding") + } + if state.GlideBoostTicks != 0 { + t.Fatalf("GlideBoostTicks = %d, want 0", state.GlideBoostTicks) + } +} + +// A glide boost held by a player who is not gliding survives water contact. +func TestWaterPreservesGlideBoostWhenNotGliding(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Gliding = false + state.GlideBoostTicks = 15 + + sim.SimulateState(state) + if state.GlideBoostTicks != 15 { + t.Fatalf("GlideBoostTicks = %d, want 15 preserved when not gliding", state.GlideBoostTicks) + } +} + +// Lava does not cancel gliding; only water travel does. +func TestLavaDoesNotCancelGliding(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + state.Gliding = true + state.GlideBoostTicks = 15 + + sim.SimulateState(state) + if !state.Gliding { + t.Fatal("lava must not cancel gliding") + } + if state.GlideBoostTicks != 15 { + t.Fatalf("GlideBoostTicks = %d, want 15", state.GlideBoostTicks) + } +} + +// A swimming player keeps water physics even after leaving the water blocks, +// until the client reports that swimming stopped. +func TestSwimmingPreservesWaterTravelOutsideWater(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := submergedState() + state.Swimming = true + state.Rotation = mgl64.Vec3{0, 0, 0} + + sim.SimulateState(state) + // Water travel with swimming: no gravity at all. + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("vertical velocity = %v, want water travel (0)", state.Vel.Y()) + } +} + +// Jumping while swimming outside the water is suppressed, so the player cannot +// gain height in open air. +func TestSwimmingOutsideWaterSuppressesJump(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := submergedState() + state.Swimming = true + state.SwimAmount = 1 + state.EffectiveJumping = true + + sim.SimulateState(state) + if state.Vel.Y() > 0 { + t.Fatalf("vertical velocity = %v, want no ascent outside water", state.Vel.Y()) + } +} + +// --- flow ------------------------------------------------------------------ + +// Flowing water pushes the player toward the lower-depth neighbour. +func TestLiquidFlowPushesTowardLowerDepth(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Water{Depth: 7}). + set(cube.Pos{-1, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{0, 0, 1}, block.Water{Depth: 8}). + set(cube.Pos{0, 0, -1}, block.Water{Depth: 8}) + sim := newLiquidSim(w) + state := submergedState() + + sim.SimulateState(state) + if !(state.Vel.X() > 0) { + t.Fatalf("X velocity = %v, want a positive push toward the shallower neighbour", state.Vel.X()) + } +} + +// Water flow strength is 0.014 per tick along the normalized flow vector. +func TestWaterFlowStrength(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Water{Depth: 7}) + sim := newLiquidSim(w) + state := submergedState() + + sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, "water"), "water") + if !approxEqual(state.Vel.X(), 0.014) { + t.Fatalf("flow X = %v, want 0.014", state.Vel.X()) + } +} + +// Lava flow is much weaker than water flow. +func TestLavaFlowStrength(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Lava{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Lava{Depth: 7}) + sim := newLiquidSim(w) + state := submergedState() + + sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, "lava"), "lava") + if !approxEqual(state.Vel.X(), 0.0035) { + t.Fatalf("flow X = %v, want 0.0035", state.Vel.X()) + } +} + +// A uniform liquid body produces no flow at all. +func TestUniformLiquidHasNoFlow(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, "water"), "water") + assertVec(t, state.Vel, mgl64.Vec3{}) +} + +// Falling liquid against a solid neighbour gains a strong downward component. +func TestFallingLiquidFlowsDownwardAlongSolids(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8, Falling: true}). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8, Falling: true}) + if !(flow.Y() < 0) { + t.Fatalf("falling liquid flow Y = %v, want negative", flow.Y()) + } +} + +// Non-falling liquid never gains the downward push. +func TestNonFallingLiquidHasNoDownwardFlow(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + if flow.Y() < 0 { + t.Fatalf("non-falling liquid flow Y = %v, want no downward push", flow.Y()) + } +} + +// A solid neighbour blocks flow in that direction rather than contributing. +func TestSolidNeighbourBlocksFlow(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Stone{}). + set(cube.Pos{1, -1, 0}, block.Water{Depth: 8}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + if !approxEqual(flow.X(), 0) { + t.Fatalf("flow X = %v, want 0 through a solid neighbour", flow.X()) + } +} + +// An open neighbour with liquid below pulls the flow into the drop. +func TestFlowFallsIntoOpenDrop(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{1, -1, 0}, block.Water{Depth: 8}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + if !(flow.X() > 0) { + t.Fatalf("flow X = %v, want a positive pull into the drop", flow.X()) + } +} + +// Flow below the epsilon threshold is discarded rather than applied. +func TestNegligibleFlowIgnored(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + before := state.Vel + + sim.applyLiquidFlow(state, nil, "water") + assertVec(t, state.Vel, before) +} + +// Falling liquid is treated as full-height with zero decay. +func TestFallingLiquidDecayAndHeight(t *testing.T) { + falling := block.Water{Depth: 3, Falling: true} + if got := liquidDecay(falling); got != 0 { + t.Fatalf("falling decay = %d, want 0", got) + } + if got := liquidHeight(falling); !approxEqual(got, 1) { + t.Fatalf("falling height = %v, want 1", got) + } + + still := block.Water{Depth: 8} + if got := liquidDecay(still); got != 0 { + t.Fatalf("source decay = %d, want 0", got) + } + if got := liquidHeight(still); !approxEqual(got, 1) { + t.Fatalf("source height = %v, want 1", got) + } + + shallow := block.Water{Depth: 1} + if got := liquidDecay(shallow); got != 7 { + t.Fatalf("shallow decay = %d, want 7", got) + } + if got := liquidHeight(shallow); !approxEqual(got, 2.0/9.0) { + t.Fatalf("shallow height = %v, want 2/9", got) + } +} + +// --- exit probing ---------------------------------------------------------- + +// Colliding horizontally with a ledge that has clear air above lets the player +// hop out of the liquid. +func TestLiquidExitProbeBoostsOverLedge(t *testing.T) { + w := newLiquidWorld(). + fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 0, 1}, waterSource). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + state.Impulse = mgl64.Vec2{0, 0.98} + + sim.SimulateState(state) + if !state.CollideX { + t.Fatal("expected a horizontal collision against the ledge") + } + if !approxEqual(state.Vel.Y(), 0.3) { + t.Fatalf("exit boost Y = %v, want 0.3", state.Vel.Y()) + } +} + +// A wall that continues above the player blocks the exit boost. +func TestLiquidExitProbeBlockedByWall(t *testing.T) { + w := newLiquidWorld(). + fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 2, 1}, waterSource). + fill(cube.Pos{1, 0, 0}, cube.Pos{1, 3, 0}, block.Stone{}) + sim := newLiquidSim(w) + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0.3) { + t.Fatal("a full wall must block the liquid exit boost") + } +} + +// Liquid above the probe also blocks the exit boost, since the player is still +// submerged rather than at the surface. +func TestLiquidExitProbeBlockedByLiquidAbove(t *testing.T) { + w := newLiquidWorld(). + fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 4, 1}, waterSource). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0.3) { + t.Fatal("liquid above the probe must block the exit boost") + } +} + +// Without a horizontal collision there is no exit probe at all. +func TestNoExitProbeWithoutHorizontalCollision(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0.3) { + t.Fatal("exit boost must require a horizontal collision") + } +} + +// --- ladder climbing ------------------------------------------------------- + +// Climbing is driven by EffectiveJumping, so the automatic ascent inputs climb +// a ladder just like a held jump key does. +func TestClimbUsesEffectiveJumping(t *testing.T) { + for name, apply := range map[string]func(*MovementState){ + "pressingJump": func(s *MovementState) { s.PressingJump = true; s.EffectiveJumping = true }, + "autoJumpingInWater": func(s *MovementState) { s.EffectiveJumping = true }, + "none": func(s *MovementState) {}, + } { + t.Run(name, func(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.BlockSemantics = overrideBlockSemantics{ + name: "minecraft:ladder", + friction: DefaultBlockFriction, + climbable: true, + } + state := submergedState() + apply(state) + + sim.SimulateState(state) + // Climb speed is set during the tick, then the end-of-tick gravity + // multiplier applies. + climbing := approxEqual(state.Vel.Y(), ClimbSpeed*NormalGravityMultiplier) + if name == "none" && climbing { + t.Fatal("no ascent input must not climb") + } + if name != "none" && !climbing { + t.Fatalf("vertical velocity = %v, want ClimbSpeed", state.Vel.Y()) + } + }) + } +} + +// --- determinism ----------------------------------------------------------- + +// Liquid simulation must be deterministic for a given state, input and world +// snapshot. Flow accumulates over a set of block positions, so any reliance on +// map iteration order would surface here. +func TestLiquidSimulationIsDeterministic(t *testing.T) { + build := func() (*Simulator, *MovementState) { + w := newLiquidWorld(). + fill(cube.Pos{-2, 0, -2}, cube.Pos{2, 2, 2}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Water{Depth: 6}). + set(cube.Pos{0, 0, 1}, block.Water{Depth: 4}). + set(cube.Pos{-1, 1, 0}, block.Water{Depth: 8, Falling: true}). + set(cube.Pos{2, 0, 2}, block.Stone{}) + sim := newLiquidSim(w) + sim.Inventory = depthStriderInventory{level: 2} + state := submergedState() + state.Swimming = true + state.SwimAmount = 0.5 + state.Rotation = mgl64.Vec3{25, 0, 40} + state.Impulse = mgl64.Vec2{0.5, 0.98} + state.SwimSpeedMultiplier = 2 + return sim, state + } + + sim, want := build() + for range 20 { + sim.Simulate(want, InputState{Jumping: true}) + } + + for run := range 5 { + sim, got := build() + for range 20 { + sim.Simulate(got, InputState{Jumping: true}) + } + if got.Pos != want.Pos || got.Vel != want.Vel { + t.Fatalf("run %d diverged: pos %v vs %v, vel %v vs %v", + run, got.Pos, want.Pos, got.Vel, want.Vel) + } + } +} + +// --- box shrinking --------------------------------------------------------- + +// Shrinking a box past its own size collapses it to its midpoint instead of +// inverting it. +func TestShrinkLiquidBoxCollapsesToMidpoint(t *testing.T) { + box := cube.Box(0, 0, 0, 1, 0.2, 1) + shrunk := shrinkLiquidBox(box, mgl64.Vec3{0.001, 0.401, 0.001}) + + if !approxEqual(shrunk.Min().Y(), 0.1) || !approxEqual(shrunk.Max().Y(), 0.1) { + t.Fatalf("collapsed Y = [%v %v], want [0.1 0.1]", shrunk.Min().Y(), shrunk.Max().Y()) + } + if !approxEqual(shrunk.Min().X(), 0.001) || !approxEqual(shrunk.Max().X(), 0.999) { + t.Fatalf("X = [%v %v], want [0.001 0.999]", shrunk.Min().X(), shrunk.Max().X()) + } +} diff --git a/movement.go b/movement.go index 6a2156f..f59d13b 100644 --- a/movement.go +++ b/movement.go @@ -41,8 +41,13 @@ type MovementState struct { AirSpeed float64 UnderwaterMovementSpeed float64 LavaMovementSpeed float64 - SwimSpeedMultiplier float64 - ServerUpdatedSpeed bool + // SwimSpeedMultiplier scales water acceleration while swimming. A dolphin + // boost sets this to 2. Zero is treated as the default of 1. + SwimSpeedMultiplier float64 + // DolphinBoostTicks counts down the remaining duration of a dolphin boost. + // When it reaches zero, SwimSpeedMultiplier is reset to its default. + DolphinBoostTicks int64 + ServerUpdatedSpeed bool Knockback mgl64.Vec3 TicksSinceKnockback uint64 diff --git a/simulation.go b/simulation.go index d0b996e..13e89a6 100644 --- a/simulation.go +++ b/simulation.go @@ -193,7 +193,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { if input.UsingConsumable { maxImpulse *= MaxConsumingImpulse } - if state.Sneaking && !state.WantDown && !state.WantDownSlow { + if state.Sneaking { maxImpulse *= MaxSneakImpulse } moveVector := mgl64.Vec2{ @@ -201,7 +201,10 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { ClampFloat(input.MoveVector[1], -maxImpulse, maxImpulse), } - state.Jumping = input.StartJumping || input.Jumping + // Jumping is edge-triggered: only the start-jump flag arms a ground jump. + // EffectiveJumping covers the held-key and automatic ascent cases used by + // liquid travel and ladders. + state.Jumping = input.StartJumping state.PressingJump = input.Jumping state.EffectiveJumping = input.Jumping || input.AutoJumpingInWater || input.AscendBlock state.JumpHeight = DefaultJumpHeight @@ -256,6 +259,13 @@ func (s *Simulator) tickState(state *MovementState) { if state.GlideBoostTicks > 0 { state.GlideBoostTicks-- } + if state.DolphinBoostTicks > 0 { + state.DolphinBoostTicks-- + if state.DolphinBoostTicks <= 0 { + state.DolphinBoostTicks = 0 + state.SwimSpeedMultiplier = DefaultSwimSpeedMultiplier + } + } state.TicksSinceKnockback++ if state.TicksSinceTeleport < math.MaxUint64 { state.TicksSinceTeleport++ @@ -277,8 +287,10 @@ func (s *Simulator) simulateMovement(state *MovementState) { if !state.Flying && (waterTravel || len(lavaBlocks) != 0) { s.debugfIf(attemptKnockback(state), "knockback applied in liquid: %v", state.Vel) if waterTravel { - state.Gliding = false - state.GlideBoostTicks = 0 + if state.Gliding { + state.Gliding = false + state.GlideBoostTicks = 0 + } s.applyLiquidFlow(state, waterBlocks, "water") s.simulateLiquidTravel(state, true, len(waterBlocks) != 0) } else { @@ -339,7 +351,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { newVel[1] = 0 } state.SetVel(newVel) - s.debugf("added climb velocity: %v (collided=%v pressingJump=%v)", newVel, state.CollideX || state.CollideZ, state.PressingJump) + s.debugf("added climb velocity: %v (collided=%v effectiveJumping=%v)", newVel, state.CollideX || state.CollideZ, state.EffectiveJumping) } inCobweb := s.isInsideCobweb(state) From 0202be6494a20251fd7738d4b7f007386ae3df0c Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Mon, 20 Jul 2026 00:46:40 -0700 Subject: [PATCH 5/7] fix(simulation): harden liquid state against client-controlled flags Addresses an independent review of the liquid port. The physics remain a 1:1 port of oomph-ac/oomph PR #145; these changes cover the gap between a proxy-embedded simulator that can trust its anticheat and a standalone authoritative library that cannot. Critical: bound water travel on server evidence. Upstream gates water travel on `touchingWater || Swimming`, and sizes the hitbox off the same flag. Since Swimming comes straight from the client, a latched flag granted indefinite zero-gravity hovering in open air with no correction raised, and shrank the server-side hitbox from 1.8 to 0.6 so the player fit through gaps a standing player cannot. Verified against the previous commit: 60 ticks of spoofed swimming in a dry world ended with velocity 0 and zero altitude lost. Both the water-travel branch and the swim pose now require recent server-observed water contact, bounded by SwimWaterGraceTicks (default 10). The budget refills only on real contact, is clamped to its bound before anything reads it, and is cleared on every frame that was not simulated: unreliable, unloaded chunk, immobile, and teleport. Lava the player is actually standing in takes priority over a retained water grace. The budget is constant across a tick, so collision, liquid detection and exit probing always agree on one hitbox; the cost is that entering water adopts the swim pose one tick later than upstream, erring toward the larger box. Important: make liquid-layer support explicit and checkable. Layer-1 liquids were discovered only by type-asserting World, so a missing or mistyped implementation silently mis-simulated waterlogged blocks as dry. Adds an explicit Simulator.Liquids field, HasLiquidLayer, and an opt-in RequireLiquidLayer that fails closed with SimulationOutcomeUnreliable. The World type assertion still works as a fallback for existing integrations. Important: replace tests that did not distinguish behavior. - TestFlyingSkipsLiquidPhysics was vacuous, since simulationIsReliable exits before physics. Split into a test for that contract and one exercising the liquid gate directly. - TestLiquidSimulationIsDeterministic compared the implementation to itself. Replaced by a pinned golden, plus a repeat-run check for nondeterminism. - TestLiquidExitProbeBlockedByWall had liquid in the probe box, so it could not attribute the result. Rebuilt to isolate the collision term, verifying the probe box is liquid-free. Adds coverage for stairs solid-face flow blocking, the nil-world guard, the +8 drop weight and -6 falling magnitude, swim-hitbox collision interaction, and the DefaultSwimWaterGraceTicks literal. Each new security branch was confirmed by mutation to fail a test when removed. Also: surfaces the sneak/consuming impulse-clamp divergence in the README and adds SimulationOptions.UpstreamImpulseClamping to opt into upstream's behavior without a breaking change; captures EffectiveJumping before updateSwimTravel to match upstream ordering; replaces the stringly liquid type with an internal liquidKind; documents the defensive depth-strider and nil-world adaptations; deprecates the unused MaxNormalizedImpulse; and drops a nil check on a type assertion that could never fire. Co-Authored-By: Claude Opus 4.8 --- README.md | 65 +++- bbox.go | 19 +- constants.go | 16 +- liquid.go | 95 +++++- liquid_hardening_test.go | 685 +++++++++++++++++++++++++++++++++++++++ liquid_test.go | 198 ++++++----- movement.go | 9 +- simulation.go | 74 ++++- simulator.go | 46 ++- 9 files changed, 1092 insertions(+), 115 deletions(-) create mode 100644 liquid_hardening_test.go diff --git a/README.md b/README.md index bf3ffda..f868e70 100644 --- a/README.md +++ b/README.md @@ -51,12 +51,14 @@ Implement provider adapters to bridge your world and player systems: sim := bedsim.Simulator{ World: myWorldProvider, // block lookups, collisions, chunk-loaded checks BlockSemantics: myBlockSemantics, // optional: per-world names, friction, climbability + Liquids: myLiquidProvider, // second block layer (waterlogged blocks) Effects: myEffectsProvider, // jump boost, levitation, slow falling Inventory: myInventoryProvider, // elytra equipped check Options: bedsim.SimulationOptions{ Mode: bedsim.SimulationModeAuthoritative, PositionCorrectionThreshold: 0.5, VelocityCorrectionThreshold: 0.5, + RequireLiquidLayer: true, }, } @@ -71,10 +73,21 @@ registry or custom block data instead of bedsim's Dragonfly-backed defaults. Custom friction values must be finite and positive; invalid values fall back to Dragonfly defaults. -Implement `LiquidProvider` on the world adapter to expose liquids from both -block layers, including waterlogged blocks. Without it, bedsim falls back to -liquids returned by `WorldProvider.Block`. Implement `DepthStriderProvider` on -the inventory adapter when Depth Strider should affect water movement. +Implement `DepthStriderProvider` on the inventory adapter when Depth Strider +should affect water movement. + +> **Set `Liquids` if you simulate authoritatively.** Bedrock stores waterlogged +> blocks as a liquid in the *second* block layer. Without a `LiquidProvider`, +> bedsim only sees liquids returned by `WorldProvider.Block`, so waterlogged +> blocks look dry and the player is simulated with air physics inside them. +> Check `sim.HasLiquidLayer()` at startup, or set +> `SimulationOptions.RequireLiquidLayer` to make the simulator return +> `SimulationOutcomeUnreliable` rather than silently mis-simulate. +> +> If `Liquids` is nil and `World` itself implements `LiquidProvider`, that is +> used instead. This keeps pre-existing integrations working, but it is +> discovered by type assertion, so a signature typo degrades silently — prefer +> the explicit field. ### Liquid movement @@ -112,9 +125,49 @@ Set `DolphinBoostTicks` when the client receives a dolphin boost. `Simulate` counts it down and restores `SwimSpeedMultiplier` to its default on expiry; callers using `SimulateState` must manage that lifecycle themselves. -While `Swimming` is set, the bounding box collapses to a width-sized cube, +While the swim pose is active the bounding box collapses to a width-sized cube, matching the client's swim pose. This affects collisions and liquid detection, -not just liquid travel. +not just liquid travel. The pose requires both `Swimming` and recent +server-observed water contact — see `MovementState.SwimPose` and the divergence +note below. + +#### Divergences from the upstream source + +Two behaviors intentionally differ from oomph PR #145. + +**Swim water-travel grace (security hardening).** Upstream keys water travel on +`touchingWater || Swimming`, trusting the client's swimming flag because the +surrounding anticheat validates it. A standalone simulator cannot. Left as-is, +a client that latches the flag gets two things it should not: *zero gravity* +forever in open air with no correction raised, and a server-side hitbox +shrunk from 1.8 to 0.6, letting it fit through gaps a standing player cannot. + +bedsim gates **both** the water-travel branch and the swim pose on recent +server-observed water contact, bounded by +`SimulationOptions.SwimWaterGraceTicks` (default `DefaultSwimWaterGraceTicks`, +10). The budget refills on every tick the hitbox actually touches water, is +clamped to the configured bound before anything reads it, and is cleared on any +frame that was not simulated — unreliable, unloaded chunk, immobile, or +teleport. A negative value requires real water contact on every tick. Lava the +player is actually standing in takes priority over a retained water grace. + +Because the budget is applied at the start of a tick and decremented at the end, +it is constant for the whole tick, so collision, liquid detection and exit +probing always agree on one hitbox. The cost is that entering water adopts the +swim pose one tick later than upstream, erring toward the larger box. + +Players genuinely in water are unaffected. Two residual limits are worth +knowing: a client that reaches real water once every `SwimWaterGraceTicks` ticks +sustains water travel at up to a 10:1 duty cycle, so the guard bounds hovering +to the neighbourhood of actual water rather than eliminating it; and the pose +lag above is a deliberate one-tick divergence from upstream. Lower +`SwimWaterGraceTicks` to tighten both. + +**Impulse clamps.** Upstream removed `MaxSneakImpulse` and `MaxConsumingImpulse` +in this PR, clamping the move vector to `[-1, 1]` instead. bedsim keeps both by +default, because they are public API affecting all movement and removing them +would be a breaking change outside liquid scope. Set +`SimulationOptions.UpstreamImpulseClamping` to opt into upstream's behavior. ### Simulation modes diff --git a/bbox.go b/bbox.go index 1ff53ab..d7b7ae7 100644 --- a/bbox.go +++ b/bbox.go @@ -5,12 +5,27 @@ import ( "github.com/go-gl/mathgl/mgl64" ) +// SwimPose reports whether the collapsed swim hitbox applies. It requires the +// client's swimming flag *and* recent server-observed water contact, because +// the flag alone is client-controlled: without the second condition a client +// could shrink its server-side hitbox from 1.8 to 0.6 in open air and walk +// through gaps a standing player cannot fit. See SwimWaterGraceTicks. +// +// The grace budget is clamped to its configured bound at the start of a tick +// and decremented at the end, so this stays constant for the whole tick and +// collision, liquid detection and exit probing always agree on one hitbox. +// Entering water therefore adopts the swim pose one tick later than upstream, +// which errs toward the larger, more conservative box. +func (s *MovementState) SwimPose() bool { + return s.Swimming && s.SwimWaterGraceTicks > 0 +} + // BoundingBox returns the entity bounding box translated to the current position. func (s *MovementState) BoundingBox(useSlideOffset bool) cube.BBox { scale := s.Size[2] width := (s.Size[0] * 0.5) * scale height := s.Size[1] * scale - if s.Swimming { + if s.SwimPose() { // The swim pose collapses the hitbox to a width-sized cube. height = s.Size[0] * scale } @@ -34,7 +49,7 @@ func (s *MovementState) ClientBoundingBox(useSlideOffset bool) cube.BBox { scale := s.Size[2] width := (s.Size[0] * 0.5) * scale height := s.Size[1] * scale - if s.Swimming { + if s.SwimPose() { // The swim pose collapses the hitbox to a width-sized cube. height = s.Size[0] * scale } diff --git a/constants.go b/constants.go index 128ec0c..42a1b81 100644 --- a/constants.go +++ b/constants.go @@ -13,9 +13,12 @@ const ( SlimeBounceMultiplier = -1.0 BedBounceMultiplier = -0.66 // This can be validated in Mob::ascendLadder(). - ClimbSpeed = 0.2 - MaxConsumingImpulse = 0.1225 - MaxSneakImpulse = 0.3 + ClimbSpeed = 0.2 + MaxConsumingImpulse = 0.1225 + MaxSneakImpulse = 0.3 + // Deprecated: MaxNormalizedImpulse is unused by the simulator. The + // diagonal-impulse normalization it was intended for is disabled upstream + // as well. It is retained only for API compatibility. MaxNormalizedImpulse = 0.70710678118 // 1/sqrt(2) DefaultUnderwaterMovementSpeed = 0.02 DefaultLavaMovementSpeed = 0.02 @@ -31,4 +34,11 @@ const ( JumpDelayTicks = 10 GlideBoostTicks = 20 + + // DefaultSwimWaterGraceTicks bounds how long water travel survives on the + // client's swimming flag alone once the hitbox stops touching water. It + // covers the brief surface transition where a swimmer's collapsed hitbox + // no longer overlaps a water block, without letting a latched flag grant + // indefinite zero-gravity travel. + DefaultSwimWaterGraceTicks = 10 ) diff --git a/liquid.go b/liquid.go index 00047ef..dcd5826 100644 --- a/liquid.go +++ b/liquid.go @@ -29,13 +29,55 @@ import ( // - Zero-valued speed and multiplier fields on MovementState mean "unset" // and fall back to the Default* constants, so callers that do not track // those attributes still get correct physics. -// - A nil EffectsProvider is tolerated and falls back to gravity. +// - A nil EffectsProvider is tolerated and falls back to gravity, and a nil +// WorldProvider reads as empty space, so an incompletely wired simulator +// degrades to no-liquid rather than panicking. +// - The Depth Strider level is clamped to [0, 3]. Upstream clamps only the +// upper bound because the value comes from an enchantment; bedsim takes it +// from a caller-supplied provider that could report a negative level. // // Upstream also removed the sneak and consumable impulse clamps outright in // this PR, clamping the move vector to [-1, 1] instead. bedsim deliberately -// does not follow that: MaxSneakImpulse and MaxConsumingImpulse are public API -// and apply to all movement, so removing them is a breaking change well -// outside liquid scope. Impulse handling is therefore unchanged from v0.1.3. +// does not follow that by default: MaxSneakImpulse and MaxConsumingImpulse are +// public API and apply to all movement, so removing them is a breaking change +// well outside liquid scope. Set SimulationOptions.UpstreamImpulseClamping to +// opt into upstream's behavior. +// +// Security hardening divergence: upstream gates water travel on the client's +// swimming flag alone (`len(waterBlocks) != 0 || Swimming`), and sizes the +// hitbox off the same flag. Oomph can afford that because the surrounding +// anticheat validates the flag, but a standalone authoritative simulator cannot +// — a latched flag would yield indefinite zero-gravity hovering in open air and +// a hitbox shrunk from 1.8 to 0.6, both with no correction raised. bedsim gates +// water travel and the swim pose (MovementState.SwimPose) on recent +// server-observed water contact, bounded by +// SimulationOptions.SwimWaterGraceTicks, clamped before use, reset on any frame +// that was not simulated, and overridden by lava the player is actually in. +// Real water contact is unaffected; see the README for the residual duty-cycle +// limit and the deliberate one-tick pose lag. + +// liquidKind identifies the liquid family a travel step simulates. Liquids are +// matched by LiquidType() so that custom world.Liquid implementations behave +// like the vanilla blocks they stand in for. +type liquidKind uint8 + +const ( + liquidWater liquidKind = iota + liquidLava +) + +// typeName is the world.Liquid.LiquidType() value identifying this kind. +func (k liquidKind) typeName() string { + if k == liquidLava { + return "lava" + } + return "water" +} + +// matches reports whether the liquid belongs to this kind. +func (k liquidKind) matches(liquid world.Liquid) bool { + return liquid.LiquidType() == k.typeName() +} var liquidFaces = [...]struct { delta cube.Pos @@ -47,8 +89,11 @@ var liquidFaces = [...]struct { {cube.Pos{0, 0, 1}, mgl64.Vec3{0, 0, 1}}, } -func (s *Simulator) simulateLiquidTravel(state *MovementState, water, touchingLiquid bool) { +func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, touchingLiquid bool) { initialY := state.Pos.Y() + water := kind == liquidWater + // Captured before updateSwimTravel, matching the upstream ordering. + jumping := state.EffectiveJumping if water { if state.WantDown || state.WantDownSlow { vel := state.Vel @@ -58,7 +103,7 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, water, touchingLi s.updateSwimTravel(state) } - if state.EffectiveJumping { + if jumping { vel := state.Vel if state.SwimAmount > 0 && state.SwimAmount < 1 || water && state.Swimming && !touchingLiquid { vel[1] = 0 @@ -181,10 +226,10 @@ func (s *Simulator) updateSwimTravel(state *MovementState) { state.SetVel(vel) } -func (s *Simulator) touchingLiquidBlocks(state *MovementState, liquidType string) []cube.Pos { +func (s *Simulator) touchingLiquidBlocks(state *MovementState, kind liquidKind) []cube.Pos { box := state.BoundingBox(s.Options.UseSlideOffset).GrowVec3(mgl64.Vec3{1e-4, 0, 1e-4}) offset := mgl64.Vec3{0.001, 0.401, 0.001} - if liquidType == "lava" { + if kind == liquidLava { offset = mgl64.Vec3{0.1, 0.4, 0.1} } box = shrinkLiquidBox(box, offset) @@ -198,7 +243,7 @@ func (s *Simulator) touchingLiquidBlocks(state *MovementState, liquidType string for z := minZ; z < maxZ; z++ { pos := cube.Pos{x, y, z} liquid, ok := s.liquidAt(pos) - if !ok || liquid.LiquidType() != liquidType { + if !ok || !kind.matches(liquid) { continue } if s.Options.Debugf != nil { @@ -245,8 +290,30 @@ func (s *Simulator) blockCollisions(pos cube.Pos) []cube.BBox { return s.World.BlockCollisions(pos) } -func (s *Simulator) liquidAt(pos cube.Pos) (world.Liquid, bool) { +// liquidLayer resolves the configured second-layer liquid source. The explicit +// Simulator.Liquids field wins; a World that itself implements LiquidProvider is +// accepted for compatibility with integrations written before the field existed. +func (s *Simulator) liquidLayer() (LiquidProvider, bool) { + if s.Liquids != nil { + return s.Liquids, true + } if provider, ok := s.World.(LiquidProvider); ok { + return provider, true + } + return nil, false +} + +// HasLiquidLayer reports whether this simulator can see liquids in the second +// block layer, which is what makes waterlogged blocks visible to movement. +// Callers running authoritatively should assert this at startup, or set +// SimulationOptions.RequireLiquidLayer to fail closed instead. +func (s *Simulator) HasLiquidLayer() bool { + _, ok := s.liquidLayer() + return ok +} + +func (s *Simulator) liquidAt(pos cube.Pos) (world.Liquid, bool) { + if provider, ok := s.liquidLayer(); ok { if liquid, found := provider.Liquid(pos); found { return liquid, true } @@ -278,22 +345,22 @@ func (s *Simulator) containsAnyLiquid(box cube.BBox) bool { return false } -func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, liquidType string) { +func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, kind liquidKind) { flow := mgl64.Vec3{} for _, pos := range positions { liquid, ok := s.liquidAt(pos) - if !ok || liquid.LiquidType() != liquidType { + if !ok || !kind.matches(liquid) { continue } flow = flow.Add(s.liquidFlow(pos, liquid)) } if length := flow.Len(); length >= 1e-4 { strength := 0.014 - if liquidType == "lava" { + if kind == liquidLava { strength = 0.0035 } state.SetVel(state.Vel.Add(flow.Mul(strength / length))) - s.debugf("%s flow applied strength=%.6f flow=%v vel=%v", liquidType, strength, flow, state.Vel) + s.debugf("%s flow applied strength=%.6f flow=%v vel=%v", kind.typeName(), strength, flow, state.Vel) } } diff --git a/liquid_hardening_test.go b/liquid_hardening_test.go new file mode 100644 index 0000000..d76826c --- /dev/null +++ b/liquid_hardening_test.go @@ -0,0 +1,685 @@ +package bedsim + +import ( + "math" + "testing" + + "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/mgl64" +) + +// --- swim water-travel grace (spoof hardening) ------------------------------ + +// dryState returns a state that is airborne with no liquid anywhere and normal +// gravity seeded, so the non-liquid path visibly accelerates downward. +func dryState() *MovementState { + state := submergedState() + state.Gravity = NormalGravity + return state +} + +// A client that latches the swimming flag while in open air must not keep water +// travel — and therefore zero gravity — indefinitely. +func TestSpoofedSwimmingInAirStopsHovering(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + + startY := state.Pos.Y() + for range 60 { + sim.Simulate(state, InputState{}) + } + + if state.Vel.Y() > -0.5 { + t.Fatalf("vertical velocity = %v, want a clear fall after the grace expires", state.Vel.Y()) + } + if state.Pos.Y() >= startY { + t.Fatalf("position Y = %v, want below the start %v", state.Pos.Y(), startY) + } +} + +// The grace window exists so a swimmer at the surface, whose hitbox briefly +// stops overlapping a water block, keeps water travel across the transition. +func TestSwimmingPreservesWaterTravelAcrossSurfaceTransition(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + // Simulate prior water contact by seeding the grace the same way a real + // contact tick would. + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + // Water travel while swimming applies no gravity at all. + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("vertical velocity = %v, want water travel (0) inside the grace window", state.Vel.Y()) + } +} + +// Real water contact refills the grace to its full window every tick. +func TestSwimWaterGraceRefillsOnWaterContact(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + state.SwimWaterGraceTicks = 1 + + sim.SimulateState(state) + if state.SwimWaterGraceTicks != DefaultSwimWaterGraceTicks { + t.Fatalf("grace = %d, want refilled to %d", state.SwimWaterGraceTicks, DefaultSwimWaterGraceTicks) + } +} + +// The grace decays by exactly one tick per dry tick and expires on schedule. +func TestSwimWaterGraceExpiresDeterministically(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.Options.SwimWaterGraceTicks = 3 + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = 3 + + for tick := 1; tick <= 3; tick++ { + sim.SimulateState(state) + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("tick %d: vertical velocity = %v, want water travel", tick, state.Vel.Y()) + } + if want := int64(3 - tick); state.SwimWaterGraceTicks != want { + t.Fatalf("tick %d: grace = %d, want %d", tick, state.SwimWaterGraceTicks, want) + } + } + + sim.SimulateState(state) + if approxEqual(state.Vel.Y(), 0) { + t.Fatal("water travel must stop once the grace is exhausted") + } +} + +// A caller can widen the grace window through options. +func TestSwimWaterGraceOptionOverride(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Options.SwimWaterGraceTicks = 25 + state := submergedState() + state.Swimming = true + + sim.SimulateState(state) + if state.SwimWaterGraceTicks != 25 { + t.Fatalf("grace = %d, want 25", state.SwimWaterGraceTicks) + } +} + +// A negative option disables the grace entirely, requiring water contact on +// every tick for water travel. +func TestSwimWaterGraceCanBeDisabled(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Options.SwimWaterGraceTicks = -1 + state := submergedState() + state.Swimming = true + + sim.SimulateState(state) + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want 0 when disabled", state.SwimWaterGraceTicks) + } + + dry := newLiquidSim(newLiquidWorld()) + dry.Options.SwimWaterGraceTicks = -1 + dryS := dryState() + dryS.Swimming = true + dryS.SwimWaterGraceTicks = 5 // stale value must not grant travel + + dry.SimulateState(dryS) + if approxEqual(dryS.Vel.Y(), 0) { + t.Fatal("disabled grace must not permit water travel out of water") + } +} + +// An unreliable frame resets the retained proof, so it cannot be carried across +// a correction. +func TestSwimWaterGraceResetOnUnreliableFrame(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.Alive = false // forces the unreliable path + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeUnreliable { + t.Fatalf("outcome = %v, want unreliable", result.Outcome) + } + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want reset to 0", state.SwimWaterGraceTicks) + } +} + +// An unloaded chunk cannot prove water contact, so the retained proof is +// cleared rather than carried across the gap. +func TestSwimWaterGraceResetOnUnloadedChunk(t *testing.T) { + w := newLiquidWorld() + w.chunkLoaded = false + sim := newLiquidSim(w) + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want reset to 0", state.SwimWaterGraceTicks) + } +} + +// The grace never exceeds its configured bound, however long the player swims. +func TestSwimWaterGraceIsBounded(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = true + + for range 100 { + sim.SimulateState(state) + if state.SwimWaterGraceTicks > DefaultSwimWaterGraceTicks { + t.Fatalf("grace = %d, want at most %d", state.SwimWaterGraceTicks, DefaultSwimWaterGraceTicks) + } + } +} + +// Real water contact still takes priority over the swimming flag: a player in +// water gets water travel whether or not the client claims to be swimming. +func TestRealWaterContactDoesNotNeedSwimmingFlag(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = false + + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// The security window's default is pinned so a regression cannot silently +// widen it. Every other grace test compares against the symbol or overrides it +// through options, so without this the constant is unconstrained. +func TestDefaultSwimWaterGraceTicksValue(t *testing.T) { + if DefaultSwimWaterGraceTicks != 10 { + t.Fatalf("DefaultSwimWaterGraceTicks = %d, want 10; widening this enlarges "+ + "the window in which a client-controlled flag alone drives water travel", + DefaultSwimWaterGraceTicks) + } +} + +// A teleport relocates the player without observing the destination, so +// retained water contact from the origin must not survive it. +func TestSwimWaterGraceResetOnTeleport(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.TeleportPos = mgl64.Vec3{50, 50, 50} + state.TeleportCompletionTicks = 3 + state.TicksSinceTeleport = 0 + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("outcome = %v, want teleport", result.Outcome) + } + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want reset to 0 across a teleport", state.SwimWaterGraceTicks) + } +} + +// Frozen ticks observe nothing, so the budget must not pause and resume later. +func TestSwimWaterGraceResetWhenImmobile(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + state := dryState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.Immobile = true + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeImmobileOrNotReady { + t.Fatalf("outcome = %v, want immobile", result.Outcome) + } + if state.SwimWaterGraceTicks != 0 { + t.Fatalf("grace = %d, want reset to 0 while immobile", state.SwimWaterGraceTicks) + } +} + +// Lava the player is demonstrably standing in must win over a stale water +// grace, so a swimming flag cannot cancel lava gravity. +func TestLavaWinsOverStaleWaterGrace(t *testing.T) { + sim := newLiquidSim(filledColumn(lavaSource)) + state := submergedState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + // Lava gravity, not water travel's zero gravity for a swimmer. + assertVec(t, state.Vel, mgl64.Vec3{0, -0.02, 0}) +} + +// The swim-speed multiplier branch scales acceleration by +// (0.7 + depthStriderFraction*0.3) * multiplier. Comparing full Depth Strider +// against none pins that expression, which the golden cannot reach because it +// runs with a multiplier of 1. +func TestSwimSpeedMultiplierDepthStriderScaling(t *testing.T) { + run := func(level int) float64 { + sim := newLiquidSim(filledColumn(waterSource)) + sim.Inventory = depthStriderInventory{level: level} + state := submergedState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.SwimSpeedMultiplier = 2 + state.OnGround = true + state.Impulse = mgl64.Vec2{0, 0.98} + sim.SimulateState(state) + return state.Vel.Z() + } + + none, full := run(0), run(3) + if approxEqual(none, 0) { + t.Fatal("baseline velocity must be non-zero") + } + // fraction 0 -> 0.7; fraction 1 -> 1.0. Drag is 0.8 in both cases because + // the Depth Strider drag term is gated on multiplier <= 1. + if ratio := full / none; math.Abs(ratio-1/0.7) > 1e-9 { + t.Fatalf("full/none acceleration ratio = %.17g, want %.17g", ratio, 1/0.7) + } +} + +// --- liquid layer capability ------------------------------------------------ + +// explicitLiquids is a standalone LiquidProvider wired through Simulator.Liquids +// rather than discovered on the world adapter. +type explicitLiquids struct { + layer map[cube.Pos]world.Liquid +} + +func (p explicitLiquids) Liquid(pos cube.Pos) (world.Liquid, bool) { + liquid, ok := p.layer[pos] + return liquid, ok +} + +func TestHasLiquidLayerReportsExplicitProvider(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + if sim.HasLiquidLayer() { + t.Fatal("a plain world must not report liquid layer support") + } + + sim.Liquids = explicitLiquids{layer: map[cube.Pos]world.Liquid{}} + if !sim.HasLiquidLayer() { + t.Fatal("an explicit Liquids provider must report support") + } +} + +// A world that implements LiquidProvider still works, preserving v0.1.3-era +// integrations that relied on the type assertion. +func TestHasLiquidLayerAcceptsWorldProvider(t *testing.T) { + sim := newLiquidSim(newLayeredLiquidWorld()) + if !sim.HasLiquidLayer() { + t.Fatal("a world implementing LiquidProvider must report support") + } +} + +// The explicit field wins over the world assertion when both are present. +func TestExplicitLiquidsFieldTakesPrecedence(t *testing.T) { + w := newLayeredLiquidWorld() + w.waterlog(cube.Pos{0, 0, 0}, block.Air{}, waterSource) + + sim := newLiquidSim(w) + sim.Liquids = explicitLiquids{layer: map[cube.Pos]world.Liquid{}} + + state := submergedState() + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 0 { + t.Fatalf("water blocks = %d, want 0 from the explicit empty provider", got) + } +} + +// Liquids supplied through the explicit field are detected normally. +func TestExplicitLiquidsProviderDetectsWaterlogged(t *testing.T) { + layer := map[cube.Pos]world.Liquid{} + for y := range 4 { + layer[cube.Pos{0, y, 0}] = waterSource + } + sim := newLiquidSim(newLiquidWorld()) + sim.Liquids = explicitLiquids{layer: layer} + + state := submergedState() + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got == 0 { + t.Fatal("expected waterlogged blocks from the explicit provider") + } + sim.SimulateState(state) + assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) +} + +// With RequireLiquidLayer set, a simulator that cannot see layer 1 refuses to +// simulate rather than silently mis-simulating waterlogged blocks. +func TestRequireLiquidLayerFailsClosed(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.Options.RequireLiquidLayer = true + state := submergedState() + state.Vel = mgl64.Vec3{0.5, 0.5, 0.5} + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeUnreliable { + t.Fatalf("outcome = %v, want unreliable when the liquid layer is unavailable", result.Outcome) + } +} + +// The same simulator proceeds normally once layer-1 support is supplied. +func TestRequireLiquidLayerPassesWithProvider(t *testing.T) { + sim := newLiquidSim(newLayeredLiquidWorld()) + sim.Options.RequireLiquidLayer = true + state := submergedState() + + result := sim.SimulateState(state) + if result.Outcome == SimulationOutcomeUnreliable { + t.Fatal("a simulator with liquid layer support must not fail closed") + } +} + +// Without the option, the legacy fallback still simulates, preserving +// compatibility for callers that do not track layer 1. +func TestLiquidLayerFallbackStillSimulatesByDefault(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeNormal { + t.Fatalf("outcome = %v, want normal", result.Outcome) + } +} + +// --- impulse clamping opt-in ------------------------------------------------ + +// By default bedsim keeps its sneak and consumable impulse clamps; the opt-in +// selects upstream's behavior, which applies neither. +func TestUpstreamImpulseClampingOptIn(t *testing.T) { + cases := []struct { + name string + upstream bool + input InputState + want float64 + }{ + {"sneak default", false, InputState{SneakDown: true, MoveVector: mgl64.Vec2{0, 1}}, MaxSneakImpulse * 0.98}, + {"sneak upstream", true, InputState{SneakDown: true, MoveVector: mgl64.Vec2{0, 1}}, 0.98}, + {"consumable default", false, InputState{UsingConsumable: true, MoveVector: mgl64.Vec2{0, 1}}, MaxConsumingImpulse * 0.98}, + {"consumable upstream", true, InputState{UsingConsumable: true, MoveVector: mgl64.Vec2{0, 1}}, 0.98}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.Options.UpstreamImpulseClamping = tc.upstream + state := newBaseState() + + sim.applyInput(state, tc.input) + if !approxEqual(state.Impulse.Y(), tc.want) { + t.Fatalf("impulse Y = %v, want %v", state.Impulse.Y(), tc.want) + } + }) + } +} + +// Upstream still clamps the raw move vector to the unit range. +func TestUpstreamImpulseClampingStillBoundsMoveVector(t *testing.T) { + sim := newLiquidSim(newLiquidWorld()) + sim.Options.UpstreamImpulseClamping = true + state := newBaseState() + + sim.applyInput(state, InputState{MoveVector: mgl64.Vec2{5, -5}}) + if !approxEqual(state.Impulse.X(), 0.98) || !approxEqual(state.Impulse.Y(), -0.98) { + t.Fatalf("impulse = %v, want the move vector clamped to [-1, 1] then scaled", state.Impulse) + } +} + +// --- flying gate ------------------------------------------------------------ + +// A flying player is treated as an unsupported scenario before physics run at +// all, so no liquid physics are applied and the state is reset to the client. +func TestFlyingIsUnreliableBeforePhysics(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Flying = true + state.Vel = mgl64.Vec3{0.25, 0.25, 0.25} + state.Client.Vel = mgl64.Vec3{1, 2, 3} + + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeUnreliable { + t.Fatalf("outcome = %v, want unreliable", result.Outcome) + } + assertVec(t, state.Vel, mgl64.Vec3{1, 2, 3}) +} + +// The liquid gate itself also excludes flying, independently of the reliability +// check above. Exercised directly because the public path cannot reach it. +func TestLiquidGateExcludesFlying(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Flying = true + state.Gravity = NormalGravity + + sim.simulateMovement(state) + if approxEqual(state.Vel.Y(), -0.005) { + t.Fatal("flying must not take the liquid path") + } + if !approxEqual(state.Vel.Y(), -NormalGravity*NormalGravityMultiplier) { + t.Fatalf("vertical velocity = %v, want normal gravity", state.Vel.Y()) + } +} + +// --- flow weights ----------------------------------------------------------- + +// The drop contribution weights an open neighbour with liquid below by +// (decay(below) - decay(current) + 8). Competing horizontal terms make the +// weight observable through the normalized result. +func TestFlowDropWeightIsEight(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8}). + set(cube.Pos{-1, 0, 0}, block.Water{Depth: 7}). + set(cube.Pos{0, 0, 1}, block.Water{Depth: 4}). + set(cube.Pos{0, 0, -1}, block.Water{Depth: 8}). + set(cube.Pos{1, -1, 0}, block.Water{Depth: 8}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + // +X: open with liquid below -> (0 - 0 + 8) = +8 + // -X: same-type neighbour -> (1 - 0) = -1 + // +Z: same-type neighbour -> (4 - 0) = +4 + want := mgl64.Vec3{7, 0, 4}.Normalize() + assertVec(t, flow, want) +} + +// Falling liquid blocked by a solid neighbour adds a downward term of exactly +// 6 against the unit-normalized horizontal flow. +func TestFallingFlowDownwardWeightIsSix(t *testing.T) { + w := newLiquidWorld(). + set(cube.Pos{0, 0, 0}, block.Water{Depth: 8, Falling: true}). + set(cube.Pos{-1, 0, 0}, block.Water{Depth: 4}). + set(cube.Pos{1, 0, 0}, block.Stone{}) + sim := newLiquidSim(w) + + flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8, Falling: true}) + // Horizontal flow normalizes to (-1, 0, 0), then Y -= 6, then normalizes. + want := mgl64.Vec3{-1, -6, 0}.Normalize() + assertVec(t, flow, want) +} + +// --- stairs face blocking --------------------------------------------------- + +// A waterlogged stairs block whose solid face points at the neighbour blocks +// flow through that face. +func TestStairsSolidFaceBlocksFlow(t *testing.T) { + build := func(facing cube.Direction) mgl64.Vec3 { + w := newLayeredLiquidWorld() + w.waterlog(cube.Pos{0, 0, 0}, block.Stairs{Facing: facing}, block.Water{Depth: 8}) + w.set(cube.Pos{1, 0, 0}, block.Water{Depth: 4}) + sim := newLiquidSim(w) + return sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}) + } + + // Facing east: the stairs' full side faces the +X neighbour and closes it. + if flow := build(cube.East); !approxEqual(flow.X(), 0) { + t.Fatalf("east-facing stairs: flow X = %v, want 0", flow.X()) + } + // Facing west: the +X side is open, so flow proceeds toward the shallower + // neighbour. + if flow := build(cube.West); !(flow.X() > 0) { + t.Fatalf("west-facing stairs: flow X = %v, want positive", flow.X()) + } +} + +// --- nil world -------------------------------------------------------------- + +// A simulator with no world must not panic on any liquid path. +func TestNilWorldIsSafe(t *testing.T) { + sim := &Simulator{Options: SimulationOptions{PositionCorrectionThreshold: 0.3}} + state := submergedState() + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + sim.SimulateState(state) + + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 0 { + t.Fatalf("water blocks = %d, want 0 with no world", got) + } + if sim.containsAnyLiquid(state.BoundingBox(false)) { + t.Fatal("no world must contain no liquid") + } + if flow := sim.liquidFlow(cube.Pos{0, 0, 0}, block.Water{Depth: 8}); flow.Len() != 0 { + t.Fatalf("flow = %v, want zero with no world", flow) + } + if sim.HasLiquidLayer() { + t.Fatal("no world must not report liquid layer support") + } +} + +// --- swim hitbox collision interaction -------------------------------------- + +// The collapsed swim hitbox fits under a ceiling that a standing hitbox would +// hit, so the swim pose genuinely changes collision results. +func TestSwimHitboxChangesCeilingCollision(t *testing.T) { + newCeilingSim := func() (*Simulator, *MovementState) { + w := newLiquidWorld(). + fill(cube.Pos{-1, 0, -1}, cube.Pos{1, 1, 1}, waterSource). + set(cube.Pos{0, 2, 0}, block.Stone{}) + state := submergedState() + // Starts clear of the ceiling in both poses; only the standing hitbox + // reaches it after the upward move. + state.Pos = mgl64.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl64.Vec3{0, 0.5, 0} + return newLiquidSim(w), state + } + + sim, standing := newCeilingSim() + sim.SimulateState(standing) + + sim2, swimming := newCeilingSim() + swimming.Swimming = true + // The swim pose requires server-observed water contact, which this state + // has: the player is standing in the water column below. + swimming.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + sim2.SimulateState(swimming) + + if !standing.CollideY { + t.Fatal("a standing hitbox must hit the ceiling") + } + if swimming.CollideY { + t.Fatal("a swimming hitbox must fit under the ceiling") + } +} + +// --- determinism ------------------------------------------------------------- + +// Repeated identical runs must agree exactly. A single pinned golden detects +// map-iteration nondeterminism only probabilistically, so this repeats the same +// scenario and compares runs against each other. +func TestLiquidSimulationIsRepeatable(t *testing.T) { + run := func() (mgl64.Vec3, mgl64.Vec3) { + w := newLiquidWorld(). + fill(cube.Pos{-8, 0, -8}, cube.Pos{8, 8, 8}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Water{Depth: 6}). + set(cube.Pos{0, 0, 1}, block.Water{Depth: 4}). + set(cube.Pos{-1, 1, 0}, block.Water{Depth: 8, Falling: true}). + set(cube.Pos{2, 0, 2}, block.Stone{}) + sim := newLiquidSim(w) + sim.Inventory = depthStriderInventory{level: 2} + state := submergedState() + state.Swimming = true + state.SwimAmount = 0.5 + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + input := InputState{ + Jumping: true, + MoveVector: mgl64.Vec2{0.5, 1}, + Pitch: 25, + Yaw: 40, + HeadYaw: 40, + } + for range 20 { + sim.Simulate(state, input) + } + return state.Pos, state.Vel + } + + wantPos, wantVel := run() + for i := range 5 { + gotPos, gotVel := run() + if gotPos != wantPos || gotVel != wantVel { + t.Fatalf("run %d diverged: pos %v vs %v, vel %v vs %v", + i, gotPos, wantPos, gotVel, wantVel) + } + } +} + +// --- pinned golden regression ---------------------------------------------- + +// A rich mixed-liquid scenario pinned to exact expected values, exercising +// swim travel, depth strider, the dolphin multiplier, flow from a depth +// gradient and a falling source, and a jump input, over 20 ticks. +// +// These constants are a change detector, captured from the implementation +// after it was audited line-by-line against the upstream source. They are not +// independently derived, so a failure here means "physics changed" and should +// be re-derived deliberately, not silently updated. The player stays submerged +// for the whole run so the values do not sit on a surface transition. +func TestLiquidGoldenScenario(t *testing.T) { + // Deep enough that the player stays submerged for the whole run, so the + // golden measures liquid physics rather than a surface transition. + w := newLiquidWorld(). + fill(cube.Pos{-8, 0, -8}, cube.Pos{8, 8, 8}, block.Water{Depth: 8}). + set(cube.Pos{1, 0, 0}, block.Water{Depth: 6}). + set(cube.Pos{0, 0, 1}, block.Water{Depth: 4}). + set(cube.Pos{-1, 1, 0}, block.Water{Depth: 8, Falling: true}). + set(cube.Pos{2, 0, 2}, block.Stone{}) + sim := newLiquidSim(w) + sim.Inventory = depthStriderInventory{level: 2} + + state := submergedState() + state.Swimming = true + state.SwimAmount = 0.5 + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + + // Rotation and impulse must arrive through InputState: Simulate overwrites + // both from the input every tick, so seeding them on the state would leave + // the pitch steering and moveRelative terms untested. The swim speed + // multiplier is left at its default so the Depth Strider drag term, which + // is gated on multiplier <= 1, is actually reached. + input := InputState{ + Jumping: true, + MoveVector: mgl64.Vec2{0.5, 1}, + Pitch: 25, + Yaw: 40, + HeadYaw: 40, + } + for range 20 { + sim.Simulate(state, input) + } + + wantPos := mgl64.Vec3{-0.012654883672021777, 2.7281474976710665, 3.5954677602500538} + wantVel := mgl64.Vec3{-0.02702143903177032, 0.15437050046578696, 0.1142856536788795} + + const tolerance = 1e-12 + for axis, name := range []string{"X", "Y", "Z"} { + if math.Abs(state.Pos[axis]-wantPos[axis]) > tolerance { + t.Errorf("Pos.%s = %.17g, want %.17g", name, state.Pos[axis], wantPos[axis]) + } + if math.Abs(state.Vel[axis]-wantVel[axis]) > tolerance { + t.Errorf("Vel.%s = %.17g, want %.17g", name, state.Vel[axis], wantVel[axis]) + } + } +} diff --git a/liquid_test.go b/liquid_test.go index 60234a0..b5085d2 100644 --- a/liquid_test.go +++ b/liquid_test.go @@ -178,6 +178,7 @@ func TestSwimmingBoundingBoxUsesWidthAsHeight(t *testing.T) { } state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks swimming := state.BoundingBox(false) if height := swimming.Height(); !approxEqual(height, 0.6) { t.Fatalf("swimming height = %v, want 0.6", height) @@ -187,6 +188,42 @@ func TestSwimmingBoundingBoxUsesWidthAsHeight(t *testing.T) { } } +// The swimming flag alone must not shrink the server-side hitbox: without +// server-observed water contact a client could otherwise claim the swim pose in +// open air and fit through gaps a standing player cannot. +func TestSwimmingFlagAloneDoesNotShrinkHitbox(t *testing.T) { + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 10, 0.5} + state.Swimming = true + state.SwimWaterGraceTicks = 0 + + if state.SwimPose() { + t.Fatal("swim pose must require water evidence") + } + if height := state.BoundingBox(false).Height(); !approxEqual(height, 1.8) { + t.Fatalf("height = %v, want the full standing hitbox", height) + } + if height := state.ClientBoundingBox(false).Height(); !approxEqual(height, 1.8) { + t.Fatalf("client height = %v, want the full standing hitbox", height) + } +} + +// A spoofed swimming flag with no water anywhere must not let the player pass +// through a gap that only the collapsed swim hitbox fits. +func TestSpoofedSwimmingCannotFitThroughCeilingGap(t *testing.T) { + sim := newLiquidSim(newLiquidWorld().set(cube.Pos{0, 2, 0}, block.Stone{})) + state := newBaseState() + state.Pos = mgl64.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Swimming = true + state.Vel = mgl64.Vec3{0, 0.5, 0} + + sim.SimulateState(state) + if !state.CollideY { + t.Fatal("a spoofed swim pose must still collide with the ceiling") + } +} + func TestSwimmingClientBoundingBoxUsesWidthAsHeight(t *testing.T) { state := newBaseState() state.Client.Pos = mgl64.Vec3{0.5, 10, 0.5} @@ -195,6 +232,7 @@ func TestSwimmingClientBoundingBoxUsesWidthAsHeight(t *testing.T) { t.Fatalf("standing client height = %v, want 1.8", height) } state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks if height := state.ClientBoundingBox(false).Height(); !approxEqual(height, 0.6) { t.Fatalf("swimming client height = %v, want 0.6", height) } @@ -206,6 +244,7 @@ func TestSwimmingBoundingBoxRespectsScale(t *testing.T) { state.Pos = mgl64.Vec3{0.5, 10, 0.5} state.Size = mgl64.Vec3{0.6, 1.8, 2} state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks if height := state.BoundingBox(false).Height(); !approxEqual(height, 1.2) { t.Fatalf("scaled swimming height = %v, want 1.2", height) @@ -595,6 +634,9 @@ func TestSwimTravelStopsAtSurface(t *testing.T) { state.Swimming = true state.Rotation = mgl64.Vec3{-90, 0, 0} state.Vel = mgl64.Vec3{0, 0.5, 0} + // The hitbox has just left the water, so water travel is still in its + // grace window. + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks sim.SimulateState(state) // Vertical velocity is zeroed at the surface, and swimming has no gravity. @@ -628,6 +670,7 @@ func TestSwimTravelSurfaceClampSkippedWhenWantDownSlow(t *testing.T) { state.Rotation = mgl64.Vec3{-90, 0, 0} state.WantDownSlow = true state.Vel = mgl64.Vec3{0, 0.5, 0} + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks sim.SimulateState(state) if approxEqual(state.Vel.Y(), 0) { @@ -859,7 +902,7 @@ func TestWaterDetectedAtFeet(t *testing.T) { sim := newLiquidSim(newLiquidWorld().set(cube.Pos{0, 0, 0}, waterSource)) state := submergedState() - if got := len(sim.touchingLiquidBlocks(state, "water")); got != 1 { + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 1 { t.Fatalf("water blocks = %d, want 1", got) } } @@ -873,8 +916,8 @@ func TestLavaUsesWiderHorizontalMargin(t *testing.T) { // Position the player so the box only just reaches into x=1. state.Pos = mgl64.Vec3{0.75, 0.5, 0.5} - water := sim.touchingLiquidBlocks(state, "water") - lava := sim.touchingLiquidBlocks(state, "lava") + water := sim.touchingLiquidBlocks(state, liquidWater) + lava := sim.touchingLiquidBlocks(state, liquidLava) if len(water) == 0 { t.Fatal("expected water contact") } @@ -888,10 +931,10 @@ func TestLiquidTypeFiltering(t *testing.T) { sim := newLiquidSim(filledColumn(lavaSource)) state := submergedState() - if got := len(sim.touchingLiquidBlocks(state, "water")); got != 0 { + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 0 { t.Fatalf("water blocks = %d, want 0 in a lava column", got) } - if got := len(sim.touchingLiquidBlocks(state, "lava")); got == 0 { + if got := len(sim.touchingLiquidBlocks(state, liquidLava)); got == 0 { t.Fatal("expected lava contact") } } @@ -929,7 +972,7 @@ func TestLiquidProviderDetectsWaterloggedBlocks(t *testing.T) { sim := newLiquidSim(w) state := submergedState() - if got := len(sim.touchingLiquidBlocks(state, "water")); got == 0 { + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got == 0 { t.Fatal("expected waterlogged blocks to register as water") } sim.SimulateState(state) @@ -983,16 +1026,9 @@ func TestLiquidIsReliableScenario(t *testing.T) { // --- flying and gliding ---------------------------------------------------- // Flying players ignore liquid physics entirely. -func TestFlyingSkipsLiquidPhysics(t *testing.T) { - sim := newLiquidSim(filledColumn(waterSource)) - state := submergedState() - state.Flying = true - - sim.SimulateState(state) - if approxEqual(state.Vel.Y(), -0.005) { - t.Fatal("flying must not apply liquid gravity") - } -} +// Flying is covered by TestFlyingIsUnreliableBeforePhysics and +// TestLiquidGateExcludesFlying in liquid_hardening_test.go, which separate the +// reliability bail-out from the liquid gate itself. // Entering water cancels an active glide and its boost. func TestWaterCancelsGliding(t *testing.T) { @@ -1046,9 +1082,13 @@ func TestSwimmingPreservesWaterTravelOutsideWater(t *testing.T) { state := submergedState() state.Swimming = true state.Rotation = mgl64.Vec3{0, 0, 0} + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + // Seeded so that falling back to normal physics would be visible as a + // gravity pull rather than an indistinguishable zero. + state.Gravity = NormalGravity sim.SimulateState(state) - // Water travel with swimming: no gravity at all. + // Water travel with swimming applies no gravity at all. if !approxEqual(state.Vel.Y(), 0) { t.Fatalf("vertical velocity = %v, want water travel (0)", state.Vel.Y()) } @@ -1062,10 +1102,26 @@ func TestSwimmingOutsideWaterSuppressesJump(t *testing.T) { state.Swimming = true state.SwimAmount = 1 state.EffectiveJumping = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.Gravity = NormalGravity sim.SimulateState(state) - if state.Vel.Y() > 0 { - t.Fatalf("vertical velocity = %v, want no ascent outside water", state.Vel.Y()) + // Exactly zero: the jump impulse is suppressed and swimming cancels + // gravity, which distinguishes this from both an ascent and a normal fall. + if !approxEqual(state.Vel.Y(), 0) { + t.Fatalf("vertical velocity = %v, want exactly 0 outside water", state.Vel.Y()) + } + + // The same state actually in water does ascend, confirming the assertion + // above is not vacuous. + wet := newLiquidSim(filledColumn(waterSource)) + wetState := submergedState() + wetState.Swimming = true + wetState.SwimAmount = 1 + wetState.EffectiveJumping = true + wet.SimulateState(wetState) + if wetState.Vel.Y() <= 0 { + t.Fatalf("in-water vertical velocity = %v, want an ascent", wetState.Vel.Y()) } } @@ -1096,7 +1152,7 @@ func TestWaterFlowStrength(t *testing.T) { sim := newLiquidSim(w) state := submergedState() - sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, "water"), "water") + sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, liquidWater), liquidWater) if !approxEqual(state.Vel.X(), 0.014) { t.Fatalf("flow X = %v, want 0.014", state.Vel.X()) } @@ -1110,7 +1166,7 @@ func TestLavaFlowStrength(t *testing.T) { sim := newLiquidSim(w) state := submergedState() - sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, "lava"), "lava") + sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, liquidLava), liquidLava) if !approxEqual(state.Vel.X(), 0.0035) { t.Fatalf("flow X = %v, want 0.0035", state.Vel.X()) } @@ -1121,7 +1177,7 @@ func TestUniformLiquidHasNoFlow(t *testing.T) { sim := newLiquidSim(filledColumn(waterSource)) state := submergedState() - sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, "water"), "water") + sim.applyLiquidFlow(state, sim.touchingLiquidBlocks(state, liquidWater), liquidWater) assertVec(t, state.Vel, mgl64.Vec3{}) } @@ -1184,7 +1240,7 @@ func TestNegligibleFlowIgnored(t *testing.T) { state := submergedState() before := state.Vel - sim.applyLiquidFlow(state, nil, "water") + sim.applyLiquidFlow(state, nil, liquidWater) assertVec(t, state.Vel, before) } @@ -1238,17 +1294,50 @@ func TestLiquidExitProbeBoostsOverLedge(t *testing.T) { } // A wall that continues above the player blocks the exit boost. -func TestLiquidExitProbeBlockedByWall(t *testing.T) { - w := newLiquidWorld(). - fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 2, 1}, waterSource). - fill(cube.Pos{1, 0, 0}, cube.Pos{1, 3, 0}, block.Stone{}) - sim := newLiquidSim(w) - state := submergedState() - state.Vel = mgl64.Vec3{0.5, 0, 0} +// Isolates the collision term of the exit probe. The two cases differ only by +// the block overhanging the probe box; in both, the probe box is verified to +// contain no liquid, so the liquid term cannot be what changes the result. +// The swim hitbox is used so the player fits below the overhang without +// colliding with it directly. +func TestLiquidExitProbeBlockedByCollisionAlone(t *testing.T) { + build := func(overhang bool) (*Simulator, *MovementState) { + w := newLiquidWorld(). + fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 0, 1}, waterSource). + set(cube.Pos{1, 0, 0}, block.Stone{}) + if overhang { + w.set(cube.Pos{0, 1, 0}, block.Stone{}) + } + state := submergedState() + state.Pos = mgl64.Vec3{0.5, 0.4, 0.5} + state.Client.Pos = state.Pos + state.Swimming = true + state.SwimWaterGraceTicks = DefaultSwimWaterGraceTicks + state.Vel = mgl64.Vec3{0.5, 0, 0} + return newLiquidSim(w), state + } - sim.SimulateState(state) - if approxEqual(state.Vel.Y(), 0.3) { - t.Fatal("a full wall must block the liquid exit boost") + for _, overhang := range []bool{false, true} { + sim, state := build(overhang) + raised := state.BoundingBox(false).Translate(mgl64.Vec3{0, 0.6, 0}) + if sim.containsAnyLiquid(raised) { + t.Fatalf("overhang=%t: probe box must contain no liquid to isolate the collision term", overhang) + } + + sim.SimulateState(state) + if !state.CollideX { + t.Fatalf("overhang=%t: expected a horizontal collision against the ledge", overhang) + } + if state.CollideY { + t.Fatalf("overhang=%t: the hitbox itself must not touch the overhang", overhang) + } + + boosted := approxEqual(state.Vel.Y(), 0.3) + if overhang && boosted { + t.Fatal("a block above the probe must deny the exit boost") + } + if !overhang && !boosted { + t.Fatalf("clear probe must grant the exit boost, got %v", state.Vel.Y()) + } } } @@ -1313,46 +1402,9 @@ func TestClimbUsesEffectiveJumping(t *testing.T) { } } -// --- determinism ----------------------------------------------------------- - -// Liquid simulation must be deterministic for a given state, input and world -// snapshot. Flow accumulates over a set of block positions, so any reliance on -// map iteration order would surface here. -func TestLiquidSimulationIsDeterministic(t *testing.T) { - build := func() (*Simulator, *MovementState) { - w := newLiquidWorld(). - fill(cube.Pos{-2, 0, -2}, cube.Pos{2, 2, 2}, block.Water{Depth: 8}). - set(cube.Pos{1, 0, 0}, block.Water{Depth: 6}). - set(cube.Pos{0, 0, 1}, block.Water{Depth: 4}). - set(cube.Pos{-1, 1, 0}, block.Water{Depth: 8, Falling: true}). - set(cube.Pos{2, 0, 2}, block.Stone{}) - sim := newLiquidSim(w) - sim.Inventory = depthStriderInventory{level: 2} - state := submergedState() - state.Swimming = true - state.SwimAmount = 0.5 - state.Rotation = mgl64.Vec3{25, 0, 40} - state.Impulse = mgl64.Vec2{0.5, 0.98} - state.SwimSpeedMultiplier = 2 - return sim, state - } - - sim, want := build() - for range 20 { - sim.Simulate(want, InputState{Jumping: true}) - } - - for run := range 5 { - sim, got := build() - for range 20 { - sim.Simulate(got, InputState{Jumping: true}) - } - if got.Pos != want.Pos || got.Vel != want.Vel { - t.Fatalf("run %d diverged: pos %v vs %v, vel %v vs %v", - run, got.Pos, want.Pos, got.Vel, want.Vel) - } - } -} +// Determinism is covered by TestLiquidGoldenScenario in +// liquid_hardening_test.go, which pins the same mixed-liquid scenario to exact +// expected values rather than comparing the implementation against itself. // --- box shrinking --------------------------------------------------------- diff --git a/movement.go b/movement.go index f59d13b..67d32c3 100644 --- a/movement.go +++ b/movement.go @@ -69,8 +69,13 @@ type MovementState struct { EffectiveJumping bool JumpDelay uint64 - Swimming bool - SwimAmount float64 + Swimming bool + SwimAmount float64 + // SwimWaterGraceTicks is the remaining budget of ticks during which the + // swimming flag alone may keep water travel alive, refilled whenever the + // hitbox actually touches water. It is server-derived evidence, not client + // input, and callers should not set it directly. + SwimWaterGraceTicks int64 AutoJumpingInWater bool WantDown, WantDownSlow bool diff --git a/simulation.go b/simulation.go index 13e89a6..0f12354 100644 --- a/simulation.go +++ b/simulation.go @@ -51,6 +51,9 @@ func (s *Simulator) debugfIf(cond bool, format string, args ...any) { func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { teleported := s.attemptTeleport(state) if teleported { + // A teleport relocates the player without observing the destination, + // so any retained water contact from the origin is void. + state.SwimWaterGraceTicks = 0 return SimulationOutcomeTeleport } @@ -59,12 +62,21 @@ func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { s.resetToClient(state) return SimulationOutcomeUnreliable } + if s.Options.RequireLiquidLayer && !s.HasLiquidLayer() { + s.debugf("no liquid layer available and RequireLiquidLayer is set") + s.resetToClient(state) + return SimulationOutcomeUnreliable + } if s.World != nil && !s.World.IsChunkLoaded(int32(math.Floor(state.Pos.X()))>>4, int32(math.Floor(state.Pos.Z()))>>4) { state.SetVel(mgl64.Vec3{}) + state.SwimWaterGraceTicks = 0 return SimulationOutcomeUnloadedChunk } if state.Immobile || !state.Ready { state.SetVel(mgl64.Vec3{}) + // Frozen ticks observe nothing, so the budget must not simply pause + // and resume later. + state.SwimWaterGraceTicks = 0 return SimulationOutcomeImmobileOrNotReady } @@ -189,12 +201,18 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.WantDown = input.WantDown state.WantDownSlow = input.WantDownSlow + // Upstream PR #145 removed both clamps and clamps the move vector to + // [-1, 1]. bedsim keeps them by default because they are public API that + // applies to all movement; UpstreamImpulseClamping opts into upstream's + // behavior without a breaking change. maxImpulse := 1.0 - if input.UsingConsumable { - maxImpulse *= MaxConsumingImpulse - } - if state.Sneaking { - maxImpulse *= MaxSneakImpulse + if !s.Options.UpstreamImpulseClamping { + if input.UsingConsumable { + maxImpulse *= MaxConsumingImpulse + } + if state.Sneaking { + maxImpulse *= MaxSneakImpulse + } } moveVector := mgl64.Vec2{ ClampFloat(input.MoveVector[0], -maxImpulse, maxImpulse), @@ -281,9 +299,38 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SetVel(mgl64.Vec3{}) } - waterBlocks := s.touchingLiquidBlocks(state, "water") - lavaBlocks := s.touchingLiquidBlocks(state, "lava") - waterTravel := len(waterBlocks) != 0 || state.Swimming + // Upstream keys water travel on the client's swimming flag alone, which a + // standalone authoritative simulator cannot trust: a latched flag would + // grant zero-gravity travel — and a shrunken hitbox — in open air forever. + // Water travel and the swim pose therefore survive on the flag only while + // recent server-observed water contact remains. + // + // The bound is applied up front, before anything reads the budget, so a + // stale or caller-supplied value can never widen the window. The budget is + // then decremented at the end of the tick, keeping the value constant for + // the whole tick so every hitbox lookup agrees with the gate below. + grace := s.swimWaterGraceTicks() + if state.SwimWaterGraceTicks > grace { + state.SwimWaterGraceTicks = grace + } + + waterBlocks := s.touchingLiquidBlocks(state, liquidWater) + lavaBlocks := s.touchingLiquidBlocks(state, liquidLava) + + inWater := len(waterBlocks) != 0 + defer func() { + if inWater { + state.SwimWaterGraceTicks = grace + } else if state.SwimWaterGraceTicks > 0 { + state.SwimWaterGraceTicks-- + } + }() + + // Retained proof must not override a liquid the player is demonstrably + // standing in: lava contact wins over a stale water grace. + waterTravel := inWater || + (state.Swimming && state.SwimWaterGraceTicks > 0 && len(lavaBlocks) == 0) + if !state.Flying && (waterTravel || len(lavaBlocks) != 0) { s.debugfIf(attemptKnockback(state), "knockback applied in liquid: %v", state.Vel) if waterTravel { @@ -291,11 +338,11 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.Gliding = false state.GlideBoostTicks = 0 } - s.applyLiquidFlow(state, waterBlocks, "water") - s.simulateLiquidTravel(state, true, len(waterBlocks) != 0) + s.applyLiquidFlow(state, waterBlocks, liquidWater) + s.simulateLiquidTravel(state, liquidWater, inWater) } else { - s.applyLiquidFlow(state, lavaBlocks, "lava") - s.simulateLiquidTravel(state, false, true) + s.applyLiquidFlow(state, lavaBlocks, liquidLava) + s.simulateLiquidTravel(state, liquidLava, true) } return } @@ -447,6 +494,9 @@ func (s *Simulator) simulationIsReliable(state *MovementState) bool { } func (s *Simulator) resetToClient(state *MovementState) { + // A frame we did not simulate proves nothing about water contact, so the + // retained evidence is dropped rather than carried across the gap. + state.SwimWaterGraceTicks = 0 state.LastPos = state.Client.LastPos state.Pos = state.Client.Pos state.LastVel = state.Client.LastVel diff --git a/simulator.go b/simulator.go index bbaed58..66ea7b9 100644 --- a/simulator.go +++ b/simulator.go @@ -45,6 +45,26 @@ type SimulationOptions struct { // rejected due to the client position matching the pre-step position. IgnoreClientStepTiebreaker bool + // RequireLiquidLayer makes the simulator refuse to run when it cannot see + // the second block layer, returning SimulationOutcomeUnreliable instead of + // silently simulating waterlogged blocks as if they were dry. Authoritative + // and anticheat deployments should set this. It defaults to false so that + // integrations predating Simulator.Liquids keep working. + RequireLiquidLayer bool + + // SwimWaterGraceTicks bounds how many consecutive ticks water travel is + // retained on the client's swimming flag alone, after the last tick where + // the hitbox actually touched water. Zero uses DefaultSwimWaterGraceTicks; + // a negative value disables the grace entirely, requiring real water + // contact on every tick. + SwimWaterGraceTicks int64 + + // UpstreamImpulseClamping selects oomph PR #145's impulse handling, which + // clamps the move vector to [-1, 1] and applies no sneak or consumable + // reduction. It defaults to false, keeping bedsim's MaxSneakImpulse and + // MaxConsumingImpulse behavior from v0.1.3. + UpstreamImpulseClamping bool + // Debugf receives internal simulation trace logs for callers that need deep diagnostics. Debugf func(format string, args ...any) } @@ -55,9 +75,16 @@ type Simulator struct { // BlockSemantics optionally resolves movement-specific block behavior from // the same world snapshot as World. Nil uses DefaultBlockSemantics. BlockSemantics BlockSemanticsProvider - Effects EffectsProvider - Inventory InventoryProvider - Options SimulationOptions + // Liquids exposes liquids from the second block layer, which is what makes + // waterlogged blocks visible to movement. Set this explicitly. If it is nil + // and World itself implements LiquidProvider, that is used instead; if + // neither is present, only liquids returned by World.Block are seen and + // waterlogged blocks are invisible. Use HasLiquidLayer to check, or + // SimulationOptions.RequireLiquidLayer to fail closed. + Liquids LiquidProvider + Effects EffectsProvider + Inventory InventoryProvider + Options SimulationOptions } func (DefaultBlockSemantics) BlockName(b world.Block) string { @@ -72,6 +99,19 @@ func (DefaultBlockSemantics) BlockClimbable(b world.Block) bool { return BlockClimbable(b) } +// swimWaterGraceTicks resolves the configured grace window: zero means the +// default, and any negative value disables the grace entirely. +func (s *Simulator) swimWaterGraceTicks() int64 { + switch { + case s.Options.SwimWaterGraceTicks < 0: + return 0 + case s.Options.SwimWaterGraceTicks == 0: + return DefaultSwimWaterGraceTicks + default: + return s.Options.SwimWaterGraceTicks + } +} + func (s *Simulator) blockName(b world.Block) string { if s.BlockSemantics != nil { return s.BlockSemantics.BlockName(b) From 36c1b9266424b4d89660ca4b4d9b41e424d12d8f Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Mon, 20 Jul 2026 07:47:22 -0700 Subject: [PATCH 6/7] docs: trim liquid simulation comments --- bbox.go | 15 ++--------- constants.go | 6 +---- liquid.go | 57 +++++----------------------------------- liquid_hardening_test.go | 34 +----------------------- liquid_test.go | 30 --------------------- movement.go | 11 +++----- simulation.go | 23 +++------------- simulator.go | 26 +++++------------- 8 files changed, 23 insertions(+), 179 deletions(-) diff --git a/bbox.go b/bbox.go index d7b7ae7..2ccc74d 100644 --- a/bbox.go +++ b/bbox.go @@ -5,17 +5,8 @@ import ( "github.com/go-gl/mathgl/mgl64" ) -// SwimPose reports whether the collapsed swim hitbox applies. It requires the -// client's swimming flag *and* recent server-observed water contact, because -// the flag alone is client-controlled: without the second condition a client -// could shrink its server-side hitbox from 1.8 to 0.6 in open air and walk -// through gaps a standing player cannot fit. See SwimWaterGraceTicks. -// -// The grace budget is clamped to its configured bound at the start of a tick -// and decremented at the end, so this stays constant for the whole tick and -// collision, liquid detection and exit probing always agree on one hitbox. -// Entering water therefore adopts the swim pose one tick later than upstream, -// which errs toward the larger, more conservative box. +// SwimPose reports whether recent server-observed water contact permits the +// client-requested collapsed hitbox. func (s *MovementState) SwimPose() bool { return s.Swimming && s.SwimWaterGraceTicks > 0 } @@ -26,7 +17,6 @@ func (s *MovementState) BoundingBox(useSlideOffset bool) cube.BBox { width := (s.Size[0] * 0.5) * scale height := s.Size[1] * scale if s.SwimPose() { - // The swim pose collapses the hitbox to a width-sized cube. height = s.Size[0] * scale } yOffset := 0.0 @@ -50,7 +40,6 @@ func (s *MovementState) ClientBoundingBox(useSlideOffset bool) cube.BBox { width := (s.Size[0] * 0.5) * scale height := s.Size[1] * scale if s.SwimPose() { - // The swim pose collapses the hitbox to a width-sized cube. height = s.Size[0] * scale } yOffset := 0.0 diff --git a/constants.go b/constants.go index 42a1b81..19b9761 100644 --- a/constants.go +++ b/constants.go @@ -35,10 +35,6 @@ const ( JumpDelayTicks = 10 GlideBoostTicks = 20 - // DefaultSwimWaterGraceTicks bounds how long water travel survives on the - // client's swimming flag alone once the hitbox stops touching water. It - // covers the brief surface transition where a swimmer's collapsed hitbox - // no longer overlaps a water block, without letting a latched flag grant - // indefinite zero-gravity travel. + // DefaultSwimWaterGraceTicks bounds retained server-observed water contact. DefaultSwimWaterGraceTicks = 10 ) diff --git a/liquid.go b/liquid.go index dcd5826..82d5b5c 100644 --- a/liquid.go +++ b/liquid.go @@ -10,55 +10,12 @@ import ( "github.com/sandertv/gophertunnel/minecraft/protocol/packet" ) -// Liquid movement physics, ported from oomph-ac/oomph PR #145 at commit -// 0bcbb8be25593f836a66ee6a4e302d4fb81fd2bb (anticheat/player/simulation/movement.go). -// -// The port is behaviorally 1:1 with that source apart from the following -// deliberate adaptations, which exist because bedsim is a standalone library -// rather than a component of a proxy: -// -// - Arithmetic is float64/mgl64 throughout, matching the rest of bedsim. -// Upstream is float32/mgl32, so results can differ in the low bits. -// - Liquids are matched by LiquidType() rather than by concrete Go type, so -// that custom world.Liquid implementations behave like water and lava. -// - The second block layer is read through the optional LiquidProvider -// instead of a hardcoded BlockLayer(pos, 1) call. Because that interface -// only exposes liquids, liquidMovementBlock treats a layer-1 entry as -// occupied only when it is a liquid; upstream treats any non-air layer-1 -// block as occupied. In Bedrock that layer only ever holds liquids. -// - Zero-valued speed and multiplier fields on MovementState mean "unset" -// and fall back to the Default* constants, so callers that do not track -// those attributes still get correct physics. -// - A nil EffectsProvider is tolerated and falls back to gravity, and a nil -// WorldProvider reads as empty space, so an incompletely wired simulator -// degrades to no-liquid rather than panicking. -// - The Depth Strider level is clamped to [0, 3]. Upstream clamps only the -// upper bound because the value comes from an enchantment; bedsim takes it -// from a caller-supplied provider that could report a negative level. -// -// Upstream also removed the sneak and consumable impulse clamps outright in -// this PR, clamping the move vector to [-1, 1] instead. bedsim deliberately -// does not follow that by default: MaxSneakImpulse and MaxConsumingImpulse are -// public API and apply to all movement, so removing them is a breaking change -// well outside liquid scope. Set SimulationOptions.UpstreamImpulseClamping to -// opt into upstream's behavior. -// -// Security hardening divergence: upstream gates water travel on the client's -// swimming flag alone (`len(waterBlocks) != 0 || Swimming`), and sizes the -// hitbox off the same flag. Oomph can afford that because the surrounding -// anticheat validates the flag, but a standalone authoritative simulator cannot -// — a latched flag would yield indefinite zero-gravity hovering in open air and -// a hitbox shrunk from 1.8 to 0.6, both with no correction raised. bedsim gates -// water travel and the swim pose (MovementState.SwimPose) on recent -// server-observed water contact, bounded by -// SimulationOptions.SwimWaterGraceTicks, clamped before use, reset on any frame -// that was not simulated, and overridden by lava the player is actually in. -// Real water contact is unaffected; see the README for the residual duty-cycle -// limit and the deliberate one-tick pose lag. - -// liquidKind identifies the liquid family a travel step simulates. Liquids are -// matched by LiquidType() so that custom world.Liquid implementations behave -// like the vanilla blocks they stand in for. +// Liquid movement follows oomph PR #145 at 0bcbb8b. bedsim retains float64, +// provider-based liquid lookup and its legacy impulse clamps. It also requires +// recent server-observed water contact before trusting the client swim flag. +// See README.md for complete compatibility and security notes. + +// liquidKind identifies the liquid family being simulated. type liquidKind uint8 const ( @@ -66,7 +23,6 @@ const ( liquidLava ) -// typeName is the world.Liquid.LiquidType() value identifying this kind. func (k liquidKind) typeName() string { if k == liquidLava { return "lava" @@ -74,7 +30,6 @@ func (k liquidKind) typeName() string { return "water" } -// matches reports whether the liquid belongs to this kind. func (k liquidKind) matches(liquid world.Liquid) bool { return liquid.LiquidType() == k.typeName() } diff --git a/liquid_hardening_test.go b/liquid_hardening_test.go index d76826c..008290c 100644 --- a/liquid_hardening_test.go +++ b/liquid_hardening_test.go @@ -10,10 +10,6 @@ import ( "github.com/go-gl/mathgl/mgl64" ) -// --- swim water-travel grace (spoof hardening) ------------------------------ - -// dryState returns a state that is airborne with no liquid anywhere and normal -// gravity seeded, so the non-liquid path visibly accelerates downward. func dryState() *MovementState { state := submergedState() state.Gravity = NormalGravity @@ -281,10 +277,6 @@ func TestSwimSpeedMultiplierDepthStriderScaling(t *testing.T) { } } -// --- liquid layer capability ------------------------------------------------ - -// explicitLiquids is a standalone LiquidProvider wired through Simulator.Liquids -// rather than discovered on the world adapter. type explicitLiquids struct { layer map[cube.Pos]world.Liquid } @@ -384,8 +376,6 @@ func TestLiquidLayerFallbackStillSimulatesByDefault(t *testing.T) { } } -// --- impulse clamping opt-in ------------------------------------------------ - // By default bedsim keeps its sneak and consumable impulse clamps; the opt-in // selects upstream's behavior, which applies neither. func TestUpstreamImpulseClampingOptIn(t *testing.T) { @@ -427,8 +417,6 @@ func TestUpstreamImpulseClampingStillBoundsMoveVector(t *testing.T) { } } -// --- flying gate ------------------------------------------------------------ - // A flying player is treated as an unsupported scenario before physics run at // all, so no liquid physics are applied and the state is reset to the client. func TestFlyingIsUnreliableBeforePhysics(t *testing.T) { @@ -462,8 +450,6 @@ func TestLiquidGateExcludesFlying(t *testing.T) { } } -// --- flow weights ----------------------------------------------------------- - // The drop contribution weights an open neighbour with liquid below by // (decay(below) - decay(current) + 8). Competing horizontal terms make the // weight observable through the normalized result. @@ -499,8 +485,6 @@ func TestFallingFlowDownwardWeightIsSix(t *testing.T) { assertVec(t, flow, want) } -// --- stairs face blocking --------------------------------------------------- - // A waterlogged stairs block whose solid face points at the neighbour blocks // flow through that face. func TestStairsSolidFaceBlocksFlow(t *testing.T) { @@ -523,8 +507,6 @@ func TestStairsSolidFaceBlocksFlow(t *testing.T) { } } -// --- nil world -------------------------------------------------------------- - // A simulator with no world must not panic on any liquid path. func TestNilWorldIsSafe(t *testing.T) { sim := &Simulator{Options: SimulationOptions{PositionCorrectionThreshold: 0.3}} @@ -548,8 +530,6 @@ func TestNilWorldIsSafe(t *testing.T) { } } -// --- swim hitbox collision interaction -------------------------------------- - // The collapsed swim hitbox fits under a ceiling that a standing hitbox would // hit, so the swim pose genuinely changes collision results. func TestSwimHitboxChangesCeilingCollision(t *testing.T) { @@ -584,8 +564,6 @@ func TestSwimHitboxChangesCeilingCollision(t *testing.T) { } } -// --- determinism ------------------------------------------------------------- - // Repeated identical runs must agree exactly. A single pinned golden detects // map-iteration nondeterminism only probabilistically, so this repeats the same // scenario and compares runs against each other. @@ -626,17 +604,7 @@ func TestLiquidSimulationIsRepeatable(t *testing.T) { } } -// --- pinned golden regression ---------------------------------------------- - -// A rich mixed-liquid scenario pinned to exact expected values, exercising -// swim travel, depth strider, the dolphin multiplier, flow from a depth -// gradient and a falling source, and a jump input, over 20 ticks. -// -// These constants are a change detector, captured from the implementation -// after it was audited line-by-line against the upstream source. They are not -// independently derived, so a failure here means "physics changed" and should -// be re-derived deliberately, not silently updated. The player stays submerged -// for the whole run so the values do not sit on a surface transition. +// Pinned change detector for the mixed-liquid path; update deliberately. func TestLiquidGoldenScenario(t *testing.T) { // Deep enough that the player stays submerged for the whole run, so the // golden measures liquid physics rather than a surface transition. diff --git a/liquid_test.go b/liquid_test.go index b5085d2..a0b73a1 100644 --- a/liquid_test.go +++ b/liquid_test.go @@ -164,8 +164,6 @@ func assertVec(t *testing.T, got, want mgl64.Vec3) { } } -// --- swimming hitbox ------------------------------------------------------- - // A swimming player's hitbox collapses to a width-sized cube, matching the // client's swim pose. This drives collision, liquid detection and exit probing. func TestSwimmingBoundingBoxUsesWidthAsHeight(t *testing.T) { @@ -251,8 +249,6 @@ func TestSwimmingBoundingBoxRespectsScale(t *testing.T) { } } -// --- jump / ascend inputs -------------------------------------------------- - // Jumping is edge-triggered by StartJumping alone. A held jump key sets // PressingJump and EffectiveJumping, but must not re-arm the ground jump. func TestJumpingIsEdgeTriggeredByStartJumping(t *testing.T) { @@ -297,8 +293,6 @@ func TestEffectiveJumpingSources(t *testing.T) { } } -// --- swim state ------------------------------------------------------------ - // SwimAmount interpolates by 0.1 per tick, using the swimming state as it was // at the start of the tick. func TestSwimAmountInterpolation(t *testing.T) { @@ -375,8 +369,6 @@ func TestStopSwimmingTakesPriority(t *testing.T) { } } -// --- water physics --------------------------------------------------------- - // A player resting in still water sinks at the water gravity rate, with water // drag applied before gravity. func TestWaterDragAndGravity(t *testing.T) { @@ -498,8 +490,6 @@ func TestLiquidResetsFallDistance(t *testing.T) { } } -// --- jump / descend inputs in liquid --------------------------------------- - // Holding jump underwater adds a fixed 0.04 ascent impulse. func TestEffectiveJumpingAscendsInWater(t *testing.T) { sim := newLiquidSim(filledColumn(waterSource)) @@ -582,8 +572,6 @@ func TestDescendInputsDoNotChangeSneakImpulseClamp(t *testing.T) { } } -// --- swim travel (pitch steering) ------------------------------------------ - // While swimming, pitch steers vertical velocity toward -sin(pitch). func TestSwimTravelFollowsPitch(t *testing.T) { sim := newLiquidSim(filledColumn(waterSource)) @@ -678,8 +666,6 @@ func TestSwimTravelSurfaceClampSkippedWhenWantDownSlow(t *testing.T) { } } -// --- depth strider --------------------------------------------------------- - // Depth Strider lowers the horizontal drag coefficient toward 0.546, so // existing momentum decays faster rather than slower. func TestDepthStriderLowersDragCoefficient(t *testing.T) { @@ -798,8 +784,6 @@ func TestInventoryWithoutDepthStriderProvider(t *testing.T) { } } -// --- dolphin boost / swim speed multiplier --------------------------------- - // A dolphin boost raises the swim speed multiplier, which only takes effect // while actually swimming. func TestSwimSpeedMultiplierRequiresSwimming(t *testing.T) { @@ -894,8 +878,6 @@ func TestZeroMovementSpeedsUseDefaults(t *testing.T) { assertVec(t, state.Vel, explicitState.Vel) } -// --- liquid detection ------------------------------------------------------ - // Water is detected through a shallow vertical offset, so a player standing on // top of a water block is still considered to be in water. func TestWaterDetectedAtFeet(t *testing.T) { @@ -952,8 +934,6 @@ func TestWaterTakesPriorityOverLava(t *testing.T) { assertVec(t, state.Vel, mgl64.Vec3{0, -0.005, 0}) } -// --- provider fallbacks ---------------------------------------------------- - // Without a LiquidProvider, liquids are read from WorldProvider.Block. func TestLiquidFallsBackToBlockProvider(t *testing.T) { sim := newLiquidSim(filledColumn(waterSource)) @@ -1023,8 +1003,6 @@ func TestLiquidIsReliableScenario(t *testing.T) { } } -// --- flying and gliding ---------------------------------------------------- - // Flying players ignore liquid physics entirely. // Flying is covered by TestFlyingIsUnreliableBeforePhysics and // TestLiquidGateExcludesFlying in liquid_hardening_test.go, which separate the @@ -1125,8 +1103,6 @@ func TestSwimmingOutsideWaterSuppressesJump(t *testing.T) { } } -// --- flow ------------------------------------------------------------------ - // Flowing water pushes the player toward the lower-depth neighbour. func TestLiquidFlowPushesTowardLowerDepth(t *testing.T) { w := newLiquidWorld(). @@ -1271,8 +1247,6 @@ func TestFallingLiquidDecayAndHeight(t *testing.T) { } } -// --- exit probing ---------------------------------------------------------- - // Colliding horizontally with a ledge that has clear air above lets the player // hop out of the liquid. func TestLiquidExitProbeBoostsOverLedge(t *testing.T) { @@ -1368,8 +1342,6 @@ func TestNoExitProbeWithoutHorizontalCollision(t *testing.T) { } } -// --- ladder climbing ------------------------------------------------------- - // Climbing is driven by EffectiveJumping, so the automatic ascent inputs climb // a ladder just like a held jump key does. func TestClimbUsesEffectiveJumping(t *testing.T) { @@ -1406,8 +1378,6 @@ func TestClimbUsesEffectiveJumping(t *testing.T) { // liquid_hardening_test.go, which pins the same mixed-liquid scenario to exact // expected values rather than comparing the implementation against itself. -// --- box shrinking --------------------------------------------------------- - // Shrinking a box past its own size collapses it to its midpoint instead of // inverting it. func TestShrinkLiquidBoxCollapsesToMidpoint(t *testing.T) { diff --git a/movement.go b/movement.go index 67d32c3..0f48fe8 100644 --- a/movement.go +++ b/movement.go @@ -41,11 +41,9 @@ type MovementState struct { AirSpeed float64 UnderwaterMovementSpeed float64 LavaMovementSpeed float64 - // SwimSpeedMultiplier scales water acceleration while swimming. A dolphin - // boost sets this to 2. Zero is treated as the default of 1. + // SwimSpeedMultiplier scales swimming acceleration; zero means the default. SwimSpeedMultiplier float64 - // DolphinBoostTicks counts down the remaining duration of a dolphin boost. - // When it reaches zero, SwimSpeedMultiplier is reset to its default. + // DolphinBoostTicks is the remaining dolphin-boost duration. DolphinBoostTicks int64 ServerUpdatedSpeed bool @@ -71,10 +69,7 @@ type MovementState struct { Swimming bool SwimAmount float64 - // SwimWaterGraceTicks is the remaining budget of ticks during which the - // swimming flag alone may keep water travel alive, refilled whenever the - // hitbox actually touches water. It is server-derived evidence, not client - // input, and callers should not set it directly. + // SwimWaterGraceTicks retains recent server-observed water contact. SwimWaterGraceTicks int64 AutoJumpingInWater bool WantDown, WantDownSlow bool diff --git a/simulation.go b/simulation.go index 0f12354..ad1712e 100644 --- a/simulation.go +++ b/simulation.go @@ -201,10 +201,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.WantDown = input.WantDown state.WantDownSlow = input.WantDownSlow - // Upstream PR #145 removed both clamps and clamps the move vector to - // [-1, 1]. bedsim keeps them by default because they are public API that - // applies to all movement; UpstreamImpulseClamping opts into upstream's - // behavior without a breaking change. + // Preserve bedsim's public impulse clamps unless upstream behavior is opted in. maxImpulse := 1.0 if !s.Options.UpstreamImpulseClamping { if input.UsingConsumable { @@ -219,9 +216,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { ClampFloat(input.MoveVector[1], -maxImpulse, maxImpulse), } - // Jumping is edge-triggered: only the start-jump flag arms a ground jump. - // EffectiveJumping covers the held-key and automatic ascent cases used by - // liquid travel and ladders. + // Ground jumps are edge-triggered; liquid and ladder ascent may be held. state.Jumping = input.StartJumping state.PressingJump = input.Jumping state.EffectiveJumping = input.Jumping || input.AutoJumpingInWater || input.AscendBlock @@ -299,16 +294,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SetVel(mgl64.Vec3{}) } - // Upstream keys water travel on the client's swimming flag alone, which a - // standalone authoritative simulator cannot trust: a latched flag would - // grant zero-gravity travel — and a shrunken hitbox — in open air forever. - // Water travel and the swim pose therefore survive on the flag only while - // recent server-observed water contact remains. - // - // The bound is applied up front, before anything reads the budget, so a - // stale or caller-supplied value can never widen the window. The budget is - // then decremented at the end of the tick, keeping the value constant for - // the whole tick so every hitbox lookup agrees with the gate below. + // Bound retained water evidence before collision and travel inspect it. grace := s.swimWaterGraceTicks() if state.SwimWaterGraceTicks > grace { state.SwimWaterGraceTicks = grace @@ -326,8 +312,7 @@ func (s *Simulator) simulateMovement(state *MovementState) { } }() - // Retained proof must not override a liquid the player is demonstrably - // standing in: lava contact wins over a stale water grace. + // Observed lava takes precedence over retained water evidence. waterTravel := inWater || (state.Swimming && state.SwimWaterGraceTicks > 0 && len(lavaBlocks) == 0) diff --git a/simulator.go b/simulator.go index 66ea7b9..f633d90 100644 --- a/simulator.go +++ b/simulator.go @@ -45,24 +45,14 @@ type SimulationOptions struct { // rejected due to the client position matching the pre-step position. IgnoreClientStepTiebreaker bool - // RequireLiquidLayer makes the simulator refuse to run when it cannot see - // the second block layer, returning SimulationOutcomeUnreliable instead of - // silently simulating waterlogged blocks as if they were dry. Authoritative - // and anticheat deployments should set this. It defaults to false so that - // integrations predating Simulator.Liquids keep working. + // RequireLiquidLayer refuses simulation without second-layer liquid data. RequireLiquidLayer bool - // SwimWaterGraceTicks bounds how many consecutive ticks water travel is - // retained on the client's swimming flag alone, after the last tick where - // the hitbox actually touched water. Zero uses DefaultSwimWaterGraceTicks; - // a negative value disables the grace entirely, requiring real water - // contact on every tick. + // SwimWaterGraceTicks bounds retained water contact. Zero uses the default; + // a negative value disables retention. SwimWaterGraceTicks int64 - // UpstreamImpulseClamping selects oomph PR #145's impulse handling, which - // clamps the move vector to [-1, 1] and applies no sneak or consumable - // reduction. It defaults to false, keeping bedsim's MaxSneakImpulse and - // MaxConsumingImpulse behavior from v0.1.3. + // UpstreamImpulseClamping opts into oomph PR #145's unclamped impulses. UpstreamImpulseClamping bool // Debugf receives internal simulation trace logs for callers that need deep diagnostics. @@ -75,12 +65,8 @@ type Simulator struct { // BlockSemantics optionally resolves movement-specific block behavior from // the same world snapshot as World. Nil uses DefaultBlockSemantics. BlockSemantics BlockSemanticsProvider - // Liquids exposes liquids from the second block layer, which is what makes - // waterlogged blocks visible to movement. Set this explicitly. If it is nil - // and World itself implements LiquidProvider, that is used instead; if - // neither is present, only liquids returned by World.Block are seen and - // waterlogged blocks are invisible. Use HasLiquidLayer to check, or - // SimulationOptions.RequireLiquidLayer to fail closed. + // Liquids exposes second-layer liquids. World is used when it implements + // LiquidProvider; otherwise waterlogged blocks are invisible. Liquids LiquidProvider Effects EffectsProvider Inventory InventoryProvider From 98e44e4f65d52b602badf9bf63e4d9c645786ac8 Mon Sep 17 00:00:00 2001 From: HashimTheArab Date: Mon, 20 Jul 2026 09:19:19 -0700 Subject: [PATCH 7/7] test: use promoted liquid world lookup --- liquid_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/liquid_test.go b/liquid_test.go index a0b73a1..07cf173 100644 --- a/liquid_test.go +++ b/liquid_test.go @@ -107,7 +107,7 @@ func (w *layeredLiquidWorld) Liquid(pos cube.Pos) (world.Liquid, bool) { if liquid, ok := w.layer[pos]; ok { return liquid, true } - liquid, ok := w.liquidWorld.Block(pos).(world.Liquid) + liquid, ok := w.Block(pos).(world.Liquid) return liquid, ok }