diff --git a/src/datatypes/lpVec3.js b/src/datatypes/lpVec3.js index 9e0eb140..d10eefda 100644 --- a/src/datatypes/lpVec3.js +++ b/src/datatypes/lpVec3.js @@ -1,6 +1,17 @@ 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 in blocks per tick — the codec's natural units. Older versions encode +// velocity as vec3i16 in 1/8000-block-per-tick "notch" units; normalizing between the two forms +// is a version-specific consumer concern (mineflayer gates it with a supportFeature), not +// something this wire codec should bake in. const MAX_QUANTIZED_VALUE = 32766.0 const ABS_MIN_VALUE = 3.051944088384301e-5 const ABS_MAX_VALUE = 1.7179869183e10 @@ -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 @@ -57,48 +67,34 @@ function writeLpVec3 (value, buffer, offset) { const y = sanitize(value.y) const z = sanitize(value.z) - const max = Math.max(Math.abs(x), Math.abs(y), Math.abs(z)) - - 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)) + 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..5388689f --- /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 in blocks per tick (the codec's natural units) + const r = readLpVec3(Buffer.from('f9ff7ffeebed', 'hex'), 0) + assert.strictEqual(r.size, 6) + assert.ok(Math.abs(r.value.y - (-0.0784)) < 0.001, 'unexpected y: ' + r.value.y) + assert.ok(Math.abs(r.value.x) < 1 && Math.abs(r.value.z) < 1, '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) + } + }) +})