Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
9338a65
fix: close movement parity edge cases
HashimTheArab Aug 7, 2026
95a65dd
fix: close movement parity gaps
HashimTheArab Aug 7, 2026
e7b85a6
fix: align movement edge cases
HashimTheArab Aug 7, 2026
8316c1a
fix: close remaining movement parity gaps
HashimTheArab Aug 7, 2026
5542460
fix: address movement parity review findings
HashimTheArab Aug 7, 2026
ff9b26f
fix: correct Riptide, air speed, and climbable parity
HashimTheArab Aug 8, 2026
6056b9c
docs: correct the AirSpeed field contract
HashimTheArab Aug 8, 2026
3bb6d61
Merge main into agent/fix-movement-parity
HashimTheArab Aug 20, 2026
28f7e79
fix: align remaining movement parity behavior
HashimTheArab Aug 20, 2026
733982b
Merge origin/main into agent/fix-movement-parity
HashimTheArab Aug 20, 2026
40f011e
fix: close review-found movement state gaps
HashimTheArab Aug 20, 2026
1c7022e
fix: enforce loaded movement and safe invalid results
HashimTheArab Aug 20, 2026
11a3896
fix: roll back unloaded physics attempts
HashimTheArab Aug 20, 2026
5f1d6ca
fix: preserve unloaded transition semantics
HashimTheArab Aug 20, 2026
ce2d11e
fix: close movement boundary states
HashimTheArab Aug 20, 2026
d63c891
fix: preserve legacy teleport rearming
HashimTheArab Aug 20, 2026
35e222a
fix: preserve bounce API and pose-aware Riptide
HashimTheArab Aug 20, 2026
5545f24
fix: guard auxiliary movement probes
HashimTheArab Aug 20, 2026
5915426
fix: bound provider movement volumes
HashimTheArab Aug 20, 2026
c126892
fix: guard remaining world-dependent probes
HashimTheArab Aug 20, 2026
523bfe2
guard teleport and riptide world boundaries
HashimTheArab Aug 20, 2026
a3f90d3
preflight movement probes and retry riptide
HashimTheArab Aug 20, 2026
4439c96
preserve legacy smoothed teleport targets
HashimTheArab Aug 20, 2026
5dbb55c
close mounted state boundary transitions
HashimTheArab Aug 20, 2026
99aa873
guard support fallback and mounted contacts
HashimTheArab Aug 20, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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)
4 changes: 4 additions & 0 deletions block/environment.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
1 change: 1 addition & 0 deletions block/semantics.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
}
Expand Down
21 changes: 20 additions & 1 deletion block_effects.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -64,6 +68,7 @@ func applyAscendableMovement(state *MovementState, traversal movementblock.Trave
}
}
state.SetVel(velocity)
return false
}

func (s *Simulator) applyInsideBlockEffects(state *MovementState) {
Expand Down Expand Up @@ -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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
71 changes: 71 additions & 0 deletions block_effects_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"},
Expand Down
13 changes: 13 additions & 0 deletions block_semantics_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down
6 changes: 6 additions & 0 deletions block_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand All @@ -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}

Expand Down
Loading