diff --git a/README.md b/README.md index 5476557..1fc0cd0 100644 --- a/README.md +++ b/README.md @@ -79,15 +79,40 @@ should affect water movement. `WorldProvider` is the only required world interface. A world may additionally implement `BubbleColumnProvider` for upward/downward columns and -`MovementCollisionProvider` for player-dependent collision shapes such as +`BubbleColumnSurfaceProvider` when it can classify the exact surface variant, +and `MovementCollisionProvider` for player-dependent collision shapes such as scaffolding and powder snow. Dynamic collision resolution receives sneak and descend intent plus leather-boots state. +For reliable streaming-world simulation, implement `MovementAreaProvider` so a +swept movement volume can be checked precisely. Without it, BedSim checks every +chunk touched by the current bounding box and velocity. Implement +`ClimbableContactProvider` when ladder/vine orientation is resolved outside the +block registry — it replaces the built-in single-cell check rather than adding +to it — and `MovementSupportProvider` when dynamic collision shapes need to +identify their supporting block. + `MovementEquipmentProvider` supplies Depth Strider, Soul Speed, Swift Sneak, Riptide, and leather-boots checks. The legacy `DepthStriderProvider` inventory extension remains a fallback when the equipment provider reports no Depth Strider level. `EffectsProvider` also controls Weaving-aware web movement. +Use `MovementState.QueueKnockback` and `MovementState.QueueTeleport` for +authoritative events instead of setting their timer fields by hand. `Simulate` +consumes those events as part of its tick; callers using `SimulateState` must +clear transient fields such as `KnockbackPending` and +`StoppedSwimmingThisTick` themselves. Set `MovementState.JumpStrength` for a +custom base jump velocity; zero keeps the default. + +`MovementState.MovementSpeed` and `DefaultMovementSpeed` are effective movement +attribute values. Include active Speed or Slowness modifiers in those values; +BedSim uses them directly and does not apply the same modifiers a second time. +`AirSpeed` is the air acceleration speed. It does not track the movement +attribute: `Simulate` sets it to `WalkAirSpeed` or `SprintAirSpeed` from the +sprint state, and `SimulateState` callers provide it with the current state. +`JumpHeight` is output-only and derived during simulation; set `JumpStrength` +when a custom base jump velocity is needed. + Riptide input flags are not trusted on their own. Set `MovementState.RiptideReady` for the simulation tick only after validating a charged Riptide-trident release. Set `MovementState.RiptideCollision` after a server-observed entity collision to @@ -191,6 +216,11 @@ would be a breaking change outside liquid scope. Set - `Simulate` — applies client input, runs physics, advances tick counters, and returns the result. Use this when bedsim owns the full tick lifecycle. - `SimulateState` — runs physics on the current state without applying input or ticking counters. Use this when your caller handles input parsing and tick management externally. +Both entry points reject NaN and infinite state/input values with +`SimulationOutcomeInvalidInput`. Mounted players return +`SimulationOutcomeMounted` after being aligned to their client-reported state; +vehicle physics belongs in the caller's vehicle simulation. + ### Correction modes - `SimulationModeAuthoritative` — `NeedsCorrection` becomes true if position or velocity drift exceeds thresholds. @@ -205,4 +235,4 @@ Each tick returns a `SimulationResult` containing: - Collision flags (`CollideX`, `CollideY`, `CollideZ`, `OnGround`) - `PositionDelta` / `VelocityDelta` — difference from client-reported values - `NeedsCorrection` — whether deltas exceed configured thresholds -- `Outcome` — which simulation path was taken (normal, teleport, unreliable, unloaded chunk, immobile) +- `Outcome` — which simulation path was taken (normal, teleport, unreliable, unloaded chunk, immobile, mounted, or invalid input) diff --git a/block/environment.go b/block/environment.go index 1e342d7..83f7034 100644 --- a/block/environment.go +++ b/block/environment.go @@ -17,5 +17,9 @@ func (r environmentRule) Apply(s resolution) resolution { s.InsideMovement = r.inside s.Traversal = r.traversal s.Honey = r.honey + if r.honey { + s.GroundFriction = 0.8 + s.groundFrictionSet = true + } return s } diff --git a/block/semantics.go b/block/semantics.go index da842e3..29a7eac 100644 --- a/block/semantics.go +++ b/block/semantics.go @@ -75,6 +75,7 @@ var rules = [...]rule{ environmentRule{name: "minecraft:powder_snow", inside: InsideMovementPowderSnow, traversal: TraversalPowderSnow}, environmentRule{name: "minecraft:scaffolding", traversal: TraversalScaffolding}, frictionBlock{name: "minecraft:ice", friction: 0.98}, + frictionBlock{name: "minecraft:frosted_ice", friction: 0.98}, frictionBlock{name: "minecraft:packed_ice", friction: 0.98}, frictionBlock{name: "minecraft:blue_ice", friction: 0.989}, } diff --git a/block_effects.go b/block_effects.go index 51d0e7a..6b47dd8 100644 --- a/block_effects.go +++ b/block_effects.go @@ -47,12 +47,16 @@ func applyStuckSpeedMultiplier(state *MovementState) bool { return true } -func applyAscendableMovement(state *MovementState, traversal movementblock.Traversal, leatherBoots bool) { +// applyAscendableMovement applies input-driven vertical block traversal and +// reports whether ordinary vertical travel should be skipped. +func applyAscendableMovement(state *MovementState, traversal movementblock.Traversal, leatherBoots bool) bool { velocity := state.Vel switch traversal { case movementblock.TraversalScaffolding: if state.PressingDescend { velocity[1] = -0.15 + state.SetVel(velocity) + return true } else if state.PressingAscend { velocity[1] = 0.15 } @@ -64,6 +68,7 @@ func applyAscendableMovement(state *MovementState, traversal movementblock.Trave } } state.SetVel(velocity) + return false } func (s *Simulator) applyInsideBlockEffects(state *MovementState) { @@ -110,8 +115,22 @@ func (s *Simulator) applyHoneyWallSlide(state *MovementState) { velocity[1] = max(-0.12, velocity[1]) velocity[2] *= 0.4 state.SetVel(velocity) + if honeySlideResetsFallDistance(state, pos) { + state.FallDistance = 0 + } } } } } } + +// honeySlideResetsFallDistance reports whether contact is with a honey side +// rather than the top surface. +func honeySlideResetsFallDistance(state *MovementState, pos cube.Pos) bool { + if state.Vel.Y() >= 0 || state.Pos.Y() > float32(pos.Y())+0.9375 { + return false + } + radius := state.Size.X()*state.Size.Z()*0.5 + 0.43125 + centerX, centerZ := float32(pos.X())+0.5, float32(pos.Z())+0.5 + return math32.Abs(centerX-state.Pos.X()) > radius || math32.Abs(centerZ-state.Pos.Z()) > radius +} diff --git a/block_effects_test.go b/block_effects_test.go index 79c4ccf..26e9885 100644 --- a/block_effects_test.go +++ b/block_effects_test.go @@ -145,6 +145,38 @@ func TestHoneyWallSlideAppliesOnSolidSideContact(t *testing.T) { } } +func TestHoneySideSlideResetsFallDistance(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:honey_block"}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{1.25, 0, 0.5} + state.Vel = mgl32.Vec3{0, -0.2, 0} + state.FallDistance = 4 + + (&Simulator{World: w, BlockSemantics: encodedBlockSemantics{}}).applyHoneyWallSlide(state) + + if state.FallDistance != 0 { + t.Fatalf("honey side slide left fall distance = %v", state.FallDistance) + } +} + +func TestHoneyTopContactPreservesFallDistance(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:honey_block"}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Vel = mgl32.Vec3{0, -0.2, 0} + state.FallDistance = 4 + + (&Simulator{World: w, BlockSemantics: encodedBlockSemantics{}}).applyHoneyWallSlide(state) + + if state.FallDistance != 4 { + t.Fatalf("honey top contact changed fall distance to %v", state.FallDistance) + } +} + func TestScaffoldingAscendAndDescendSpeeds(t *testing.T) { state := newBaseState() state.PressingAscend = true @@ -204,6 +236,45 @@ func TestSimulationAppliesScaffoldingTraversal(t *testing.T) { } } +func TestScaffoldingDescendSkipsAirGravity(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:scaffolding"}, + }} + sim := &Simulator{World: w, BlockSemantics: encodedBlockSemantics{}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Gravity = NormalGravity + state.HasGravity = true + state.PressingDescend = true + state.FallDistance = 4 + sim.SimulateState(state) + if math32.Abs(state.Vel.Y()-(-0.15)) > 1e-6 { + t.Fatalf("scaffolding descent velocity = %v, want %v", state.Vel.Y(), -0.15) + } + if state.FallDistance != 0 { + t.Fatalf("scaffolding descent left fall distance = %v", state.FallDistance) + } +} + +func TestScaffoldingSupportEnablesDescent(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:scaffolding"}, + }} + sim := &Simulator{World: w, BlockSemantics: encodedBlockSemantics{}} + support := cube.Pos{0, 0, 0} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 1, 0.5} + state.OnGround = true + state.HasGravity = true + state.SupportingBlockPos = &support + state.PressingDescend = true + + sim.SimulateState(state) + if math32.Abs(state.Vel.Y()-(-0.15)) > 1e-6 { + t.Fatalf("supported scaffolding descent velocity = %v, want %v", state.Vel.Y(), -0.15) + } +} + func TestSimulationDetectsNonSolidWebAndAppliesWeaving(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{ {0, 0, 0}: semanticsNamedBlock{name: "minecraft:web"}, diff --git a/block_semantics_test.go b/block_semantics_test.go index 6f1f30c..fca74f3 100644 --- a/block_semantics_test.go +++ b/block_semantics_test.go @@ -106,6 +106,12 @@ func TestBlueIceFrictionMatchesAcrossBlockRepresentations(t *testing.T) { } } +func TestFrostedIceFrictionMatchesVanillaIce(t *testing.T) { + if got := movementblock.Resolve(semanticsNamedBlock{"minecraft:frosted_ice"}, "minecraft:frosted_ice").GroundFriction; got != 0.98 { + t.Fatalf("frosted ice friction = %.8f, want 0.98", got) + } +} + func TestDefaultMovementBlockSemanticsSpecialBlocks(t *testing.T) { for name, want := range map[string]struct { block world.Block @@ -151,6 +157,13 @@ func TestEnvironmentMovementSemantics(t *testing.T) { } } +func TestHoneyBlockFrictionMatchesVanilla(t *testing.T) { + got := movementblock.Resolve(semanticsNamedBlock{"minecraft:honey_block"}, "minecraft:honey_block") + if got.GroundFriction != 0.8 { + t.Fatalf("honey block friction = %.8f, want 0.8", got.GroundFriction) + } +} + // semanticsNamedBlock is enough to exercise name-based semantics without depending on // a particular Dragonfly block implementation being present in the registry. type semanticsNamedBlock struct{ name string } diff --git a/block_test.go b/block_test.go index e830b6d..94f1c6e 100644 --- a/block_test.go +++ b/block_test.go @@ -27,6 +27,9 @@ func (namedBlock) Model() world.BlockModel { } func TestBlockNameCachesRawHashPair(t *testing.T) { + key := blockNameKey{base: 0xf32ca, state: 7} + blockNameCache.Delete(key) + t.Cleanup(func() { blockNameCache.Delete(key) }) var calls int b := namedBlock{name: "test:cached", base: 0xf32ca, state: 7, encodeCalls: &calls} @@ -53,6 +56,9 @@ func TestBlockNameDoesNotCacheUnknownHash(t *testing.T) { } func TestBlockNameCachesMaxStateWithKnownBase(t *testing.T) { + key := blockNameKey{base: 1, state: math.MaxUint64} + blockNameCache.Delete(key) + t.Cleanup(func() { blockNameCache.Delete(key) }) var calls int b := namedBlock{name: "test:max_state", base: 1, state: math.MaxUint64, encodeCalls: &calls} diff --git a/bubble.go b/bubble.go index 2e5af00..126bb3b 100644 --- a/bubble.go +++ b/bubble.go @@ -21,6 +21,13 @@ type BubbleColumnProvider interface { BubbleColumn(pos cube.Pos) (BubbleColumnDirection, bool) } +// BubbleColumnSurfaceProvider optionally supplies the exact client-side +// surface variant for a bubble-column cell. The bool reports whether the +// adapter knows the variant; false falls back to the block-above heuristic. +type BubbleColumnSurfaceProvider interface { + BubbleColumnSurface(pos cube.Pos) (surface, known bool) +} + func applyBubbleColumn(state *MovementState, direction BubbleColumnDirection, surface bool) { velocity := state.Vel switch direction { @@ -40,10 +47,6 @@ func applyBubbleColumn(state *MovementState, direction BubbleColumnDirection, su state.SetVel(velocity) } -// applyBubbleColumns applies at most one column impulse per tick. An entity -// carries a single resolved column contact regardless of how many column cells -// its hitbox overlaps, and the topmost overlapped cell decides the contact: -// only that cell can have the open air above it that selects the surface form. func (s *Simulator) applyBubbleColumns(state *MovementState) { provider, ok := s.World.(BubbleColumnProvider) if !ok { @@ -51,49 +54,105 @@ func (s *Simulator) applyBubbleColumns(state *MovementState) { } bb := state.BoundingBox(s.Options.UseSlideOffset) min, max := bb.Min(), bb.Max() - contact, found := cube.Pos{}, false - var direction BubbleColumnDirection + found := false for x := int(math32.Floor(min.X())); x < int(math32.Ceil(max.X())); x++ { for y := int(math32.Floor(min.Y())); y < int(math32.Ceil(max.Y())); y++ { for z := int(math32.Floor(min.Z())); z < int(math32.Ceil(max.Z())); z++ { pos := cube.Pos{x, y, z} - cellDirection, ok := provider.BubbleColumn(pos) - if !ok || (found && pos.Y() <= contact.Y()) { + direction, ok := provider.BubbleColumn(pos) + if !ok { continue } - contact, direction, found = pos, cellDirection, true + found = true + surface, known := false, false + if surfaceProvider, ok := s.World.(BubbleColumnSurfaceProvider); ok { + surface, known = surfaceProvider.BubbleColumnSurface(pos) + } + if !known { + above := pos.Side(cube.FaceUp) + _, liquidAbove := s.liquidAt(above) + surface = !liquidAbove && s.blockAir(s.blockAtPos(above)) + } + applyBubbleColumn(state, direction, surface) } } } if !found { return } - above := contact.Side(cube.FaceUp) - _, liquidAbove := s.liquidAt(above) - applyBubbleColumn(state, direction, !liquidAbove && s.blockAir(s.blockAtPos(above))) state.FallDistance = 0 } -func (s *Simulator) attemptRiptide(state *MovementState, touchingWater bool) bool { +// attemptRiptide applies a validated one-shot Riptide launch and reports +// whether every world probe needed for the decision was known. +func (s *Simulator) attemptRiptide(state *MovementState, touchingWater bool) (launched, known bool) { if s.Equipment == nil || state.InVehicle || state.RiptideTicks > 0 || !state.RiptideReady || (!touchingWater && !state.RiptideInRain) { - return false + return false, true } level := s.Equipment.EnchantmentLevel(EnchantmentRiptide) if level <= 0 || !state.StartingSpinAttack { - return false + return false, true + } + headInWater := false + if state.OnGround && state.HasGravity && touchingWater { + headInWater, known = s.riptideHeadInWaterKnown(state) + if !known { + return false, false + } } - force := 1.5 + 0.75*float32(level-1) + state.SetVel(state.Vel.Add(s.riptideImpulse(state, level, touchingWater, headInWater))) + state.RiptideTicks = 20 + state.RiptideCollision = false + state.StartingSpinAttack = false + return true, true +} + +// riptideImpulse returns the one-shot launch velocity for a spin attack. The +// grounded adjustment compensates for the drag and gravity the same tick will +// apply afterwards, so it is skipped entirely while airborne. +func (s *Simulator) riptideImpulse(state *MovementState, level int, wasInWater, headInWater bool) mgl32.Vec3 { + force := 0.75 * float32(level+1) pitch := state.Rotation.X() * math32.Pi / 180 yaw := state.Rotation.Z() * math32.Pi / 180 direction := mgl32.Vec3{-MCSin(yaw) * MCCos(pitch), -MCSin(pitch), MCCos(yaw) * MCCos(pitch)} if length := direction.Len(); length > 0 { direction = direction.Mul(force / length) } - state.SetVel(state.Vel.Add(direction)) - state.RiptideTicks = 20 - state.RiptideCollision = false - state.StartingSpinAttack = false - return true + if state.OnGround && state.HasGravity { + if wasInWater && !headInWater { + direction[1] = direction[1] / WaterDrag * NormalGravityMultiplier + } else { + direction[1] += state.Gravity + } + } + return direction +} + +// riptideHeadInWater reports whether the player's head is below the local +// water surface. +func (s *Simulator) riptideHeadInWater(state *MovementState) bool { + inWater, _ := s.riptideHeadInWaterKnown(state) + return inWater +} + +// riptideHeadInWaterKnown reports whether the head is submerged and whether +// the block containing it is loaded. +func (s *Simulator) riptideHeadInWaterKnown(state *MovementState) (inWater, known bool) { + heightOffset := DefaultPlayerHeightOffset + if state.Sneaking { + heightOffset = SneakingPlayerHeightOffset + } + position := state.Pos.Add(mgl32.Vec3{0, heightOffset, 0}) + pos := posFromVec3(position) + probe := cube.Box32( + float32(pos.X()), float32(pos.Y()), float32(pos.Z()), + float32(pos.X()+1), float32(pos.Y()+1), float32(pos.Z()+1), + ) + if !s.movementAreaLoaded(probe) { + return false, false + } + liquid, ok := s.liquidAt(pos) + return ok && liquidWater.matches(liquid) && position.Y() < float32(pos.Y())+liquidHeight(liquid), true } func stopRiptideOnBlockCollision(state *MovementState) { diff --git a/bubble_test.go b/bubble_test.go index 05cad52..3b98d7b 100644 --- a/bubble_test.go +++ b/bubble_test.go @@ -55,7 +55,35 @@ func TestBubbleColumnSurfaceAcceptsRegistryBackedAir(t *testing.T) { } } -func TestBubbleColumnAppliesOnceForOverlappedCells(t *testing.T) { +type exactBubbleSurfaceWorld struct { + environmentWorld + surface bool +} + +// BubbleColumnSurface returns the configured exact surface classification. +func (w exactBubbleSurfaceWorld) BubbleColumnSurface(cube.Pos) (bool, bool) { + return w.surface, true +} + +func TestBubbleColumnUsesExactSurfaceProvider(t *testing.T) { + w := exactBubbleSurfaceWorld{ + environmentWorld: environmentWorld{ + bubbles: map[cube.Pos]BubbleColumnDirection{{0, 0, 0}: BubbleColumnUp}, + blocks: map[cube.Pos]world.Block{{0, 1, 0}: block.Water{Still: true, Depth: 8}}, + }, + surface: true, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + + (&Simulator{World: w}).applyBubbleColumns(state) + + if state.Vel.Y() != 0.1 { + t.Fatalf("exact surface provider was ignored: %v", state.Vel.Y()) + } +} + +func TestBubbleColumnAppliesForEachOccupiedCell(t *testing.T) { w := environmentWorld{ bubbles: map[cube.Pos]BubbleColumnDirection{ {0, 0, 0}: BubbleColumnUp, @@ -72,16 +100,32 @@ func TestBubbleColumnAppliesOnceForOverlappedCells(t *testing.T) { (&Simulator{World: w}).applyBubbleColumns(state) - // The topmost overlapped cell has open air above it, so this resolves to the - // surface form and applies once: 0.1, not 0.06+0.1 for the two cells. - if want := float32(0.1); math32.Abs(state.Vel.Y()-want) > 1e-6 { - t.Fatalf("bubble-column velocity = %v, want a single impulse of %v", state.Vel.Y(), want) + // The lower cell applies its submerged impulse and the top cell applies its + // surface impulse: 0.06 + 0.1. + if want := float32(0.16); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("bubble-column velocity = %v, want per-cell impulses totalling %v", state.Vel.Y(), want) } if state.FallDistance != 0 { t.Fatalf("bubble-column contact left fall distance = %v", state.FallDistance) } } +func TestBubbleColumnAppliesOutsideLiquidTravel(t *testing.T) { + w := environmentWorld{ + bubbles: map[cube.Pos]BubbleColumnDirection{{0, 0, 0}: BubbleColumnUp}, + blocks: map[cube.Pos]world.Block{}, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.HasGravity = false + + (&Simulator{World: w}).SimulateState(state) + + if state.Vel.Y() != 0.1 { + t.Fatalf("normal movement missed surface bubble impulse: %v", state.Vel.Y()) + } +} + func TestRiptideLaunchesInWaterAndStartsSpinAttack(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Water{Still: true, Depth: 8}}} sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} @@ -100,6 +144,42 @@ func TestRiptideLaunchesInWaterAndStartsSpinAttack(t *testing.T) { } } +func TestRiptideHeadWaterUsesSneakingOffset(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 1, 0}: block.Water{Depth: 2}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Sneaking = true + + if !(&Simulator{World: w}).riptideHeadInWater(state) { + t.Fatal("sneaking head below the partial water surface was reported dry") + } +} + +func TestRiptideDoesNotCompensateDisabledGravity(t *testing.T) { + state := newBaseState() + state.OnGround = true + state.HasGravity = false + + impulse := (&Simulator{}).riptideImpulse(state, 2, false, false) + if impulse.Y() != 0 { + t.Fatalf("gravity-disabled Riptide impulse gained vertical motion: %v", impulse) + } +} + +func TestRiptideUsesConfiguredGravityForDryCompensation(t *testing.T) { + state := newBaseState() + state.OnGround = true + state.HasGravity = true + state.Gravity = 0.04 + + impulse := (&Simulator{}).riptideImpulse(state, 2, false, false) + if math32.Abs(impulse.Y()-state.Gravity) > 1e-6 { + t.Fatalf("Riptide vertical compensation = %v, want configured gravity %v", impulse.Y(), state.Gravity) + } +} + func TestRiptideDoesNotLaunchInLava(t *testing.T) { w := environmentWorld{blocks: map[cube.Pos]world.Block{{0, 0, 0}: block.Lava{Still: true, Depth: 8}}} sim := &Simulator{World: w, Equipment: fixedEquipment{EnchantmentRiptide: 2}} @@ -191,6 +271,17 @@ func TestRiptideStopsOnNormalMovementWallCollision(t *testing.T) { } } +func TestRiptideCollisionClearsActiveAttack(t *testing.T) { + state := newBaseState() + state.RiptideTicks = 10 + state.CollideX = true + + stopRiptideOnBlockCollision(state) + if state.RiptideTicks != 0 { + t.Fatalf("riptide collision left active attack: ticks=%d", state.RiptideTicks) + } +} + func TestRiptideStopRequiresValidatedEntityCollision(t *testing.T) { sim := &Simulator{} state := newBaseState() diff --git a/collision.go b/collision.go index 3765af8..1468e50 100644 --- a/collision.go +++ b/collision.go @@ -114,7 +114,13 @@ func doBBClipCollide(stationary, moving cube.BBox32, velocity mgl32.Vec3) (resul return } -// BBHasZeroVolume returns true if the bounding box has zero volume. +// BBHasZeroVolume returns true for empty or invalid bounding boxes. func BBHasZeroVolume(bb cube.BBox32) bool { - return bb.Min() == bb.Max() + min, max := bb.Min(), bb.Max() + for axis := range 3 { + if !finiteFloat(min[axis]) || !finiteFloat(max[axis]) || min[axis] >= max[axis] { + return true + } + } + return false } diff --git a/constants.go b/constants.go index 7811290..fbe289e 100644 --- a/constants.go +++ b/constants.go @@ -11,16 +11,25 @@ const ( StepHeight = float32(0.5625) SlideOffsetMultiplier = float32(0.4) SlimeBounceMultiplier = float32(-1) - BedBounceMultiplier = float32(-0.66) - // BedBounceCap bounds the upward bounce velocity. + BedBounceMultiplier = float32(-0.75) + // Deprecated: BedBounceCap is retained for source compatibility. Bed + // bounces are not capped. BedBounceCap = float32(1) // This can be validated in Mob::ascendLadder(). - ClimbSpeed = float32(0.2) - MaxConsumingImpulse = float32(0.1225) - MaxSneakImpulse = float32(0.3) + ClimbSpeed = float32(0.2) + MaxConsumingImpulse = float32(0.1225) + MaxSneakImpulse = float32(0.3) DefaultUnderwaterMovementSpeed = float32(0.02) DefaultLavaMovementSpeed = float32(0.02) DefaultSwimSpeedMultiplier = float32(1) + GlideHorizontalLookEpsilon = float32(1e-4) + // WalkAirSpeed and SprintAirSpeed are the air acceleration pair vanilla + // selects on the sprint flag alone; neither scales with the movement + // attribute. + WalkAirSpeed = float32(0.02) + SprintAirSpeed = float32(0.026) + // WaterDrag is the ordinary horizontal water drag; sprinting uses 0.9. + WaterDrag = float32(0.8) DefaultPlayerHeightOffset = float32(1.62) SneakingPlayerHeightOffset = float32(1.27) diff --git a/interfaces.go b/interfaces.go index f9738ce..4c7c69a 100644 --- a/interfaces.go +++ b/interfaces.go @@ -15,6 +15,14 @@ type WorldProvider interface { IsChunkLoaded(chunkX, chunkZ int32) bool } +// MovementAreaProvider can provide a precise loaded/known check for a swept +// movement volume in world space. Worlds that only expose chunk loading use BedSim's +// conservative chunk-range fallback. +type MovementAreaProvider interface { + // IsMovementAreaLoaded receives a world-space movement volume. + IsMovementAreaLoaded(aabb cube.BBox32) bool +} + // LiquidProvider returns liquids from either block layer at a position. type LiquidProvider interface { Liquid(pos cube.Pos) (world.Liquid, bool) @@ -36,6 +44,23 @@ type MovementCollisionProvider interface { GetMovementBBoxes(aabb cube.BBox32, context MovementCollisionContext) []cube.BBox32 } +// ClimbableContactProvider resolves orientation-aware ladder and vine contact. +// aabb is in world space. +// The built-in fallback scans intersecting block volumes when this is absent. +type ClimbableContactProvider interface { + // HasClimbableContact receives a world-space movement volume. + HasClimbableContact(aabb cube.BBox32) bool +} + +// MovementSupportProvider resolves the exact support block for dynamic shapes. +// aabb is in world space. +// It is optional because a generic collision provider may not retain source +// block identities. +type MovementSupportProvider interface { + // SupportingBlock receives a world-space movement volume. + SupportingBlock(aabb cube.BBox32, context MovementCollisionContext) (cube.Pos, bool) +} + // BlockMovementSemanticsProvider resolves the complete movement behavior for a // block from a custom world registry or block data. GroundFriction and // GroundAccelerationFrictionMultiplier must be finite and positive; invalid diff --git a/liquid.go b/liquid.go index 765d3af..00588c7 100644 --- a/liquid.go +++ b/liquid.go @@ -44,7 +44,7 @@ var liquidFaces = [...]struct { {cube.Pos{0, 0, 1}, mgl32.Vec3{0, 0, 1}}, } -func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, touchingLiquid bool) { +func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, touchingLiquid bool) bool { initialY := state.Pos.Y() water := kind == liquidWater // Captured before updateSwimTravel, matching the upstream ordering. @@ -100,12 +100,16 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, moveRelativeSpeed += (state.MovementSpeed - moveRelativeSpeed) * depthStriderFraction } } - moveRelative(state, moveRelativeSpeed) stuckMovement := applyStuckSpeedMultiplier(state) + if !s.movementSweepLoaded(state) { + return false + } oldVel := state.Vel oldOnGround := state.OnGround - s.tryCollisions(state, false) + if !s.tryCollisions(state, false) { + return false + } stopRiptideOnBlockCollision(state) if stuckMovement { state.SetMov(state.Vel) @@ -120,7 +124,7 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, vel := state.Vel if water { drag := float32(0.8) - if state.Sprinting { + if state.Sprinting || state.StoppedSwimmingThisTick { drag = 0.9 } if depthStriderLevel > 0 && swimSpeedMultiplier <= 1 { @@ -147,6 +151,9 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, if state.CollideX || state.CollideZ { raised := mgl32.Vec3{vel.X(), vel.Y() + 0.6 + initialY - state.Pos.Y(), vel.Z()} raisedBox := state.BoundingBox(s.Options.UseSlideOffset).Translate(raised) + if !s.movementAreaLoaded(raisedBox) { + return false + } hasCollision := s.hasNearbyBBoxes(state, raisedBox) hasLiquid := s.containsAnyLiquid(raisedBox) if debugf := s.Options.Debugf; debugf != nil { @@ -160,6 +167,7 @@ func (s *Simulator) simulateLiquidTravel(state *MovementState, kind liquidKind, s.applyBubbleColumns(state) s.applyInsideBlockEffects(state) state.FallDistance = 0 + return true } func liquidGravity(swimming, water bool) float32 { @@ -184,7 +192,7 @@ func (s *Simulator) updateSwimTravel(state *MovementState) { if targetY > 0 && !state.WantDownSlow && !state.PressingDescend { belowPos := posFromVec3(state.Pos.Add(mgl32.Vec3{0, DefaultPlayerHeightOffset - 1.1})) - if _, belowAir := s.liquidMovementBlock(belowPos).(block.Air); belowAir { + if s.blockAir(s.liquidMovementBlock(belowPos)) { liquidPos := posFromVec3(state.Pos.Add(mgl32.Vec3{0, DefaultPlayerHeightOffset - 1.2})) if _, liquid := s.liquidAt(liquidPos); !liquid { vel := state.Vel @@ -219,6 +227,9 @@ func (s *Simulator) touchingLiquidBlocks(state *MovementState, kind liquidKind) if !ok || !kind.matches(liquid) { continue } + if !liquidIntersects(box, pos, liquid) { + continue + } if debugf := s.Options.Debugf; debugf != nil { height := liquidHeight(liquid) surface := float32(pos[1]) + height @@ -302,6 +313,12 @@ func liquidHeight(liquid world.Liquid) float32 { return float32(liquid.LiquidDepth()+1) / 9 } +// liquidIntersects reports whether box reaches the liquid surface in pos. +func liquidIntersects(box cube.BBox32, pos cube.Pos, liquid world.Liquid) bool { + surface := float32(pos[1]) + liquidHeight(liquid) + return box.Max().Y() > float32(pos[1]) && box.Min().Y() < surface +} + func (s *Simulator) containsAnyLiquid(box cube.BBox32) bool { min, max := box.Min(), box.Max() minX, minY, minZ := int(math32.Floor(min.X())), int(math32.Floor(min.Y())), int(math32.Floor(min.Z())) @@ -309,7 +326,8 @@ func (s *Simulator) containsAnyLiquid(box cube.BBox32) bool { for x := minX; x < maxX; x++ { for z := minZ; z < maxZ; z++ { for y := minY; y < maxY; y++ { - if _, ok := s.liquidAt(cube.Pos{x, y, z}); ok { + pos := cube.Pos{x, y, z} + if liquid, ok := s.liquidAt(pos); ok && liquidIntersects(box, pos, liquid) { return true } } @@ -318,7 +336,13 @@ func (s *Simulator) containsAnyLiquid(box cube.BBox32) bool { return false } -func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, kind liquidKind) { +func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, kind liquidKind) bool { + if len(positions) == 0 { + return true + } + if !s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset).Grow(1)) { + return false + } flow := mgl32.Vec3{} for _, pos := range positions { liquid, ok := s.liquidAt(pos) @@ -337,6 +361,7 @@ func (s *Simulator) applyLiquidFlow(state *MovementState, positions []cube.Pos, debugf("%s flow applied strength=%.6f flow=%v vel=%v", kind.typeName(), strength, flow, state.Vel) } } + return true } func (s *Simulator) liquidFlow(pos cube.Pos, liquid world.Liquid) mgl32.Vec3 { @@ -380,8 +405,7 @@ func (s *Simulator) liquidFlow(pos cube.Pos, liquid world.Liquid) mgl32.Vec3 { } func (s *Simulator) liquidFlowSideClosed(pos, side cube.Pos) bool { - stairs, ok := s.blockAtPos(pos).(block.Stairs) - return ok && stairs.Model().FaceSolid(pos, pos.Face(side), s.World) + return s.blockAtPos(pos).Model().FaceSolid(pos, pos.Face(side), s.World) } func liquidDecay(liquid world.Liquid) int { diff --git a/liquid_hardening_test.go b/liquid_hardening_test.go index caf3699..5628702 100644 --- a/liquid_hardening_test.go +++ b/liquid_hardening_test.go @@ -507,6 +507,20 @@ func TestStairsSolidFaceBlocksFlow(t *testing.T) { } } +func TestWaterloggedTrapdoorSolidFaceBlocksFlow(t *testing.T) { + pos := cube.Pos{0, 0, 0} + neighbour := cube.Pos{1, 0, 0} + w := newLayeredLiquidWorld() + w.waterlog(pos, block.WoodTrapdoor{Facing: cube.West, Open: true}, block.Water{Depth: 8}) + w.set(neighbour, block.Water{Depth: 4}) + + flow := newLiquidSim(w).liquidFlow(pos, block.Water{Depth: 8}) + + if !approxEqual(flow.X(), 0) { + t.Fatalf("waterlogged trapdoor solid face allowed flow X = %v", flow.X()) + } +} + // A simulator with no world must not panic on any liquid path. func TestNilWorldIsSafe(t *testing.T) { sim := &Simulator{Options: SimulationOptions{PositionCorrectionThreshold: 0.3}} diff --git a/liquid_test.go b/liquid_test.go index 62f1d60..36f1e47 100644 --- a/liquid_test.go +++ b/liquid_test.go @@ -464,6 +464,32 @@ func TestSwimmingCancelsWaterGravity(t *testing.T) { } } +func TestStopSwimmingUsesFastWaterDragForOneTick(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = false + state.StoppedSwimmingThisTick = true + state.Vel = mgl32.Vec3{0.5, 0, 0} + + sim.SimulateState(state) + if !approxEqual(state.Vel.X(), 0.45) { + t.Fatalf("stop-swimming drag = %v, want 0.45", state.Vel.X()) + } +} + +func TestStopSwimmingFlagWithoutTransitionUsesNormalDrag(t *testing.T) { + sim := newLiquidSim(filledColumn(waterSource)) + state := submergedState() + state.Swimming = false + state.Vel = mgl32.Vec3{0.5, 0, 0} + + sim.Simulate(state, InputState{StopSwimming: true}) + + if !approxEqual(state.Vel.X(), 0.4) { + t.Fatalf("false stop-swimming drag = %v, want 0.4", state.Vel.X()) + } +} + // Gravity is skipped entirely when the state has no gravity. func TestNoGravityInLiquid(t *testing.T) { sim := newLiquidSim(filledColumn(waterSource)) diff --git a/movement.go b/movement.go index 1237a82..82f13f2 100644 --- a/movement.go +++ b/movement.go @@ -42,12 +42,23 @@ type MovementState struct { SupportingBlockPos *cube.Pos - Gravity float32 - JumpHeight float32 + Gravity float32 + // JumpHeight is an output derived by Simulate from JumpStrength and active + // effects; set JumpStrength to customize the base jump velocity. + JumpHeight float32 + // JumpStrength is the base jump velocity. Zero uses DefaultJumpHeight. + JumpStrength float32 FallDistance float32 - MovementSpeed float32 - DefaultMovementSpeed float32 + // MovementSpeed is the effective movement attribute used by travel. Include + // movement effects in this value before passing the state to BedSim. + MovementSpeed float32 + // DefaultMovementSpeed is the effective non-sprinting movement attribute + // used when sprinting is toggled. + DefaultMovementSpeed float32 + // AirSpeed is the air acceleration speed, which does not track the movement + // attribute. Simulate sets it from the sprint state; SimulateState callers + // provide it as part of the current state. AirSpeed float32 UnderwaterMovementSpeed float32 LavaMovementSpeed float32 @@ -59,6 +70,7 @@ type MovementState struct { Knockback mgl32.Vec3 TicksSinceKnockback uint64 + KnockbackPending bool PendingTeleportPos mgl32.Vec3 PendingTeleports int @@ -67,6 +79,7 @@ type MovementState struct { TicksSinceTeleport uint64 TeleportCompletionTicks uint64 TeleportIsSmoothed bool + TeleportPending bool Sprinting, PressingSprint bool ServerSprint, ServerSprintApplied bool @@ -81,6 +94,9 @@ type MovementState struct { Swimming bool SwimAmount float32 + // StoppedSwimmingThisTick selects the client's fast water drag on the + // transition out of swimming. + StoppedSwimmingThisTick bool // SwimWaterGraceTicks retains recent server-observed water contact. SwimWaterGraceTicks int64 AutoJumpingInWater bool @@ -170,13 +186,44 @@ func (s *MovementState) SetRotation(newRot mgl32.Vec3) { } func (s *MovementState) HasKnockback() bool { - return s.TicksSinceKnockback == 0 + return s.KnockbackPending || (s.TicksSinceKnockback == 0 && s.Knockback != (mgl32.Vec3{})) } func (s *MovementState) HasTeleport() bool { + if s.TeleportPending || s.PendingTeleports > 0 { + return true + } + if s.TeleportCompletionTicks == 0 { + return s.TicksSinceTeleport == 0 && s.TeleportPos != (mgl32.Vec3{}) + } return s.TicksSinceTeleport <= s.TeleportCompletionTicks } func (s *MovementState) RemainingTeleportTicks() int { - return int(s.TeleportCompletionTicks) - int(s.TicksSinceTeleport) + if !s.HasTeleport() || s.TicksSinceTeleport >= s.TeleportCompletionTicks { + return 0 + } + remaining := s.TeleportCompletionTicks - s.TicksSinceTeleport + maxInt := uint64(^uint(0) >> 1) + if remaining > maxInt { + return int(maxInt) + } + return int(remaining) +} + +// QueueKnockback schedules one authoritative velocity replacement. +func (s *MovementState) QueueKnockback(velocity mgl32.Vec3) { + s.Knockback = velocity + s.KnockbackPending = true + s.TicksSinceKnockback = 0 +} + +// QueueTeleport schedules one authoritative teleport. +func (s *MovementState) QueueTeleport(pos mgl32.Vec3, smoothed bool, completionTicks uint64) { + s.PendingTeleportPos = pos + s.TeleportPos = pos + s.TeleportIsSmoothed = smoothed + s.TeleportCompletionTicks = completionTicks + s.TicksSinceTeleport = 0 + s.TeleportPending = true } diff --git a/parity_regressions_test.go b/parity_regressions_test.go new file mode 100644 index 0000000..ef687fc --- /dev/null +++ b/parity_regressions_test.go @@ -0,0 +1,985 @@ +package bedsim + +import ( + "testing" + + "github.com/chewxy/math32" + "github.com/df-mc/dragonfly/server/block" + "github.com/df-mc/dragonfly/server/block/cube" + "github.com/df-mc/dragonfly/server/world" + "github.com/go-gl/mathgl/mgl32" + "github.com/sandertv/gophertunnel/minecraft/protocol/packet" +) + +func TestZeroValueStateHasNoSyntheticEvents(t *testing.T) { + var state MovementState + if state.HasKnockback() { + t.Fatal("zero state must not report knockback") + } + if state.HasTeleport() { + t.Fatal("zero state must not report teleport") + } +} + +func TestSimulationRejectsNonFiniteInputAndState(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{1, 2, 3} + result := (&Simulator{World: mockWorld{}}).Simulate(state, InputState{Pitch: math32.NaN()}) + if result.Outcome != SimulationOutcomeInvalidInput || !result.NeedsCorrection { + t.Fatalf("invalid input result = %+v", result) + } + if state.Pos != (mgl32.Vec3{1, 2, 3}) { + t.Fatalf("invalid input mutated state position to %v", state.Pos) + } + + state = newBaseState() + state.Vel[0] = math32.Inf(1) + result = (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeInvalidInput || !result.NeedsCorrection { + t.Fatalf("invalid state result = %+v", result) + } +} + +func TestInvalidInputResultPreservesAuthoritativeState(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{12, 64, 9} + state.Vel = mgl32.Vec3{0.1, 0.2, 0.3} + state.Mov = mgl32.Vec3{0.2, 0, 0} + + result := (&Simulator{}).Simulate(state, InputState{Pitch: math32.NaN()}) + + if result.Outcome != SimulationOutcomeInvalidInput { + t.Fatalf("outcome = %v, want invalid input", result.Outcome) + } + if result.Position != state.Pos || result.Velocity != state.Vel || result.Movement != state.Mov { + t.Fatalf("invalid input dropped authoritative state: result=%+v state=%+v", result, state) + } +} + +func TestPassiveModeDoesNotRequestCorrectionForInvalidInput(t *testing.T) { + result := (&Simulator{Options: SimulationOptions{Mode: SimulationModePassive}}).SimulateState(&MovementState{ + Vel: mgl32.Vec3{math32.NaN(), 0, 0}, + }) + if result.Outcome != SimulationOutcomeInvalidInput || result.NeedsCorrection { + t.Fatalf("passive invalid-input result = %+v", result) + } +} + +func TestMountedStateSkipsMovement(t *testing.T) { + state := newBaseState() + state.InVehicle = true + state.Pos = mgl32.Vec3{10, 70, 10} + state.Vel = mgl32.Vec3{1, 2, 3} + state.Client.Pos = mgl32.Vec3{4, 5, 6} + state.Client.Vel = mgl32.Vec3{0.1, 0.2, 0.3} + state.OnGround = true + state.CollideX = true + state.CollideY = true + state.CollideZ = true + + result := (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeMounted { + t.Fatalf("outcome = %v, want mounted", result.Outcome) + } + if state.Pos != state.Client.Pos || state.Vel != state.Client.Vel { + t.Fatalf("mounted state was simulated: pos=%v vel=%v", state.Pos, state.Vel) + } + if state.OnGround || state.CollideX || state.CollideY || state.CollideZ { + t.Fatalf("mounted reset retained contact flags: ground=%v collisions=%v/%v/%v", state.OnGround, state.CollideX, state.CollideY, state.CollideZ) + } +} + +func TestMountedStateIgnoresUnknownOriginArea(t *testing.T) { + state := newBaseState() + state.InVehicle = true + state.Pos = mgl32.Vec3{16.5, 0, 0.5} + state.Client.Pos = state.Pos + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{ClientPos: state.Client.Pos}) + + if result.Outcome != SimulationOutcomeMounted { + t.Fatalf("outcome = %v, want mounted despite unknown origin area", result.Outcome) + } +} + +func TestMountedResetClearsStaleSupport(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: semanticsNamedBlock{name: "minecraft:scaffolding"}, + }} + support := cube.Pos{0, 0, 0} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 1, 0.5} + state.Client.Pos = mgl32.Vec3{100.5, 1, 0.5} + state.SupportingBlockPos = &support + state.InVehicle = true + state.HasGravity = false + sim := &Simulator{World: w} + + sim.SimulateState(state) + if state.SupportingBlockPos != nil { + t.Fatalf("mounted reset retained stale support %v", *state.SupportingBlockPos) + } + + state.InVehicle = false + state.PressingDescend = true + sim.SimulateState(state) + if state.Vel.Y() != 0 { + t.Fatalf("stale support affected later movement: %v", state.Vel) + } +} + +func TestSimulateStateLeavesTransientInputForCaller(t *testing.T) { + state := newBaseState() + state.RiptideReady = true + (&Simulator{World: mockWorld{}}).SimulateState(state) + if !state.RiptideReady { + t.Fatal("SimulateState must not consume caller-managed transient input") + } +} + +func TestActiveRiptideRunsOrdinaryPhysics(t *testing.T) { + state := newBaseState() + state.RiptideTicks = 5 + state.Vel = mgl32.Vec3{0, 0.8, 0} + state.Gravity = NormalGravity + state.HasGravity = true + + (&Simulator{World: mockWorld{}, Equipment: fixedEquipment{EnchantmentRiptide: 2}}).SimulateState(state) + if math32.Abs(state.Vel.Y()-0.8) <= 1e-6 { + t.Fatalf("riptide tick skipped gravity: %v", state.Vel) + } + if state.Vel.Z() != 0 { + t.Fatalf("riptide tick re-applied its launch impulse: %v", state.Vel) + } +} + +func TestRiptideLaunchAppliesImpulseOnce(t *testing.T) { + sim := &Simulator{World: mockWorld{}, Equipment: fixedEquipment{EnchantmentRiptide: 2}} + state := newBaseState() + state.RiptideInRain = true + state.RiptideReady = true + state.StartingSpinAttack = true + + sim.SimulateState(state) + // The 2.25 impulse for level 2 decays through ordinary air friction the + // same tick, so the launch is observable but never the raw impulse. + launched := state.Vel.Z() + if math32.Abs(launched-2.25*DefaultAirFriction) > 1e-6 { + t.Fatalf("riptide launch velocity = %v, want %v", launched, 2.25*DefaultAirFriction) + } + + state.RiptideReady = false + state.StartingSpinAttack = false + sim.SimulateState(state) + if state.Vel.Z() > launched { + t.Fatalf("riptide gained speed after its launch tick: %v", state.Vel.Z()) + } +} + +func TestActiveRiptideConsumesRetainedWaterGrace(t *testing.T) { + state := newBaseState() + state.RiptideTicks = 5 + state.SwimWaterGraceTicks = 2 + + (&Simulator{World: mockWorld{}, Equipment: fixedEquipment{}}).SimulateState(state) + if state.SwimWaterGraceTicks != 1 { + t.Fatalf("active Riptide retained water grace = %d, want 1", state.SwimWaterGraceTicks) + } +} + +func TestMovementSpeedUsesEffectiveAttribute(t *testing.T) { + withoutEffect := newBaseState() + withoutEffect.MovementSpeed = 0.12 + withoutEffect.DefaultMovementSpeed = 0.12 + withoutEffect.Impulse = mgl32.Vec2{0, 1} + + withEffect := *withoutEffect + + base := (&Simulator{World: mockWorld{}}).SimulateState(withoutEffect) + withSpeedEffect := (&Simulator{World: mockWorld{}, Effects: fixedEffects{packet.EffectSpeed: 0}}).SimulateState(&withEffect) + if base.Velocity != withSpeedEffect.Velocity { + t.Fatalf("effective movement speed was modified by a second effect pass: base=%v with_effect=%v", base.Velocity, withSpeedEffect.Velocity) + } +} + +func TestAirSpeedIgnoresTheMovementAttribute(t *testing.T) { + state := newBaseState() + state.MovementSpeed = 0.2 + state.DefaultMovementSpeed = 0.2 + + (&Simulator{World: mockWorld{}}).Simulate(state, InputState{StartSprinting: true}) + if math32.Abs(state.MovementSpeed-0.26) > 1e-6 { + t.Fatalf("sprinting movement speed = %v, want 0.26", state.MovementSpeed) + } + if state.AirSpeed != SprintAirSpeed { + t.Fatalf("sprinting air speed = %v, want %v", state.AirSpeed, SprintAirSpeed) + } +} + +func TestTeleportDoesNotApplyJumpImpulse(t *testing.T) { + state := newBaseState() + state.OnGround = true + state.Jumping = true + support := cube.Pos{7, 8, 9} + state.SupportingBlockPos = &support + state.QueueTeleport(mgl32.Vec3{10, 20, 30}, false, 0) + + result := (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("outcome = %v, want teleport", result.Outcome) + } + if state.Vel != (mgl32.Vec3{}) { + t.Fatalf("teleport applied jump/other velocity: %v", state.Vel) + } + if state.HasTeleport() { + t.Fatal("completed hard teleport remained active") + } + if state.SupportingBlockPos != nil { + t.Fatalf("teleport retained stale support block: %v", *state.SupportingBlockPos) + } +} + +func TestQueuedTeleportEscapesUnloadedOrigin(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{16.5, 0, 0.5} + state.Client.Pos = state.Pos + state.QueueTeleport(mgl32.Vec3{0.5, 0, 0.5}, false, 0) + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{ClientPos: state.Client.Pos}) + + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("outcome = %v, want teleport from unloaded origin", result.Outcome) + } + if state.Pos != state.TeleportPos || state.HasTeleport() { + t.Fatalf("queued teleport was not completed: pos=%v target=%v pending=%v", state.Pos, state.TeleportPos, state.HasTeleport()) + } +} + +func TestQueueTeleportCanTargetOrigin(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{10, 20, 30} + state.QueueTeleport(mgl32.Vec3{}, false, 0) + + result := (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport || state.Pos != (mgl32.Vec3{}) { + t.Fatalf("origin teleport result=%+v pos=%v", result, state.Pos) + } +} + +func TestHardTeleportAtMaximumCompletionTickFinishes(t *testing.T) { + state := newBaseState() + state.QueueTeleport(mgl32.Vec3{10, 20, 30}, false, math32.MaxUint64) + sim := &Simulator{World: mockWorld{}} + + first := sim.SimulateState(state) + second := sim.SimulateState(state) + + if first.Outcome != SimulationOutcomeTeleport { + t.Fatalf("first outcome = %v, want teleport", first.Outcome) + } + if state.HasTeleport() || second.Outcome == SimulationOutcomeTeleport { + t.Fatalf("completed maximum-window teleport remained active: active=%v second=%v", state.HasTeleport(), second.Outcome) + } +} + +func TestLegacyTeleportFieldsCanBeRearmed(t *testing.T) { + state := newBaseState() + sim := &Simulator{World: mockWorld{}} + state.TeleportPos = mgl32.Vec3{1, 2, 3} + state.TicksSinceTeleport = 0 + state.TeleportCompletionTicks = 0 + + if result := sim.SimulateState(state); result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("first outcome = %v, want teleport", result.Outcome) + } + + state.TeleportPos = mgl32.Vec3{4, 5, 6} + state.TicksSinceTeleport = 0 + state.TeleportCompletionTicks = 0 + result := sim.SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport || state.Pos != state.TeleportPos { + t.Fatalf("rearmed teleport result=%+v pos=%v target=%v", result, state.Pos, state.TeleportPos) + } +} + +func TestLegacyPendingTeleportKeepsExplicitTarget(t *testing.T) { + state := newBaseState() + state.PendingTeleports = 1 + state.TeleportPos = mgl32.Vec3{10, 20, 30} + + result := (&Simulator{World: mockWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeTeleport || state.Pos != state.TeleportPos { + t.Fatalf("legacy teleport result=%+v pos=%v target=%v", result, state.Pos, state.TeleportPos) + } +} + +func TestLegacySmoothedPendingTeleportKeepsExplicitTarget(t *testing.T) { + state := newBaseState() + state.PendingTeleports = 1 + state.TeleportPos = mgl32.Vec3{8, 0, 0} + state.TeleportCompletionTicks = 2 + state.TicksSinceTeleport = 0 + state.TeleportIsSmoothed = true + target := state.TeleportPos + sim := &Simulator{World: mockWorld{}} + + for tick := range 3 { + result := sim.Simulate(state, InputState{}) + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("tick %d outcome = %v, want teleport", tick, result.Outcome) + } + if state.TeleportPos != target { + t.Fatalf("tick %d changed target to %v, want %v", tick, state.TeleportPos, target) + } + } + if state.Pos != target || state.HasTeleport() { + t.Fatalf("smoothed teleport did not complete: pos=%v target=%v pending=%v", state.Pos, target, state.HasTeleport()) + } +} + +func TestGlideAtVerticalPitchRemainsFinite(t *testing.T) { + state := newBaseState() + state.Gliding = true + state.OnGround = false + state.Rotation = mgl32.Vec3{-90, 0, 0} + state.Vel = mgl32.Vec3{1, 0, 0} + + (&Simulator{World: mockWorld{}, Inventory: mockInventory{hasElytra: true}}).SimulateState(state) + for axis, value := range state.Vel { + if !finiteFloat(value) { + t.Fatalf("glide velocity axis %d is not finite: %v", axis, state.Vel) + } + } +} + +func TestGlideNearVerticalPitchDoesNotExplode(t *testing.T) { + state := newBaseState() + state.Gliding = true + state.OnGround = false + state.Rotation = mgl32.Vec3{-89.999, 0, 0} + state.Vel = mgl32.Vec3{1, 0, 0} + + (&Simulator{World: mockWorld{}, Inventory: mockInventory{hasElytra: true}}).SimulateState(state) + for axis, value := range state.Vel { + if !finiteFloat(value) || math32.Abs(value) > 10 { + t.Fatalf("near-vertical glide velocity axis %d = %v", axis, value) + } + } +} + +func TestShallowLiquidBelowPlayerIsNotContact(t *testing.T) { + w := newLiquidWorld().set(cube.Pos{0, 0, 0}, block.Water{Depth: 0, Still: true}) + sim := newLiquidSim(w) + state := submergedState() + + if got := len(sim.touchingLiquidBlocks(state, liquidWater)); got != 0 { + t.Fatalf("shallow liquid blocks = %d, want no contact above its surface", got) + } +} + +func TestMovementChecksSweptChunks(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{15.5, 0, 0.5} + state.Vel = mgl32.Vec3{1, 0, 0} + + result := (&Simulator{World: selectiveChunkWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for swept movement", result.Outcome) + } +} + +func TestMovementPreflightsAuxiliaryWorldProbes(t *testing.T) { + w := &auxiliaryProbeWorld{} + state := newBaseState() + state.HasGravity = false + + result := (&Simulator{World: w}).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown auxiliary probes", result.Outcome) + } + if w.blockReads != 0 { + t.Fatalf("unknown auxiliary area was read %d times", w.blockReads) + } +} + +func TestMovementPreflightsTranslatedSupportFallback(t *testing.T) { + w := &supportFallbackProbeWorld{staticWorld: staticWorld{ + chunkLoaded: true, + boxes: []cube.BBox32{cube.Box32(1, -1, -1, 2, 2, 1)}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl32.Vec3{20, 0, 0} + state.OnGround = true + state.HasGravity = false + + result := (&Simulator{World: w}).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown support fallback", result.Outcome) + } + if w.unknownReads != 0 { + t.Fatalf("translated support fallback made %d unknown reads", w.unknownReads) + } +} + +func TestMovementChecksStepProbeArea(t *testing.T) { + w := stepProbeWorld{staticWorld: staticWorld{ + chunkLoaded: true, + boxes: []cube.BBox32{cube.Box32(1, 0, 0, 2, 0.5, 1)}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl32.Vec3{1, 0, 0} + state.OnGround = true + state.HasGravity = false + + result := (&Simulator{ + World: w, + Options: SimulationOptions{IgnoreClientStepTiebreaker: true}, + }).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown step probe", result.Outcome) + } + if state.Pos != (mgl32.Vec3{0.5, 0, 0.5}) { + t.Fatalf("unknown step probe moved state to %v", state.Pos) + } +} + +func TestMovementChecksSneakEdgeProbeArea(t *testing.T) { + state := newBaseState() + state.Sneaking = true + state.OnGround = true + state.HasGravity = false + state.Vel = mgl32.Vec3{0.2, 0, 0} + + result := (&Simulator{World: edgeProbeWorld{}}).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown sneak-edge probe", result.Outcome) + } +} + +func TestMovementChecksLiquidExitProbeArea(t *testing.T) { + base := newLiquidWorld().fill(cube.Pos{-1, 0, -1}, cube.Pos{0, 2, 1}, waterSource) + for y := range 3 { + base.set(cube.Pos{1, y, 0}, block.Stone{}) + } + w := liquidExitProbeWorld{liquidWorld: base} + state := submergedState() + state.HasGravity = false + state.Vel = mgl32.Vec3{1, 0, 0} + + result := newLiquidSim(w).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown liquid-exit probe", result.Outcome) + } +} + +func TestMovementChecksLiquidFlowProbeArea(t *testing.T) { + base := newLiquidWorld().set(cube.Pos{15, 0, 0}, waterSource) + w := liquidFlowProbeWorld{liquidWorld: base} + state := submergedState() + state.Pos = mgl32.Vec3{15.5, 0.5, 0.5} + state.Client.Pos = state.Pos + state.HasGravity = false + + result := newLiquidSim(w).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown liquid-flow probe", result.Outcome) + } +} + +func TestMovementChecksTargetPoseArea(t *testing.T) { + w := poseProbeWorld{staticWorld: staticWorld{ + chunkLoaded: true, + boxes: []cube.BBox32{cube.Box32(-1, 0.7, -1, 1, 1.8, 1)}, + }} + state := newBaseState() + state.CrawlingHeight = 0.6 + state.Crawling = true + state.Size[1] = state.CrawlingHeight + + result := (&Simulator{World: w}).Simulate(state, InputState{StopCrawling: true}) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown target pose", result.Outcome) + } + if !state.Crawling || state.Size[1] != state.CrawlingHeight { + t.Fatalf("unknown target pose was committed: crawling=%v size=%v", state.Crawling, state.Size) + } +} + +func TestImmobileMovementDoesNotCheckUnappliedSweep(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{15.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl32.Vec3{1, 0, 0} + state.Immobile = true + state.RiptideReady = true + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{ClientPos: state.Pos}) + + if result.Outcome != SimulationOutcomeImmobileOrNotReady { + t.Fatalf("outcome = %v, want immobile/not ready", result.Outcome) + } + if state.Vel != (mgl32.Vec3{}) { + t.Fatalf("immobile state retained stale velocity: %v", state.Vel) + } + if state.RiptideReady || state.TicksSinceKnockback != 2 { + t.Fatalf("immobile tick did not advance transient state: ready=%v knockback=%d", state.RiptideReady, state.TicksSinceKnockback) + } +} + +func TestLegacySprintTransitionUpdatesSpeedOnUnloadedTick(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{16.5, 0, 0.5} + state.Client.Pos = state.Pos + sim := &Simulator{ + World: selectiveChunkWorld{}, + Options: SimulationOptions{ + SprintTiming: SprintTimingLegacy, + }, + } + + result := sim.Simulate(state, InputState{StartSprinting: true, ClientPos: state.Pos}) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk", result.Outcome) + } + if !state.Sprinting || math32.Abs(state.MovementSpeed-0.13) > 1e-6 { + t.Fatalf("legacy sprint transition desynchronized state: sprinting=%v speed=%v", state.Sprinting, state.MovementSpeed) + } +} + +func TestQueuedKnockbackChecksSweptChunks(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{15.5, 0, 0.5} + state.Client.Pos = state.Pos + state.QueueKnockback(mgl32.Vec3{1, 0, 0}) + + result := (&Simulator{World: selectiveChunkWorld{}}).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for queued knockback", result.Outcome) + } + if state.Pos != state.Client.Pos { + t.Fatalf("queued knockback moved into unloaded area: %v", state.Pos) + } +} + +func TestInputAccelerationChecksSweptChunks(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{15.69, 0, 0.5} + state.Client.Pos = state.Pos + state.HasGravity = false + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{MoveVector: mgl32.Vec2{1, 0}}) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for same-tick acceleration", result.Outcome) + } +} + +func TestRiptideLaunchChecksSweptChunks(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 15.5} + state.Client.Pos = state.Pos + state.RiptideInRain = true + state.RiptideReady = true + + result := (&Simulator{ + World: selectiveChunkWorld{}, + Equipment: fixedEquipment{EnchantmentRiptide: 2}, + }).Simulate(state, InputState{StartSpinAttack: true}) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for Riptide launch", result.Outcome) + } + if state.RiptideTicks != 0 || !state.RiptideReady || !state.StartingSpinAttack { + t.Fatalf("unloaded Riptide consumed launch state: ticks=%d ready=%v starting=%v", state.RiptideTicks, state.RiptideReady, state.StartingSpinAttack) + } + if state.TicksSinceKnockback != 1 { + t.Fatalf("unloaded Riptide advanced tick counters: knockback=%d", state.TicksSinceKnockback) + } +} + +func TestRiptideLaunchRetriesAfterUnloadedTick(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 15.5} + state.Client.Pos = state.Pos + state.RiptideInRain = true + state.RiptideReady = true + equipment := fixedEquipment{EnchantmentRiptide: 2} + + first := (&Simulator{ + World: selectiveChunkWorld{}, + Equipment: equipment, + }).Simulate(state, InputState{ClientPos: state.Client.Pos, StartSpinAttack: true}) + if first.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("first outcome = %v, want unloaded chunk", first.Outcome) + } + + second := (&Simulator{ + World: mockWorld{}, + Equipment: equipment, + }).Simulate(state, InputState{ClientPos: state.Client.Pos}) + if second.Outcome != SimulationOutcomeNormal || state.RiptideTicks == 0 { + t.Fatalf("retried launch outcome=%v ticks=%d", second.Outcome, state.RiptideTicks) + } + if state.RiptideReady || state.StartingSpinAttack { + t.Fatalf("successful retry left launch pending: ready=%v starting=%v", state.RiptideReady, state.StartingSpinAttack) + } +} + +func TestRiptideHeadProbeRequiresLoadedArea(t *testing.T) { + base := newLiquidWorld().set(cube.Pos{0, 0, 0}, waterSource) + w := &riptideHeadProbeWorld{liquidWorld: base} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.CrawlingHeight = 0.6 + state.Crawling = true + state.Size[1] = state.CrawlingHeight + state.OnGround = true + state.HasGravity = true + state.RiptideReady = true + state.StartingSpinAttack = true + + result := (&Simulator{ + World: w, + Equipment: fixedEquipment{EnchantmentRiptide: 2}, + }).SimulateState(state) + + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk for unknown Riptide head probe", result.Outcome) + } + if w.headProbes != 1 { + t.Fatalf("Riptide head probe checks = %d, want 1", w.headProbes) + } + if state.RiptideTicks != 0 || !state.RiptideReady || !state.StartingSpinAttack { + t.Fatalf("unknown head probe consumed launch state: ticks=%d ready=%v starting=%v", state.RiptideTicks, state.RiptideReady, state.StartingSpinAttack) + } +} + +func TestIneligibleRiptideSkipsHeadProbe(t *testing.T) { + w := &riptideHeadProbeWorld{liquidWorld: newLiquidWorld()} + state := newBaseState() + state.CrawlingHeight = 0.6 + state.Crawling = true + state.Size[1] = state.CrawlingHeight + state.HasGravity = false + + result := (&Simulator{World: w}).SimulateState(state) + + if result.Outcome != SimulationOutcomeNormal { + t.Fatalf("outcome = %v, want normal movement without Riptide", result.Outcome) + } + if w.headProbes != 0 { + t.Fatalf("ineligible Riptide performed %d head probes", w.headProbes) + } +} + +func TestCompletedTeleportCounterAdvancesOnce(t *testing.T) { + state := newBaseState() + state.QueueTeleport(mgl32.Vec3{10, 20, 30}, false, 0) + + result := (&Simulator{World: mockWorld{}}).Simulate(state, InputState{}) + + if result.Outcome != SimulationOutcomeTeleport { + t.Fatalf("outcome = %v, want teleport", result.Outcome) + } + if state.TicksSinceTeleport != 1 { + t.Fatalf("completed teleport tick counter = %d, want 1", state.TicksSinceTeleport) + } +} + +func TestMovementRejectsOutOfRangeSweep(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{math32.MaxFloat32, 0, 0} + + result := (&Simulator{World: selectiveChunkWorld{}}).SimulateState(state) + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("out-of-range sweep outcome = %v, want unloaded chunk", result.Outcome) + } +} + +func TestMovementAreaProviderCannotApproveUnsafeVolume(t *testing.T) { + sim := &Simulator{World: approvingAreaWorld{}} + for name, aabb := range map[string]cube.BBox32{ + "coordinate": cube.Box32(0, 0, 0, math32.MaxFloat32, 1, 1), + "height": cube.Box32(0, 0, 0, 1, math32.MaxFloat32, 1), + } { + t.Run(name, func(t *testing.T) { + if sim.movementAreaLoaded(aabb) { + t.Fatalf("provider approved unsafe %s volume: %v", name, aabb) + } + }) + } +} + +func TestUnloadedTickDoesNotCommitPoseChanges(t *testing.T) { + state := newBaseState() + state.Pos = mgl32.Vec3{16.5, 0, 0.5} + state.Swimming = true + state.Size[1] = state.StandingHeight + originalSize := state.Size + + result := (&Simulator{World: selectiveChunkWorld{}}).Simulate(state, InputState{StopSwimming: true}) + if result.Outcome != SimulationOutcomeUnloadedChunk { + t.Fatalf("outcome = %v, want unloaded chunk", result.Outcome) + } + if !state.Swimming || state.Size != originalSize { + t.Fatalf("unloaded tick committed pose change: swimming=%v size=%v", state.Swimming, state.Size) + } +} + +func TestAdjacentClimbableIsNotContact(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {1, 0, 0}: block.Ladder{Facing: cube.West}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.8, 0, 0.5} + state.Client.Pos = state.Pos + state.EffectiveJumping = true + state.Gravity = NormalGravity + + (&Simulator{World: w}).SimulateState(state) + if math32.Abs(state.Vel.Y()-ClimbSpeed) < 1e-6 { + t.Fatalf("a ladder the player only overlaps was treated as climbable contact: %v", state.Vel) + } +} + +func TestClimbableContactResetsFallDistance(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, 0, 0}: block.Ladder{Facing: cube.West}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.Vel = mgl32.Vec3{0, -0.1, 0} + state.FallDistance = 4 + state.HasGravity = false + + (&Simulator{World: w}).SimulateState(state) + + if state.FallDistance != 0 { + t.Fatalf("climbable contact left fall distance = %v", state.FallDistance) + } +} + +func TestStandingOnClimbableBlockDoesNotEnableClimbing(t *testing.T) { + pos := cube.Pos{0, -1, 0} + w := environmentWorld{ + solids: map[cube.Pos]bool{pos: true}, + blocks: map[cube.Pos]world.Block{pos: semanticsNamedBlock{name: "minecraft:ladder"}}, + } + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.EffectiveJumping = true + + (&Simulator{World: w}).SimulateState(state) + if math32.Abs(state.Vel.Y()-ClimbSpeed) < 1e-6 { + t.Fatalf("standing on a climbable block enabled climbing: %v", state.Vel) + } +} + +func TestClimbableBlockBelowIsNotContact(t *testing.T) { + w := environmentWorld{blocks: map[cube.Pos]world.Block{ + {0, -1, 0}: block.Ladder{Facing: cube.West}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 0, 0.5} + state.Client.Pos = state.Pos + state.EffectiveJumping = true + state.Gravity = NormalGravity + + (&Simulator{World: w}).SimulateState(state) + if math32.Abs(state.Vel.Y()-ClimbSpeed) < 1e-6 { + t.Fatalf("ladder below the player was treated as climbable contact: %v", state.Vel) + } +} + +func TestPowderSnowSupportDoesNotEnableTraversal(t *testing.T) { + pos := cube.Pos{0, 0, 0} + w := &dynamicCollisionWorld{environmentWorld: environmentWorld{ + solids: map[cube.Pos]bool{pos: true}, + blocks: map[cube.Pos]world.Block{pos: semanticsNamedBlock{name: "minecraft:powder_snow"}}, + }} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 1, 0.5} + state.OnGround = true + state.PressingAscend = true + + (&Simulator{World: w, Equipment: leatherEquipment{}}).SimulateState(state) + if math32.Abs(state.Vel.Y()-0.2) < 1e-6 { + t.Fatalf("powder snow below the player enabled traversal: %v", state.Vel) + } +} + +func TestDynamicCollisionProviderKeepsStaticSupportFallback(t *testing.T) { + pos := cube.Pos{0, 0, 0} + w := &dynamicCollisionWorld{environmentWorld: environmentWorld{solids: map[cube.Pos]bool{pos: true}}} + state := newBaseState() + state.Pos = mgl32.Vec3{0.5, 1, 0.5} + state.OnGround = true + + (&Simulator{World: w}).checkSupportingBlockPos(state, false, mgl32.Vec3{}) + if state.SupportingBlockPos == nil || *state.SupportingBlockPos != pos { + t.Fatalf("dynamic collision provider lost support block: %v", state.SupportingBlockPos) + } +} + +func TestFilteredCollisionBoxesPreserveProviderOrder(t *testing.T) { + first := cube.Box32(2, 0, 0, 3, 1, 1) + second := cube.Box32(1, 0, 0, 2, 1, 1) + got := filteredCollisionBoxes([]cube.BBox32{first, cube.Box32(0, 0, 0, 0, 1, 1), second}) + if len(got) != 2 || got[0] != first || got[1] != second { + t.Fatalf("collision order changed while filtering: %v", got) + } +} + +func TestCollisionPresenceFiltersInvalidBoxes(t *testing.T) { + state := newBaseState() + sim := &Simulator{World: invalidCollisionWorld{}} + + if sim.hasNearbyBBoxes(state, state.BoundingBox(false)) { + t.Fatal("zero-volume collision box was reported as present") + } +} + +type invalidCollisionWorld struct{ mockWorld } + +func (invalidCollisionWorld) GetNearbyBBoxes(cube.BBox32) []cube.BBox32 { + return []cube.BBox32{cube.Box32(0, 0, 0, 0, 1, 1)} +} + +// HasNearbyBBoxes reports the invalid box to exercise the unfilterable fast path. +func (invalidCollisionWorld) HasNearbyBBoxes(cube.BBox32) bool { + return true +} + +type selectiveChunkWorld struct{} + +func (selectiveChunkWorld) Block(cube.Pos) world.Block { return block.Air{} } + +func (selectiveChunkWorld) BlockCollisions(cube.Pos) []cube.BBox32 { return nil } + +func (selectiveChunkWorld) GetNearbyBBoxes(cube.BBox32) []cube.BBox32 { return nil } + +func (selectiveChunkWorld) IsChunkLoaded(chunkX, chunkZ int32) bool { + return chunkX == 0 && chunkZ == 0 +} + +type auxiliaryProbeWorld struct { + blockReads int +} + +type supportFallbackProbeWorld struct { + staticWorld + unknownReads int +} + +// BlockCollisions records reads behind the approved support-probe boundary. +func (w *supportFallbackProbeWorld) BlockCollisions(pos cube.Pos) []cube.BBox32 { + if pos.X() < -2 { + w.unknownReads++ + } + return nil +} + +// IsMovementAreaLoaded rejects the translated high-velocity support fallback. +func (*supportFallbackProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Min().X() >= -2 +} + +func (w *auxiliaryProbeWorld) Block(cube.Pos) world.Block { + w.blockReads++ + return block.Air{} +} + +func (*auxiliaryProbeWorld) BlockCollisions(cube.Pos) []cube.BBox32 { return nil } + +func (*auxiliaryProbeWorld) GetNearbyBBoxes(cube.BBox32) []cube.BBox32 { return nil } + +func (*auxiliaryProbeWorld) IsChunkLoaded(int32, int32) bool { return true } + +// IsMovementAreaLoaded accepts the actor box but rejects surrounding probes. +func (*auxiliaryProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Min().X() >= -0.3 && aabb.Min().Y() >= 0 && aabb.Min().Z() >= -0.3 && + aabb.Max().X() <= 0.3 && aabb.Max().Y() <= 1.8 && aabb.Max().Z() <= 0.3 +} + +type stepProbeWorld struct { + staticWorld +} + +// IsMovementAreaLoaded rejects collision probes above the ordinary movement sweep. +func (stepProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Max().Y() <= 1.81 +} + +type edgeProbeWorld struct { + mockWorld +} + +// IsMovementAreaLoaded rejects the downward sneak-edge support probe. +func (edgeProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Min().Y() >= -0.1 +} + +type liquidExitProbeWorld struct { + *liquidWorld +} + +// IsMovementAreaLoaded rejects the raised liquid-exit probe. +func (liquidExitProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Max().Y() <= 2.5 +} + +type liquidFlowProbeWorld struct { + *liquidWorld +} + +// IsMovementAreaLoaded rejects liquid-flow reads across the chunk boundary. +func (liquidFlowProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Max().X() < 16 +} + +type poseProbeWorld struct { + staticWorld +} + +type riptideHeadProbeWorld struct { + *liquidWorld + headProbes int +} + +// IsMovementAreaLoaded rejects the block containing a crawling player's head. +func (w *riptideHeadProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + if aabb.Min().Y() == 1 && aabb.Max().Y() == 2 && aabb.Min().X() == 0 && aabb.Max().X() == 1 && aabb.Min().Z() == 0 && aabb.Max().Z() == 1 { + w.headProbes++ + return false + } + return true +} + +// IsMovementAreaLoaded accepts the current crawl pose but rejects standing. +func (poseProbeWorld) IsMovementAreaLoaded(aabb cube.BBox32) bool { + return aabb.Max().Y() <= 0.7 +} + +type approvingAreaWorld struct { + mockWorld +} + +// IsMovementAreaLoaded approves every volume so BedSim's own bounds are tested. +func (approvingAreaWorld) IsMovementAreaLoaded(cube.BBox32) bool { + return true +} diff --git a/parity_test.go b/parity_test.go index fdbf3ea..c3092dd 100644 --- a/parity_test.go +++ b/parity_test.go @@ -61,22 +61,21 @@ func TestBedrockStepHeight(t *testing.T) { } } -func TestBedBounceUsesCorroboratedRestitutionAndCap(t *testing.T) { +func TestBedBounceUsesVanillaRestitutionWithoutCap(t *testing.T) { sim := &Simulator{BlockSemantics: overrideBlockSemantics{semantics: movementblock.MovementSemantics{Bounce: movementblock.BounceBed}}} state := newBaseState() state.Vel = mgl32.Vec3{0, -2} sim.landOnBlock(state, state.Vel, block.Air{}) - // -0.66 * -2 = 1.32, above the cap. - if want := BedBounceCap; math32.Abs(state.Vel.Y()-want) > 1e-6 { - t.Fatalf("expected capped bed bounce %v, got %v", want, state.Vel.Y()) + if want := float32(1.5); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected bed bounce %v, got %v", want, state.Vel.Y()) } state.Vel = mgl32.Vec3{0, -1} sim.landOnBlock(state, state.Vel, block.Air{}) - if want := -BedBounceMultiplier; math32.Abs(state.Vel.Y()-want) > 1e-6 { - t.Fatalf("expected uncapped bed bounce %v, got %v", want, state.Vel.Y()) + if want := float32(0.75); math32.Abs(state.Vel.Y()-want) > 1e-6 { + t.Fatalf("expected bed bounce %v, got %v", want, state.Vel.Y()) } } diff --git a/result.go b/result.go index 029881a..85cdb5d 100644 --- a/result.go +++ b/result.go @@ -11,6 +11,8 @@ const ( SimulationOutcomeUnreliable SimulationOutcomeUnloadedChunk SimulationOutcomeImmobileOrNotReady + SimulationOutcomeMounted + SimulationOutcomeInvalidInput ) // SimulationResult captures the outcome of a single simulation tick. diff --git a/simulation.go b/simulation.go index cc046c4..b61036f 100644 --- a/simulation.go +++ b/simulation.go @@ -15,33 +15,103 @@ import ( // Simulate runs a movement simulation tick and returns the resulting state. func (s *Simulator) Simulate(state *MovementState, input InputState) SimulationResult { - if state == nil { - return SimulationResult{} + if state == nil || !finiteMovementState(state) { + return s.invalidSimulationResult(nil) + } + if !finiteInput(input) { + return s.invalidSimulationResult(state) + } + + pose := movementPoseSnapshot{ + size: state.Size, + sneaking: state.Sneaking, + crawling: state.Crawling, + swimming: state.Swimming, + swimAmt: state.SwimAmount, + } + inputWorldKnown := s.applyInput(state, input) + reason := SimulationOutcomeUnloadedChunk + // Teleports are authoritative and run before world-dependent simulation, so + // an unknown origin pose must not prevent one from reaching its destination. + if inputWorldKnown || state.HasTeleport() || state.InVehicle { + reason = s.simulateCore(state, true) + } else { + state.SetVel(mgl32.Vec3{}) + state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} } - - s.applyInput(state, input) - reason := s.simulateCore(state) if s.Options.SprintTiming == SprintTimingLegacy { s.applyLegacySprint(state, input) } - s.tickState(state) + if reason == SimulationOutcomeUnloadedChunk { + pose.restore(state) + } else { + state.AirSpeed = effectiveAirSpeed(state) + advanceTeleport := reason != SimulationOutcomeTeleport || state.HasTeleport() + s.tickState(state, advanceTeleport) + } return s.resultFromState(state, reason) } +// movementPoseSnapshot preserves pose fields across an unloaded simulation. +type movementPoseSnapshot struct { + size mgl32.Vec3 + sneaking bool + crawling bool + swimming bool + swimAmt float32 +} + +// restore replaces the state's pose fields with the snapshot. +func (p movementPoseSnapshot) restore(state *MovementState) { + state.Size = p.size + state.Sneaking = p.sneaking + state.Crawling = p.crawling + state.Swimming = p.swimming + state.SwimAmount = p.swimAmt +} + // SimulateState runs movement simulation using the current state values, without applying input updates -// or advancing tick counters. This is useful when the caller handles input parsing and ticking externally. +// or advancing tick counters. Callers that use it must advance tick counters +// and clear transient fields such as KnockbackPending, RiptideReady, and +// StoppedSwimmingThisTick themselves. func (s *Simulator) SimulateState(state *MovementState) SimulationResult { - if state == nil { - return SimulationResult{} + if state == nil || !finiteMovementState(state) { + return s.invalidSimulationResult(nil) } - reason := s.simulateCore(state) + reason := s.simulateCore(state, false) return s.resultFromState(state, reason) } -func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { +// invalidSimulationResult returns the mode-aware result for invalid data and +// preserves state when its numeric fields are safe to expose. +func (s *Simulator) invalidSimulationResult(state *MovementState) SimulationResult { + result := SimulationResult{ + Outcome: SimulationOutcomeInvalidInput, + NeedsCorrection: s == nil || s.Options.Mode != SimulationModePassive, + } + if state == nil || !finiteMovementState(state) { + return result + } + result.Position = state.Pos + result.Velocity = state.Vel + result.Movement = state.Mov + result.OnGround = state.OnGround + result.CollideX = state.CollideX + result.CollideY = state.CollideY + result.CollideZ = state.CollideZ + result.PositionDelta = state.Pos.Sub(state.Client.Pos) + result.VelocityDelta = state.Vel.Sub(state.Client.Vel) + return result +} + +func (s *Simulator) simulateCore(state *MovementState, consumeTransient bool) SimulationOutcome { state.ensurePoseHeights() + clearRiptideReady := consumeTransient defer func() { - state.RiptideReady = false + if clearRiptideReady { + state.RiptideReady = false + } }() teleported := s.attemptTeleport(state) if teleported { @@ -51,6 +121,14 @@ func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { state.StuckSpeedMultiplier = mgl32.Vec3{} return SimulationOutcomeTeleport } + if state.InVehicle { + s.resetToClient(state) + state.OnGround = false + state.CollideX = false + state.CollideY = false + state.CollideZ = false + return SimulationOutcomeMounted + } reliable := s.simulationIsReliable(state) if !reliable { @@ -64,7 +142,9 @@ func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { s.resetToClient(state) return SimulationOutcomeUnreliable } - if s.World != nil && !s.World.IsChunkLoaded(int32(math32.Floor(state.Pos.X()))>>4, int32(math32.Floor(state.Pos.Z()))>>4) { + currentArea := state.BoundingBox(s.Options.UseSlideOffset) + if s.World != nil && !s.movementAreaLoaded(currentArea) { + clearRiptideReady = false state.SetVel(mgl32.Vec3{}) state.SwimWaterGraceTicks = 0 state.StuckSpeedMultiplier = mgl32.Vec3{} @@ -78,8 +158,27 @@ func (s *Simulator) simulateCore(state *MovementState) SimulationOutcome { state.StuckSpeedMultiplier = mgl32.Vec3{} return SimulationOutcomeImmobileOrNotReady } + sweepVelocity := state.Vel + if state.HasKnockback() { + sweepVelocity = state.Knockback + } + if s.World != nil && !s.movementAreaLoaded(movementProbeArea(currentArea.Extend(sweepVelocity))) { + clearRiptideReady = false + state.SetVel(mgl32.Vec3{}) + state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} + return SimulationOutcomeUnloadedChunk + } - s.simulateMovement(state) + prePhysics := *state + if !s.simulateMovement(state) { + *state = prePhysics + clearRiptideReady = false + state.SetVel(mgl32.Vec3{}) + state.SwimWaterGraceTicks = 0 + state.StuckSpeedMultiplier = mgl32.Vec3{} + return SimulationOutcomeUnloadedChunk + } return SimulationOutcomeNormal } @@ -113,9 +212,17 @@ func (s *Simulator) resultFromState(state *MovementState, outcome SimulationOutc return result } -func (s *Simulator) applyInput(state *MovementState, input InputState) { +func (s *Simulator) applyInput(state *MovementState, input InputState) bool { state.ensurePoseHeights() poseCollisionsAvailable := s.poseCollisionsAvailable(state) + poseWorldKnown := poseCollisionsAvailable + canFitHeight := func(height float32) bool { + fits, known := s.canFitHeightKnown(state, height) + if !known { + poseWorldKnown = false + } + return known && fits + } state.Client.HorizontalCollision = input.HorizontalCollision state.Client.VerticalCollision = input.VerticalCollision @@ -152,23 +259,18 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { if startFlag && stopFlag { needsSpeedAdjusted = isModernSprint state.Sprinting = false - state.AirSpeed = 0.02 } else if !startFlag && !stopFlag && !state.ServerSprintApplied && state.ServerSprint != state.Sprinting { if state.ServerSprint { state.Sprinting = true - state.AirSpeed = 0.026 } else { state.Sprinting = false - state.AirSpeed = 0.02 } } else if startFlag { state.Sprinting = true needsSpeedAdjusted = isModernSprint - state.AirSpeed = 0.026 } else if stopFlag { state.Sprinting = false needsSpeedAdjusted = isModernSprint && !state.ServerUpdatedSpeed - state.AirSpeed = 0.02 } state.ServerSprintApplied = true @@ -179,6 +281,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.MovementSpeed *= 1.3 } } + state.AirSpeed = effectiveAirSpeed(state) wantSneak := input.SneakDown || input.StartSneaking if input.StopSneaking { @@ -192,7 +295,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } else if input.StopSneaking { if state.Crawling { state.Sneaking = false - } else if poseCollisionsAvailable && s.canFitHeight(state, state.StandingHeight) { + } else if poseCollisionsAvailable && canFitHeight(state.StandingHeight) { state.Sneaking = false state.Size[1] = state.StandingHeight } else { @@ -205,7 +308,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } else if input.SneakDown { state.Sneaking = true state.Size[1] = state.SneakingHeight - } else if state.Sneaking && (!poseCollisionsAvailable || !s.canFitHeight(state, state.StandingHeight)) { + } else if state.Sneaking && (!poseCollisionsAvailable || !canFitHeight(state.StandingHeight)) { state.Size[1] = state.SneakingHeight } else { state.Sneaking = false @@ -213,7 +316,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } } if input.StartCrawling { - if poseCollisionsAvailable && !s.canFitHeight(state, state.StandingHeight) { + if poseCollisionsAvailable && !canFitHeight(state.StandingHeight) { state.Crawling = true state.Sneaking = false state.Size[1] = state.CrawlingHeight @@ -223,7 +326,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { if wantSneak { targetHeight = state.SneakingHeight } - if poseCollisionsAvailable && s.canFitHeight(state, targetHeight) { + if poseCollisionsAvailable && canFitHeight(targetHeight) { state.Crawling = false state.Sneaking = wantSneak state.Size[1] = targetHeight @@ -234,12 +337,15 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } wasSwimming := state.Swimming + state.StoppedSwimmingThisTick = wasSwimming && input.StopSwimming if input.StopSwimming { state.Swimming = false - s.restorePoseAfterSwimming(state, poseCollisionsAvailable) + if !s.restorePoseAfterSwimming(state, poseCollisionsAvailable) { + poseWorldKnown = false + } } else if input.StartSwimming { state.Swimming = true - if state.SwimPose() || poseCollisionsAvailable && s.canFitHeight(state, state.StandingHeight) { + if state.SwimPose() || poseCollisionsAvailable && canFitHeight(state.StandingHeight) { setSwimmingPoseFlags(state) } } @@ -281,7 +387,10 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.Jumping = input.StartJumping state.PressingJump = input.Jumping state.EffectiveJumping = input.Jumping || input.AutoJumpingInWater || input.AscendBlock - state.JumpHeight = DefaultJumpHeight + state.JumpHeight = state.JumpStrength + if state.JumpHeight <= 0 { + state.JumpHeight = DefaultJumpHeight + } if s.Effects != nil { if amp, ok := s.Effects.GetEffect(packet.EffectJumpBoost); ok { state.JumpHeight += float32(amp+1) * 0.1 @@ -291,7 +400,9 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { if !state.PressingJump { state.JumpDelay = 0 } - state.Gravity = NormalGravity + if state.Gravity == 0 { + state.Gravity = NormalGravity + } state.SlowFalling = false if s.Effects != nil { if _, ok := s.Effects.GetEffect(packet.EffectSlowFalling); ok { @@ -305,7 +416,9 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { state.Gliding = true } - state.StartingSpinAttack = input.StartSpinAttack + // Keep a validated launch edge pending when an unloaded tick could not + // consume it. A fresh validated event or successful simulation clears it. + state.StartingSpinAttack = input.StartSpinAttack || state.RiptideReady && state.StartingSpinAttack if input.StopSpinAttack && state.RiptideTicks > 0 && state.RiptideCollision { state.RiptideTicks = 0 state.RiptideCollision = false @@ -313,6 +426,7 @@ func (s *Simulator) applyInput(state *MovementState, input InputState) { } state.Impulse = moveVector.Mul(0.98) + return poseWorldKnown } func (s *Simulator) applyLegacySprint(state *MovementState, input InputState) { @@ -334,9 +448,20 @@ func (s *Simulator) applyLegacySprint(state *MovementState, input InputState) { state.MovementSpeed *= 1.3 } } + state.AirSpeed = effectiveAirSpeed(state) } -func (s *Simulator) tickState(state *MovementState) { +// effectiveAirSpeed returns the air acceleration for the current sprint state. +// Vanilla picks from a fixed pair here, so movement effects must not reach it +// the way they reach the ground and liquid speeds. +func effectiveAirSpeed(state *MovementState) float32 { + if state.Sprinting { + return SprintAirSpeed + } + return WalkAirSpeed +} + +func (s *Simulator) tickState(state *MovementState, advanceTeleport bool) { if state.GlideBoostTicks > 0 { state.GlideBoostTicks-- } @@ -348,7 +473,8 @@ func (s *Simulator) tickState(state *MovementState) { } } state.TicksSinceKnockback++ - if state.TicksSinceTeleport < math32.MaxUint64 { + state.KnockbackPending = false + if advanceTeleport && state.TicksSinceTeleport < math32.MaxUint64 { state.TicksSinceTeleport++ } if state.JumpDelay > 0 { @@ -361,9 +487,10 @@ func (s *Simulator) tickState(state *MovementState) { } } state.JustDisabledFlight = false + state.StoppedSwimmingThisTick = false } -func (s *Simulator) simulateMovement(state *MovementState) { +func (s *Simulator) simulateMovement(state *MovementState) bool { vel := state.Vel for axis := range 3 { if math32.Abs(vel[axis]) < 1e-8 { @@ -385,12 +512,6 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SwimWaterGraceTicks = grace setSwimmingPoseFlags(state) } - if !state.Flying && s.attemptRiptide(state, inWater) { - if debugf := s.Options.Debugf; debugf != nil { - debugf("riptide launch applied: %v", state.Vel) - } - } - defer func() { if inWater { state.SwimWaterGraceTicks = grace @@ -398,6 +519,19 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SwimWaterGraceTicks-- } }() + // The launch is a one-shot impulse; the remaining Riptide ticks decay + // through ordinary travel rather than a dedicated movement mode. + if !state.Flying { + launched, known := s.attemptRiptide(state, inWater) + if !known { + return false + } + if launched { + if debugf := s.Options.Debugf; debugf != nil { + debugf("riptide launch applied: %v", state.Vel) + } + } + } // Observed lava takes precedence over retained water evidence. waterTravel := inWater || @@ -412,13 +546,21 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.Gliding = false state.GlideBoostTicks = 0 } - s.applyLiquidFlow(state, waterBlocks, liquidWater) - s.simulateLiquidTravel(state, liquidWater, inWater) + if !s.applyLiquidFlow(state, waterBlocks, liquidWater) { + return false + } + if !s.simulateLiquidTravel(state, liquidWater, inWater) { + return false + } } else { - s.applyLiquidFlow(state, lavaBlocks, liquidLava) - s.simulateLiquidTravel(state, liquidLava, true) + if !s.applyLiquidFlow(state, lavaBlocks, liquidLava) { + return false + } + if !s.simulateLiquidTravel(state, liquidLava, true) { + return false + } } - return + return true } blockUnder := s.blockAtPos(posFromVec3(state.Pos.Sub(mgl32.Vec3{0, 0.5}))) @@ -435,7 +577,6 @@ func (s *Simulator) simulateMovement(state *MovementState) { accelerationFriction := blockFriction * accelerationMultiplier moveRelativeSpeed = mSpeed * (0.16277136 / (accelerationFriction * accelerationFriction * accelerationFriction)) } - if state.Gliding && s.Effects != nil { if _, levitating := s.Effects.GetEffect(packet.EffectLevitation); levitating { state.Gliding = false @@ -447,9 +588,14 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.OnGround = false s.simulateGlide(state) stuckMovement := applyStuckSpeedMultiplier(state) + if !s.movementSweepLoaded(state) { + return false + } oldVel := state.Vel oldY := state.Pos.Y() - s.tryCollisions(state, false) + if !s.tryCollisions(state, false) { + return false + } stopRiptideOnBlockCollision(state) updateFallDistance(state, oldY) if debugf := s.Options.Debugf; debugf != nil { @@ -460,7 +606,8 @@ func (s *Simulator) simulateMovement(state *MovementState) { state.SetVel(mgl32.Vec3{}) } s.applyInsideBlockEffects(state) - return + s.applyBubbleColumns(state) + return true } state.Gliding = false @@ -484,10 +631,16 @@ func (s *Simulator) simulateMovement(state *MovementState) { s.Options.Debugf("jump force applied (sprint=%v): %v", state.Sprinting, state.Vel) } insideSemantics := s.blockMovementSemantics(s.blockAtPos(posFromVec3(state.Pos))) + if insideSemantics.Traversal == movementblock.TraversalNone && state.SupportingBlockPos != nil { + supportingSemantics := s.blockMovementSemantics(s.blockAtPos(*state.SupportingBlockPos)) + if supportingSemantics.Traversal == movementblock.TraversalScaffolding { + insideSemantics.Traversal = supportingSemantics.Traversal + } + } leatherBoots := s.Equipment != nil && s.Equipment.WearingLeatherBoots() - applyAscendableMovement(state, insideSemantics.Traversal, leatherBoots) + scaffoldDescend := applyAscendableMovement(state, insideSemantics.Traversal, leatherBoots) - nearClimbable := insideSemantics.Climbable + nearClimbable := s.climbableContact(state, insideSemantics.Climbable) if nearClimbable { newVel := state.Vel negClimbSpeed := -ClimbSpeed @@ -526,14 +679,24 @@ func (s *Simulator) simulateMovement(state *MovementState) { } stuckMovement := applyStuckSpeedMultiplier(state) - s.avoidEdge(state) + if !s.movementSweepLoaded(state) { + return false + } + if !s.avoidEdge(state) { + return false + } oldVel := state.Vel oldOnGround := state.OnGround oldY := state.Pos.Y() - s.tryCollisions(state, clientJumpPrevented) + if !s.tryCollisions(state, clientJumpPrevented) { + return false + } stopRiptideOnBlockCollision(state) updateFallDistance(state, oldY) + if scaffoldDescend || nearClimbable { + state.FallDistance = 0 + } if state.SupportingBlockPos != nil { blockUnder = s.blockAtPos(*state.SupportingBlockPos) @@ -570,22 +733,26 @@ func (s *Simulator) simulateMovement(state *MovementState) { } newVel := state.Vel - if s.Effects != nil { - if amp, ok := s.Effects.GetEffect(packet.EffectLevitation); ok { - levSpeed := LevitationGravityMultiplier * float32(amp+1) - newVel[1] += (levSpeed - newVel[1]) * 0.2 + if !scaffoldDescend { + if s.Effects != nil { + if amp, ok := s.Effects.GetEffect(packet.EffectLevitation); ok { + levSpeed := LevitationGravityMultiplier * float32(amp+1) + newVel[1] += (levSpeed - newVel[1]) * 0.2 + } else if state.HasGravity { + newVel[1] -= effectiveGravity(state, newVel) + newVel[1] *= NormalGravityMultiplier + } } else if state.HasGravity { newVel[1] -= effectiveGravity(state, newVel) newVel[1] *= NormalGravityMultiplier } - } else if state.HasGravity { - newVel[1] -= effectiveGravity(state, newVel) - newVel[1] *= NormalGravityMultiplier } newVel[0] *= blockFriction newVel[2] *= blockFriction state.SetVel(newVel) s.applyInsideBlockEffects(state) + s.applyBubbleColumns(state) + return true } func (s *Simulator) simulationIsReliable(state *MovementState) bool { @@ -613,6 +780,7 @@ func (s *Simulator) resetToClient(state *MovementState) { state.Vel = state.Client.Vel state.LastMov = state.Client.LastMov state.Mov = state.Client.Mov + state.SupportingBlockPos = nil if state.Flying || state.NoClip { state.OnGround = false } @@ -629,26 +797,64 @@ func (s *Simulator) resetToClient(state *MovementState) { } func (s *Simulator) attemptTeleport(state *MovementState) bool { + if state.PendingTeleports > 0 { + // QueueTeleport marks TeleportPending before this path, which keeps an + // explicitly queued origin distinct from legacy callers that only set + // PendingTeleports and TeleportPos. + if state.TeleportPending || state.PendingTeleportPos != (mgl32.Vec3{}) { + state.TeleportPos = state.PendingTeleportPos + } + } if !state.HasTeleport() { return false } if !state.TeleportIsSmoothed { state.SetPos(state.TeleportPos) + state.SupportingBlockPos = nil state.SetVel(mgl32.Vec3{}) state.JumpDelay = 0 - s.attemptJump(state, nil) + state.TeleportPending = false + if state.PendingTeleports > 0 { + state.PendingTeleports-- + } + if state.PendingTeleports == 0 { + state.PendingTeleportPos = mgl32.Vec3{} + } + completeTeleport(state) return true } posDelta := state.TeleportPos.Sub(state.Pos) - if remaining := state.RemainingTeleportTicks() + 1; remaining > 0 { - newPos := state.Pos.Add(posDelta.Mul(1.0 / float32(remaining))) - state.SetPos(newPos) - state.JumpDelay = 0 - return remaining > 1 + remaining := state.RemainingTeleportTicks() + if remaining < int(^uint(0)>>1) { + remaining++ } - return false + newPos := state.Pos.Add(posDelta.Mul(1.0 / float32(remaining))) + state.SetPos(newPos) + state.SupportingBlockPos = nil + state.JumpDelay = 0 + if remaining == 1 { + state.TeleportPending = false + if state.PendingTeleports > 0 { + state.PendingTeleports-- + } + if state.PendingTeleports == 0 { + state.PendingTeleportPos = mgl32.Vec3{} + } + completeTeleport(state) + } + return true +} + +// completeTeleport moves the timer beyond its active window without overflow. +func completeTeleport(state *MovementState) { + if state.TeleportCompletionTicks == math32.MaxUint64 { + state.TeleportCompletionTicks = 0 + state.TicksSinceTeleport = 1 + return + } + state.TicksSinceTeleport = state.TeleportCompletionTicks + 1 } func (s *Simulator) simulateGlide(state *MovementState) { @@ -670,19 +876,19 @@ func (s *Simulator) simulateGlide(state *MovementState) { gravity := effectiveGravity(state, vel) vel[1] += -gravity + sqrPitchCos*(gravity*0.75) - if vel[1] < 0 && lookHz > 0 { + if vel[1] < 0 && lookHz > GlideHorizontalLookEpsilon { yAccel := vel[1] * -0.1 * sqrPitchCos vel[1] += yAccel vel[0] += lookX * yAccel / lookHz vel[2] += lookZ * yAccel / lookHz } - if pitch < 0 { + if pitch < 0 && lookHz > GlideHorizontalLookEpsilon { yAccel := velHz * -pitchSin * 0.04 vel[1] += yAccel * 3.2 vel[0] -= lookX * yAccel / lookHz vel[2] -= lookZ * yAccel / lookHz } - if lookHz > 0 { + if lookHz > GlideHorizontalLookEpsilon { vel[0] += (lookX/lookHz*velHz - vel[0]) * 0.1 vel[2] += (lookZ/lookHz*velHz - vel[2]) * 0.1 } @@ -747,7 +953,7 @@ func (s *Simulator) landOnBlock(state *MovementState, old mgl32.Vec3, blockUnder newVel[1] = 0.0 } case movementblock.BounceBed: - newVel[1] = math32.Min(BedBounceCap, BedBounceMultiplier*old.Y()) + newVel[1] = BedBounceMultiplier * old.Y() default: newVel[1] = 0 } @@ -862,6 +1068,9 @@ func (s *Simulator) isJumpBlocked(state *MovementState, jumpVel mgl32.Vec3) bool } useSlideOffset := s.Options.UseSlideOffset collisionBB := state.BoundingBox(useSlideOffset) + if !s.movementAreaLoaded(collisionBB.Extend(jumpVel)) { + return false + } bbList := s.nearbyBBoxes(state, collisionBB.Extend(jumpVel)) yVel := mgl32.Vec3{0, jumpVel.Y()} @@ -907,10 +1116,10 @@ func (s *Simulator) isJumpBlocked(state *MovementState, jumpVel mgl32.Vec3) bool return yVel[1] != jumpVel[1] && xVel[0] == jumpVel[0] && zVel[2] == jumpVel[2] } -func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool) { +func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool) bool { w := s.World if w == nil { - return + return true } useSlideOffset := s.Options.UseSlideOffset correctionThreshold := s.Options.PositionCorrectionThreshold @@ -974,6 +1183,10 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool onGround := state.OnGround || (yCollision && currVel.Y() < 0.0) if onGround && (xCollision || zCollision) { + stepProbeBB := state.BoundingBox(useSlideOffset).Extend(currVel).ExtendTowards(cube.FaceUp, StepHeight) + if !s.movementAreaLoaded(stepProbeBB) { + return false + } stepYVel := mgl32.Vec3{0, StepHeight} stepXVel := mgl32.Vec3{currVel.X()} stepZVel := mgl32.Vec3{0, 0, currVel.Z()} @@ -1045,8 +1258,8 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool if s.Options.IgnoreClientStepTiebreaker || collisionPosDist > correctionThreshold || stepPosDist <= collisionPosDist { collisionVel = stepVel collisionBB = stepBB + completedStep = true if useSlideOffset { - completedStep = true slideOffset := state.SlideOffset.Mul(SlideOffsetMultiplier) slideOffset[1] += stepVel.Y() state.SlideOffset = slideOffset @@ -1093,8 +1306,12 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool state.CollideY = yCollision state.CollideZ = math32.Abs(currVel.Z()-collisionVel.Z()) >= 1e-5 - state.OnGround = (yCollision && currVel.Y() < 0) || (state.OnGround && !yCollision && math32.Abs(currVel.Y()) <= 1e-5) - checkSupportingBlockPos(state, w, useSlideOffset, currVel) + state.OnGround = (yCollision && currVel.Y() < 0) || + (onGround && !yCollision && math32.Abs(currVel.Y()) <= 1e-5) || + (clientJumpPrevented && onGround) || completedStep + if !s.checkSupportingBlockPos(state, useSlideOffset, currVel) { + return false + } state.SetVel(collisionVel) if debugf := s.Options.Debugf; debugf != nil { debugf("clientVel=%v clientPos=%v", state.Client.Mov, state.Client.Pos) @@ -1108,12 +1325,15 @@ func (s *Simulator) tryCollisions(state *MovementState, clientJumpPrevented bool if debugf := s.Options.Debugf; debugf != nil { debugf("(server) xCollision=%v yCollision=%v zCollision=%v", state.CollideX, state.CollideY, state.CollideZ) } + return true } -func (s *Simulator) avoidEdge(state *MovementState) { +// avoidEdge limits sneaking movement to supported ground and reports whether +// the complete support-probe volume is loaded. +func (s *Simulator) avoidEdge(state *MovementState) bool { w := s.World if w == nil { - return + return true } if !state.Sneaking || !state.OnGround || state.Vel.Y() > 0 { if debugf := s.Options.Debugf; debugf != nil { @@ -1124,7 +1344,7 @@ func (s *Simulator) avoidEdge(state *MovementState) { state.Vel.Y(), ) } - return + return true } edgeBoundry := float32(0.025) @@ -1138,6 +1358,10 @@ func (s *Simulator) avoidEdge(state *MovementState) { useSlideOffset := s.Options.UseSlideOffset bb := state.BoundingBox(useSlideOffset).GrowVec3(mgl32.Vec3{-edgeBoundry, 0, -edgeBoundry}) xMov, zMov := newVel.X(), newVel.Z() + probeVolume := bb.Extend(mgl32.Vec3{xMov, -StepHeight * 1.01, zMov}) + if !s.movementAreaLoaded(probeVolume) { + return false + } i := 0 for i = 0; i < maxIter && xMov != 0.0 && !s.hasNearbyBBoxes(state, bb.Translate(mgl32.Vec3{xMov, -StepHeight * 1.01, 0})); i++ { @@ -1194,6 +1418,7 @@ func (s *Simulator) avoidEdge(state *MovementState) { if debugf := s.Options.Debugf; debugf != nil { debugf("(avoidEdge): oldVel=%v newVel=%v", oldVel, newVel) } + return true } func (s *Simulator) isInsideCobweb(state *MovementState) bool { @@ -1242,21 +1467,126 @@ func nearbyBlocks(aabb cube.BBox32, w WorldProvider) iter.Seq2[cube.Pos, world.B } } -func checkSupportingBlockPos(state *MovementState, w WorldProvider, useSlideOffset bool, vel mgl32.Vec3) { +// climbableContact reports ladder/vine contact. Vanilla tests the single block +// cell the player stands in, which insideClimbable already resolves; an adapter +// overrides only when orientation lives outside the block registry. +func (s *Simulator) climbableContact(state *MovementState, insideClimbable bool) bool { + if s.World == nil { + return insideClimbable + } + if provider, ok := s.World.(ClimbableContactProvider); ok { + return provider.HasClimbableContact(state.BoundingBox(s.Options.UseSlideOffset)) + } + return insideClimbable +} + +// movementAreaLoaded reports whether the complete movement volume is known. +func (s *Simulator) movementAreaLoaded(aabb cube.BBox32) bool { + if s.World == nil { + return true + } + minX, minZ, maxX, maxZ, ok := movementChunkRange(aabb) + if !ok { + return false + } + if provider, ok := s.World.(MovementAreaProvider); ok { + return provider.IsMovementAreaLoaded(aabb) + } + for chunkX := int64(minX); chunkX <= int64(maxX); chunkX++ { + for chunkZ := int64(minZ); chunkZ <= int64(maxZ); chunkZ++ { + if !s.World.IsChunkLoaded(int32(chunkX), int32(chunkZ)) { + return false + } + } + } + return true +} + +// movementSweepLoaded reports whether the world contains the displacement +// produced after all same-tick acceleration has been applied. +func (s *Simulator) movementSweepLoaded(state *MovementState) bool { + if s.World == nil { + return true + } + sweep := state.BoundingBox(s.Options.UseSlideOffset).Extend(state.Vel) + return s.movementAreaLoaded(movementProbeArea(sweep)) +} + +// movementProbeArea returns the block-aligned volume containing normal +// movement's surrounding block, support, web, and bubble-column lookups. +func movementProbeArea(aabb cube.BBox32) cube.BBox32 { + grown := aabb.Grow(1) + min, max := grown.Min(), grown.Max() + return cube.Box32( + math32.Floor(min.X()), math32.Floor(min.Y()), math32.Floor(min.Z()), + math32.Ceil(max.X())+1, math32.Ceil(max.Y())+1, math32.Ceil(max.Z())+1, + ) +} + +const ( + maxMovementChunkSpan int64 = 256 + maxMovementBlockSpan = maxMovementChunkSpan << 4 + minMovementBlockCoord float32 = -2147483648 + maxMovementBlockCoord float32 = 2147483520 +) + +// movementChunkRange returns a bounded chunk range for a movement volume. +func movementChunkRange(aabb cube.BBox32) (minX, minZ, maxX, maxZ int32, ok bool) { + min, max := aabb.Min(), aabb.Max() + minBlockX, minBlockY, minBlockZ := math32.Floor(min.X()), math32.Floor(min.Y()), math32.Floor(min.Z()) + maxBlockX, maxBlockY, maxBlockZ := math32.Ceil(max.X())-1, math32.Ceil(max.Y())-1, math32.Ceil(max.Z())-1 + for _, value := range []float32{minBlockX, minBlockY, minBlockZ, maxBlockX, maxBlockY, maxBlockZ} { + if !finiteFloat(value) || value < minMovementBlockCoord || value > maxMovementBlockCoord { + return 0, 0, 0, 0, false + } + } + + minX, minZ = int32(minBlockX)>>4, int32(minBlockZ)>>4 + maxX, maxZ = int32(maxBlockX)>>4, int32(maxBlockZ)>>4 + spanX := int64(maxX) - int64(minX) + 1 + spanZ := int64(maxZ) - int64(minZ) + 1 + spanY := int64(int32(maxBlockY)) - int64(int32(minBlockY)) + 1 + if spanX <= 0 || spanZ <= 0 || spanY <= 0 || spanX > maxMovementChunkSpan || spanZ > maxMovementChunkSpan || spanY > maxMovementBlockSpan { + return 0, 0, 0, 0, false + } + return minX, minZ, maxX, maxZ, true +} + +// checkSupportingBlockPos refreshes the support block and reports whether both +// possible support probes are in known world data. +func (s *Simulator) checkSupportingBlockPos(state *MovementState, useSlideOffset bool, vel mgl32.Vec3) bool { if !state.OnGround { state.SupportingBlockPos = nil - return + return true } decBB := state.BoundingBox(useSlideOffset).ExtendTowards(cube.FaceDown, 1e-3) - findSupportingBlock(state, w, decBB) + if !s.movementAreaLoaded(decBB) { + state.SupportingBlockPos = nil + return false + } + s.findSupportingBlock(state, decBB) if state.SupportingBlockPos == nil { decBB = decBB.Translate(mgl32.Vec3{-vel[0], 0, -vel[2]}) - findSupportingBlock(state, w, decBB) + if !s.movementAreaLoaded(decBB) { + return false + } + s.findSupportingBlock(state, decBB) } + return true } -func findSupportingBlock(state *MovementState, w WorldProvider, bb cube.BBox32) { +func (s *Simulator) findSupportingBlock(state *MovementState, bb cube.BBox32) { + w := s.World if w == nil { + state.SupportingBlockPos = nil + return + } + if provider, ok := w.(MovementSupportProvider); ok { + if pos, found := provider.SupportingBlock(bb, s.movementCollisionContext(state)); found { + state.SupportingBlockPos = &pos + } else { + state.SupportingBlockPos = nil + } return } var blockPos *cube.Pos @@ -1270,6 +1600,9 @@ func findSupportingBlock(state *MovementState, w WorldProvider, bb cube.BBox32) } for _, box := range boxes { + if BBHasZeroVolume(box) { + continue + } if !bb.IntersectsWith(box.Translate(posVec3(pos))) { continue } @@ -1298,20 +1631,38 @@ func (s *Simulator) nearbyBBoxes(state *MovementState, aabb cube.BBox32) []cube. return nil } if provider, ok := s.World.(MovementCollisionProvider); ok { - leatherBoots := s.Equipment != nil && s.Equipment.WearingLeatherBoots() - return provider.GetMovementBBoxes(aabb, MovementCollisionContext{ - Position: [3]float32(state.Pos), - Sneaking: state.Sneaking, - Descending: state.PressingDescend, - WantDown: state.WantDown, - LeatherBoots: leatherBoots, - }) - } - return s.World.GetNearbyBBoxes(aabb) + return filteredCollisionBoxes(provider.GetMovementBBoxes(aabb, s.movementCollisionContext(state))) + } + return filteredCollisionBoxes(s.World.GetNearbyBBoxes(aabb)) } -type nearbyBBoxProbe interface { - HasNearbyBBoxes(aabb cube.BBox32) bool +// movementCollisionContext builds the dynamic collision context for state. +func (s *Simulator) movementCollisionContext(state *MovementState) MovementCollisionContext { + return MovementCollisionContext{ + Position: [3]float32(state.Pos), + Sneaking: state.Sneaking, + Descending: state.PressingDescend, + WantDown: state.WantDown, + LeatherBoots: s.Equipment != nil && s.Equipment.WearingLeatherBoots(), + } +} + +// filteredCollisionBoxes removes invalid boxes without reordering valid ones. +func filteredCollisionBoxes(boxes []cube.BBox32) []cube.BBox32 { + for i, box := range boxes { + if !BBHasZeroVolume(box) { + continue + } + filtered := make([]cube.BBox32, 0, len(boxes)-1) + filtered = append(filtered, boxes[:i]...) + for _, remaining := range boxes[i+1:] { + if !BBHasZeroVolume(remaining) { + filtered = append(filtered, remaining) + } + } + return filtered + } + return boxes } func (s *Simulator) hasNearbyBBoxes(state *MovementState, aabb cube.BBox32) bool { @@ -1321,15 +1672,18 @@ func (s *Simulator) hasNearbyBBoxes(state *MovementState, aabb cube.BBox32) bool if _, dynamic := s.World.(MovementCollisionProvider); dynamic { return len(s.nearbyBBoxes(state, aabb)) > 0 } - if probe, ok := s.World.(nearbyBBoxProbe); ok { - return probe.HasNearbyBBoxes(aabb) - } - return len(s.World.GetNearbyBBoxes(aabb)) > 0 + return len(filteredCollisionBoxes(s.World.GetNearbyBBoxes(aabb))) > 0 } func (s *Simulator) canFitHeight(state *MovementState, height float32) bool { + fits, known := s.canFitHeightKnown(state, height) + return known && fits +} + +// canFitHeightKnown reports whether the target pose is known and collision-free. +func (s *Simulator) canFitHeightKnown(state *MovementState, height float32) (fits, known bool) { if s.World == nil { - return true + return true, true } standing := *state standing.Size[1] = height @@ -1338,16 +1692,18 @@ func (s *Simulator) canFitHeight(state *MovementState, height float32) bool { standing.SwimWaterGraceTicks = 0 standing.PressingDescend = false standing.WantDown = false - return len(s.nearbyBBoxes(&standing, standing.BoundingBox(s.Options.UseSlideOffset))) == 0 + aabb := standing.BoundingBox(s.Options.UseSlideOffset) + if !s.movementAreaLoaded(aabb) { + return false, false + } + return len(s.nearbyBBoxes(&standing, aabb)) == 0, true } func (s *Simulator) poseCollisionsAvailable(state *MovementState) bool { if s.World == nil { return true } - chunkX := int32(math32.Floor(state.Pos.X())) >> 4 - chunkZ := int32(math32.Floor(state.Pos.Z())) >> 4 - return s.World.IsChunkLoaded(chunkX, chunkZ) + return s.movementAreaLoaded(state.BoundingBox(s.Options.UseSlideOffset)) } func setSwimmingPoseFlags(state *MovementState) { @@ -1356,20 +1712,28 @@ func setSwimmingPoseFlags(state *MovementState) { state.Size[1] = state.StandingHeight } -func (s *Simulator) restorePoseAfterSwimming(state *MovementState, collisionsAvailable bool) { - if collisionsAvailable && s.canFitHeight(state, state.StandingHeight) { +func (s *Simulator) restorePoseAfterSwimming(state *MovementState, collisionsAvailable bool) bool { + if !collisionsAvailable { + return false + } + if fits, known := s.canFitHeightKnown(state, state.StandingHeight); !known { + return false + } else if fits { state.Sneaking = false state.Crawling = false state.Size[1] = state.StandingHeight - return + return true } - if collisionsAvailable && s.canFitHeight(state, state.SneakingHeight) { + if fits, known := s.canFitHeightKnown(state, state.SneakingHeight); !known { + return false + } else if fits { state.Sneaking = true state.Crawling = false state.Size[1] = state.SneakingHeight - return + return true } state.Sneaking = false state.Crawling = true state.Size[1] = state.CrawlingHeight + return true } diff --git a/validation.go b/validation.go new file mode 100644 index 0000000..6a2b10a --- /dev/null +++ b/validation.go @@ -0,0 +1,72 @@ +package bedsim + +import ( + "github.com/chewxy/math32" + "github.com/go-gl/mathgl/mgl32" +) + +// finiteFloat reports whether value is neither NaN nor infinite. +func finiteFloat(value float32) bool { + return !math32.IsNaN(value) && !math32.IsInf(value, 0) +} + +// finiteVec2 reports whether every component is finite. +func finiteVec2(value mgl32.Vec2) bool { + for axis := range 2 { + if !finiteFloat(value[axis]) { + return false + } + } + return true +} + +// finiteVec3 reports whether every component is finite. +func finiteVec3(value mgl32.Vec3) bool { + for axis := range 3 { + if !finiteFloat(value[axis]) { + return false + } + } + return true +} + +// finiteInput reports whether all numeric input fields are finite. +func finiteInput(input InputState) bool { + return finiteVec2(input.MoveVector) && + finiteVec3(input.ClientPos) && + finiteVec3(input.ClientVel) && + finiteFloat(input.Pitch) && finiteFloat(input.Yaw) && finiteFloat(input.HeadYaw) +} + +// finiteMovementState reports whether all simulated numeric state is finite. +func finiteMovementState(state *MovementState) bool { + if state == nil { + return false + } + for _, value := range []mgl32.Vec3{ + state.Client.Pos, state.Client.LastPos, state.Client.Vel, state.Client.LastVel, + state.Client.Mov, state.Client.LastMov, state.Pos, state.LastPos, state.Vel, + state.LastVel, state.Mov, state.LastMov, state.Rotation, state.LastRotation, + state.Knockback, state.TeleportPos, state.PendingTeleportPos, state.Size, + state.StuckSpeedMultiplier, + } { + if !finiteVec3(value) { + return false + } + } + if !finiteVec2(state.SlideOffset) || !finiteVec2(state.Impulse) { + return false + } + for _, value := range []float32{ + state.StandingHeight, state.SneakingHeight, state.CrawlingHeight, + state.Gravity, state.JumpHeight, state.JumpStrength, state.FallDistance, + state.MovementSpeed, state.DefaultMovementSpeed, state.AirSpeed, + state.UnderwaterMovementSpeed, state.LavaMovementSpeed, state.SwimSpeedMultiplier, + state.SwimAmount, + } { + if !finiteFloat(value) { + return false + } + } + return true +} diff --git a/validation_test.go b/validation_test.go new file mode 100644 index 0000000..580c61a --- /dev/null +++ b/validation_test.go @@ -0,0 +1,64 @@ +package bedsim + +import ( + "math" + "reflect" + "testing" + + "github.com/go-gl/mathgl/mgl32" +) + +type numericStateField struct { + name string + index []int + typ reflect.Type +} + +func TestFiniteMovementStateRejectsEveryFloatField(t *testing.T) { + base := newBaseState() + fields := collectNumericStateFields(reflect.TypeOf(*base), nil, "MovementState") + if len(fields) == 0 { + t.Fatal("no numeric movement fields discovered") + } + + for _, field := range fields { + t.Run(field.name, func(t *testing.T) { + state := *base + value := reflect.ValueOf(&state).Elem().FieldByIndex(field.index) + switch field.typ { + case reflect.TypeFor[float32](): + value.SetFloat(math.NaN()) + case reflect.TypeFor[mgl32.Vec2](), reflect.TypeFor[mgl32.Vec3](): + value.Index(0).SetFloat(math.NaN()) + default: + t.Fatalf("unsupported numeric field type %v", field.typ) + } + if finiteMovementState(&state) { + t.Fatalf("finiteMovementState accepted NaN in %s", field.name) + } + }) + } +} + +// collectNumericStateFields returns every float or movement-vector field in a +// movement state, including fields nested in value structs. +func collectNumericStateFields(t reflect.Type, prefix []int, name string) []numericStateField { + floatType := reflect.TypeFor[float32]() + vec2Type := reflect.TypeFor[mgl32.Vec2]() + vec3Type := reflect.TypeFor[mgl32.Vec3]() + fields := make([]numericStateField, 0) + for i := range t.NumField() { + field := t.Field(i) + index := append(append([]int(nil), prefix...), i) + fieldName := name + "." + field.Name + switch field.Type { + case floatType, vec2Type, vec3Type: + fields = append(fields, numericStateField{name: fieldName, index: index, typ: field.Type}) + default: + if field.Type.Kind() == reflect.Struct { + fields = append(fields, collectNumericStateFields(field.Type, index, fieldName)...) + } + } + } + return fields +}