Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
82 changes: 39 additions & 43 deletions src/datatypes/lpVec3.js
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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) {
Expand All @@ -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
Expand All @@ -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
}

Expand Down
41 changes: 41 additions & 0 deletions test/lpVec3Test.js
Original file line number Diff line number Diff line change
@@ -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)
}
})
})
Loading