From 774f4b24c02444338fecfb341e95f416ded12427 Mon Sep 17 00:00:00 2001 From: AI Bengineering Date: Fri, 4 Sep 2026 15:28:18 +1000 Subject: [PATCH] Expand the step-up probe by the requested movement The box that decides how far a step may rise was expanded along the movement collision had already clamped, rather than the movement that was asked for. That box exists to reach into the cell being entered, so that a ceiling over the destination limits the rise. Built from the clamped movement it collapses to the player's own box whenever the player is flush against the obstacle, because the clamped movement is then exactly zero. The rise is limited by whatever stands over the player instead, so with open space overhead it takes the whole 0.6 step height and strikes the ceiling over the destination. In a two-high passage a player touching a carpet can never step on to it, and never will, since standing still does not change any of the inputs. Vanilla expands by the requested movement. Checked against the 1.21.4 server jar, disassembled with javap and read against Mojang's official server mappings for that version (piston-data server.txt, sha1 0b1e60cc509cfb0172573ae56b436c29febbc187), which map net.minecraft.world.entity.Entity to bum, Entity.collide(Vec3) to bum.a(fbb), AABB.expandTowards(double,double,double) to faw.b(DDD) and Vec3.x/y/z to fbb.d/e/f. In bum.a(fbb) the parameter in local 1 is the requested movement and local 4 holds the result of collideBoundingBox; the two are compared per axis at offsets 45 to 91 to decide whether a step is worth trying. The step probe is then built at offsets 179 to 197 as aABB2.expandTowards(vec3.x, maxUpStep(), vec3.z) reading local 1, the requested movement, at offsets 181 and 190. The collided vector in local 4 is used only for those comparisons and for the aabb.move(0, vec32.y, 0) at offset 170. `queryBB` a few lines above this hunk already expands by `oldVel` for the same reason, so the two were inconsistent with each other as well as with vanilla. Observed against a 1.21.4 server: a bot flush against a moss carpet under a two-high ceiling could not move at all, for as long as it kept trying. Placing a block above its own head made the same move succeed, which is what isolated the probe box, since a block over your head cannot help you walk forward. The change can only lower the permitted rise, never raise it, so a candidate it now rejects was one stepping into a space the probe reports as occupied. --- index.js | 2 +- test/step.test.js | 111 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 test/step.test.js diff --git a/index.js b/index.js index d95ce7f..581058f 100644 --- a/index.js +++ b/index.js @@ -237,7 +237,7 @@ function Physics (mcData, world) { const BB1 = oldBB.clone() const BB2 = oldBB.clone() - const BB_XZ = BB1.clone().extend(dx, 0, dz) + const BB_XZ = BB1.clone().extend(oldVelX, 0, oldVelZ) let dy1 = dy let dy2 = dy diff --git a/test/step.test.js b/test/step.test.js new file mode 100644 index 0000000..094639b --- /dev/null +++ b/test/step.test.js @@ -0,0 +1,111 @@ +/* eslint-env mocha */ + +const { Physics, PlayerState } = require('prismarine-physics') +const { Vec3 } = require('vec3') +const expect = require('expect') + +// A player's 0.6 step height clears a one-sixteenth-tall block such as a +// carpet. https://www.mcpk.wiki/wiki/Stepping +// +// Under a two-high ceiling that step was refused when the player was already +// flush against the block, and allowed when it was not, which is what these +// cases pin down. + +const version = '1.21' +const mcData = require('minecraft-data')(version) +const Block = require('prismarine-block')(version) + +const STONE = mcData.blocksByName.stone.defaultState +const CARPET = mcData.blocksByName.moss_carpet.defaultState +const AIR = mcData.blocksByName.air.defaultState + +/** + * Floor everywhere at y=60. West of the player, at x=-1, a carpet on that floor + * under a ceiling at y=63, which leaves the destination two cells high. The + * player starts flush against the carpet's east face with nothing overhead, + * unless `ceilingOverPlayer` closes its own cell in the same way. + */ +function corridor (ceilingOverPlayer) { + return { + getBlock: (pos) => { + let stateId = AIR + if (pos.y === 60) stateId = STONE + else if (pos.y === 61 && pos.x === -1) stateId = CARPET + else if (pos.y === 63 && pos.x === -1) stateId = STONE + else if (pos.y === 63 && pos.x === 0 && ceilingOverPlayer) stateId = STONE + const block = Block.fromStateId(stateId, 0) + block.position = pos + return block + } + } +} + +/** Walk west, into the carpet, for `ticks` ticks. Yaw of PI/2 is -x. */ +function walkWest (world, ticks) { + const player = { + entity: { + // x=0.3 puts the player's west face on 0.0, flush with the carpet. + position: new Vec3(0.3, 61, 0.5), + velocity: new Vec3(0, 0, 0), + onGround: true, + isInWater: false, + isInLava: false, + isInWeb: false, + isCollidedHorizontally: false, + isCollidedVertically: false, + elytraFlying: false, + yaw: Math.PI / 2, + pitch: 0, + effects: {} + }, + jumpTicks: 0, + jumpQueued: false, + fireworkRocketDuration: 0, + version, + inventory: { slots: [] } + } + const controls = { + forward: true, + back: false, + left: false, + right: false, + jump: false, + sprint: false, + sneak: false + } + const physics = Physics(mcData, world) + const state = new PlayerState(player, controls) + for (let tick = 0; tick < ticks; tick++) { + physics.simulatePlayer(state, world).apply(player) + } + return player.entity.position +} + +describe('Stepping onto a thin block under a low ceiling', () => { + it('the fixture really is a one-sixteenth block', () => { + const carpet = Block.fromStateId(CARPET, 0) + const height = Math.max(...carpet.shapes.map(shape => shape[4])) + + expect(height).toBeLessThan(0.1) + expect(height).toBeGreaterThan(0) + }) + + it('steps on to the carpet from a standstill flush against it', () => { + const position = walkWest(corridor(false), 10) + + // Crossed into the carpet's cell, standing on top of it rather than in it. + expect(position.x).toBeLessThan(0) + expect(position.y).toBeGreaterThan(61) + }) + + it('does not depend on what stands over the player', () => { + // The same move, differing only in a block above the player's own head. + // That block cannot help the player walk forward, so both must travel. + const open = walkWest(corridor(false), 10) + const covered = walkWest(corridor(true), 10) + + expect(open.x).toBeLessThan(0) + expect(covered.x).toBeLessThan(0) + expect(Math.abs(open.x - covered.x)).toBeLessThan(0.05) + }) +})