From 64c8cee434a24a08a050ef73c471075b160a3f64 Mon Sep 17 00:00:00 2001 From: Adalberto Garcia Garces Date: Mon, 15 Jun 2026 13:25:48 -0300 Subject: [PATCH] Fix LpVec3 entity velocity decoding (big-endian word + notch units) The 32-bit word was read/written little-endian, but Minecraft's LpVec3 uses FriendlyByteBuf.readUnsignedInt/writeInt (big-endian), so every entity velocity decoded to a wrong but same-length (hence silent) value. Also scale the decoded vector to 1/8000-block-per-tick units to match vec3i16, so fromNotchVelocity() consumers work uniformly across versions. Fixes broken entity velocity / knockback on 1.21.9-1.21.11 (mineflayer#3887). Co-Authored-By: Claude Opus 4.8 --- src/datatypes/lpVec3.js | 82 ++++++++++++++++++++--------------------- test/lpVec3Test.js | 41 +++++++++++++++++++++ 2 files changed, 80 insertions(+), 43 deletions(-) create mode 100644 test/lpVec3Test.js diff --git a/src/datatypes/lpVec3.js b/src/datatypes/lpVec3.js index 9e0eb140..6029669c 100644 --- a/src/datatypes/lpVec3.js +++ b/src/datatypes/lpVec3.js @@ -1,9 +1,20 @@ const [readVarInt, writeVarInt, sizeOfVarInt] = require('protodef').types.varint -const DATA_BITS_MASK = 32767 +// LpVec3 (net.minecraft.network.LpVec3) — the variable-length "low precision" vector +// used to encode entity velocity since 1.21.9. A leading 0 byte encodes the zero vector; +// otherwise it is 1 + 1 + 4 bytes that pack three 15-bit quantized components plus a 2-bit +// scale, with an optional trailing varint when the scale needs more than 2 bits. +// +// Wire format (matching FriendlyByteBuf): byte `a`, byte `b`, then a 32-bit `c` read/written +// BIG-endian via readUnsignedInt/writeInt. The 48-bit value is `c << 16 | b << 8 | a`. +// +// The decoded vector is exposed in the same 1/8000-block-per-tick units as vec3i16 (used by +// pre-1.21.9 versions), so consumers such as mineflayer's fromNotchVelocity() handle every +// version uniformly. The raw codec yields blocks per tick, hence the * NOTCH_UNITS_PER_BLOCK. const MAX_QUANTIZED_VALUE = 32766.0 const ABS_MIN_VALUE = 3.051944088384301e-5 const ABS_MAX_VALUE = 1.7179869183e10 +const NOTCH_UNITS_PER_BLOCK = 8000 function sanitize (value) { if (isNaN(value)) return 0.0 @@ -14,11 +25,11 @@ function pack (value) { return Math.round((value * 0.5 + 0.5) * MAX_QUANTIZED_VALUE) } +// extract the 15-bit quantized component at the given bit offset and map it back to [-1, 1]. +// Uses division + modulo because the packed value can exceed 2^32 (JS bitwise ops are 32-bit). function unpack (packed, shift) { - // We use division by power of 2 to simulate a 64-bit right shift - const val = Math.floor(packed / Math.pow(2, shift)) & DATA_BITS_MASK - const clamped = val > 32766 ? 32766 : val - return (clamped * 2.0) / 32766.0 - 1.0 + const q = Math.min(Math.floor(packed / Math.pow(2, shift)) % 0x8000, MAX_QUANTIZED_VALUE) + return (q * 2.0) / MAX_QUANTIZED_VALUE - 1.0 } function readLpVec3 (buffer, offset) { @@ -28,14 +39,13 @@ function readLpVec3 (buffer, offset) { } const b = buffer[offset + 1] - const c = buffer.readUInt32LE(offset + 2) + const c = buffer.readUInt32BE(offset + 2) // big-endian (FriendlyByteBuf.readUnsignedInt) - // Combine into 48-bit safe integer (up to 2^53 is safe in JS) + // combine into a 48-bit value; below 2^48 so it is exact as a JS double const packed = (c * 65536) + (b << 8) + a let scale = a & 3 let size = 6 - if ((a & 4) === 4) { const { value: varIntVal, size: varIntSize } = readVarInt(buffer, offset + 6) scale = (varIntVal * 4) + scale @@ -44,61 +54,47 @@ function readLpVec3 (buffer, offset) { return { value: { - x: unpack(packed, 3) * scale, - y: unpack(packed, 18) * scale, - z: unpack(packed, 33) * scale + x: unpack(packed, 3) * scale * NOTCH_UNITS_PER_BLOCK, + y: unpack(packed, 18) * scale * NOTCH_UNITS_PER_BLOCK, + z: unpack(packed, 33) * scale * NOTCH_UNITS_PER_BLOCK }, size } } function writeLpVec3 (value, buffer, offset) { - const x = sanitize(value.x) - const y = sanitize(value.y) - const z = sanitize(value.z) - - const max = Math.max(Math.abs(x), Math.abs(y), Math.abs(z)) + const x = sanitize(value.x / NOTCH_UNITS_PER_BLOCK) + const y = sanitize(value.y / NOTCH_UNITS_PER_BLOCK) + const z = sanitize(value.z / NOTCH_UNITS_PER_BLOCK) - if (max < ABS_MIN_VALUE) { - buffer[offset] = 0 + const chessboard = Math.max(Math.abs(x), Math.abs(y), Math.abs(z)) + if (chessboard < ABS_MIN_VALUE) { + buffer.writeUInt8(0, offset) return offset + 1 } - const scale = Math.ceil(max) - const needsContinuation = (scale & 3) !== scale - const scaleByte = needsContinuation ? ((scale & 3) | 4) : (scale & 3) - - const pX = pack(x / scale) - const pY = pack(y / scale) - const pZ = pack(z / scale) - - // Layout: - // [Z (15)] [Y (15)] [X (15)] [Flags (3)] + const scale = Math.ceil(chessboard) + const needsContinuation = scale > 3 + const markers = needsContinuation ? ((scale % 4) | 4) : scale - // low32 contains Flags(3), X(15), and the first 14 bits of Y (3+15+14 = 32) - const low32 = (scaleByte | (pX << 3) | (pY << 18)) >>> 0 + // packed = markers | (xq << 3) | (yq << 18) | (zq << 33) + const packed = markers + pack(x / scale) * 0x8 + pack(y / scale) * 0x40000 + pack(z / scale) * 0x200000000 - // high16 contains the 15th bit of Y and all 15 bits of Z - const high16 = ((pY >> 14) & 0x01) | (pZ << 1) - - buffer.writeUInt32LE(low32, offset) - buffer.writeUInt16LE(high16, offset + 4) + buffer.writeUInt8(packed % 0x100, offset) + buffer.writeUInt8(Math.floor(packed / 0x100) % 0x100, offset + 1) + buffer.writeUInt32BE(Math.floor(packed / 0x10000) % 0x100000000, offset + 2) // big-endian if (needsContinuation) { return writeVarInt(Math.floor(scale / 4), buffer, offset + 6) } - return offset + 6 } function sizeOfLpVec3 (value) { - const max = Math.max(Math.abs(value.x), Math.abs(value.y), Math.abs(value.z)) - if (max < ABS_MIN_VALUE) return 1 - - const scale = Math.ceil(max) - if ((scale & 3) !== scale) { - return 6 + sizeOfVarInt(Math.floor(scale / 4)) - } + const chessboard = Math.max(Math.abs(value.x), Math.abs(value.y), Math.abs(value.z)) / NOTCH_UNITS_PER_BLOCK + if (chessboard < ABS_MIN_VALUE) return 1 + const scale = Math.ceil(chessboard) + if (scale > 3) return 6 + sizeOfVarInt(Math.floor(scale / 4)) return 6 } diff --git a/test/lpVec3Test.js b/test/lpVec3Test.js new file mode 100644 index 00000000..b9db9ff9 --- /dev/null +++ b/test/lpVec3Test.js @@ -0,0 +1,41 @@ +/* eslint-env mocha */ +const assert = require('assert') +const [readLpVec3, writeLpVec3, sizeOfLpVec3] = require('../src/datatypes/lpVec3') + +// Velocity payloads captured from a real vanilla 1.21.11 server: the bytes of a +// ClientboundSetEntityMotionPacket after the entity-id varint (i.e. the LpVec3 itself). +const REAL_PAYLOADS = ['f9ff7ffeebed', '59e7800cebed', '51e880011541', '09e98000d8fd'] + +describe('lpVec3', () => { + it('decodes the zero vector as a single byte', () => { + const r = readLpVec3(Buffer.from('00', 'hex'), 0) + assert.deepStrictEqual(r.value, { x: 0, y: 0, z: 0 }) + assert.strictEqual(r.size, 1) + }) + + it('encodes a near-zero vector as a single byte', () => { + const buf = Buffer.alloc(8) + const end = writeLpVec3({ x: 0, y: 0, z: 0 }, buf, 0) + assert.strictEqual(end, 1) + assert.strictEqual(buf[0], 0) + assert.strictEqual(sizeOfLpVec3({ x: 0, y: 0, z: 0 }), 1) + }) + + it('decodes real 1.21.11 server velocity bytes to sane values', () => { + // big-endian 32-bit word; value exposed in 1/8000-block-per-tick units + const r = readLpVec3(Buffer.from('f9ff7ffeebed', 'hex'), 0) + assert.strictEqual(r.size, 6) + assert.ok(Math.abs(r.value.y / 8000 - (-0.0784)) < 0.001, 'unexpected y: ' + r.value.y) + assert.ok(Math.abs(r.value.x) < 8000 && Math.abs(r.value.z) < 8000, 'velocity out of range') + }) + + it('round-trips real server velocity bytes exactly', () => { + for (const hex of REAL_PAYLOADS) { + const value = readLpVec3(Buffer.from(hex, 'hex'), 0).value + const buf = Buffer.alloc(16) + const end = writeLpVec3(value, buf, 0) + assert.strictEqual(buf.subarray(0, end).toString('hex'), hex, 'round-trip mismatch for ' + hex) + assert.strictEqual(sizeOfLpVec3(value), end, 'sizeOf mismatch for ' + hex) + } + }) +})