basalt_types/position.rs
1use crate::{Decode, Encode, EncodedSize, Result};
2
3/// A packed block position encoded as a single 64-bit integer.
4///
5/// The Minecraft protocol packs three spatial coordinates into one `i64`
6/// to minimize bandwidth for the most common spatial reference in the game.
7/// Used in packets like block changes, player digging, block placement,
8/// sign updates, and many more.
9///
10/// Bit layout (MSB to LSB):
11/// - x: 26 bits (signed, range -33554432 to 33554431)
12/// - z: 26 bits (signed, range -33554432 to 33554431)
13/// - y: 12 bits (signed, range -2048 to 2047)
14///
15/// Encoding: `(x & 0x3FFFFFF) << 38 | (z & 0x3FFFFFF) << 12 | (y & 0xFFF)`
16#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
17pub struct Position {
18 pub x: i32,
19 pub y: i32,
20 pub z: i32,
21}
22
23impl Position {
24 /// Creates a new position from block coordinates.
25 pub fn new(x: i32, y: i32, z: i32) -> Self {
26 Self { x, y, z }
27 }
28
29 /// Packs the position into a single 64-bit integer.
30 ///
31 /// The x and z coordinates are masked to 26 bits, y to 12 bits.
32 /// Bits beyond the valid range are silently truncated.
33 fn pack(&self) -> i64 {
34 ((self.x as i64 & 0x3FFFFFF) << 38)
35 | ((self.z as i64 & 0x3FFFFFF) << 12)
36 | (self.y as i64 & 0xFFF)
37 }
38
39 /// Unpacks a 64-bit integer into x, y, z coordinates.
40 ///
41 /// Performs sign extension for each field: x and z from 26 bits,
42 /// y from 12 bits. This correctly handles negative coordinates.
43 fn unpack(val: i64) -> Self {
44 let mut x = (val >> 38) as i32;
45 let mut z = ((val >> 12) & 0x3FFFFFF) as i32;
46 let mut y = (val & 0xFFF) as i32;
47
48 // Sign-extend from 26 bits for x and z
49 if x >= 1 << 25 {
50 x -= 1 << 26;
51 }
52 if z >= 1 << 25 {
53 z -= 1 << 26;
54 }
55 // Sign-extend from 12 bits for y
56 if y >= 1 << 11 {
57 y -= 1 << 12;
58 }
59
60 Self { x, y, z }
61 }
62}
63
64/// Encodes a Position as a packed 64-bit big-endian integer.
65///
66/// The three coordinates are packed into a single `i64` using the bit
67/// layout described on the type. The packed value is then written as
68/// 8 big-endian bytes, matching the Minecraft protocol wire format.
69impl Encode for Position {
70 /// Packs x, y, z into a 64-bit integer and writes it as 8 big-endian bytes.
71 fn encode(&self, buf: &mut Vec<u8>) -> Result<()> {
72 self.pack().encode(buf)
73 }
74}
75
76/// Decodes a Position from a packed 64-bit big-endian integer.
77///
78/// Reads 8 bytes as a big-endian `i64`, then unpacks x (26 bits),
79/// z (26 bits), and y (12 bits) with proper sign extension for
80/// negative coordinates.
81impl Decode for Position {
82 /// Reads 8 big-endian bytes, unpacks into x, y, z with sign extension.
83 ///
84 /// Fails with `Error::BufferUnderflow` if fewer than 8 bytes remain.
85 fn decode(buf: &mut &[u8]) -> Result<Self> {
86 let val = i64::decode(buf)?;
87 Ok(Self::unpack(val))
88 }
89}
90
91/// A Position always occupies exactly 8 bytes on the wire (one packed i64).
92impl EncodedSize for Position {
93 fn encoded_size(&self) -> usize {
94 8
95 }
96}
97
98/// A block position using full 32-bit coordinates.
99///
100/// BlockPosition represents a block's location in the world without the bit
101/// packing constraints of [`Position`]. It is used internally for world
102/// logic and converts to/from `Position` for protocol serialization.
103///
104/// Unlike `Position`, BlockPosition is not directly serialized on the wire —
105/// it converts through `Position` for encoding. This type exists to
106/// provide a more ergonomic API for working with block coordinates
107/// without worrying about bit packing limitations.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
109pub struct BlockPosition {
110 pub x: i32,
111 pub y: i32,
112 pub z: i32,
113}
114
115impl BlockPosition {
116 /// Creates a new block position.
117 pub fn new(x: i32, y: i32, z: i32) -> Self {
118 Self { x, y, z }
119 }
120
121 /// Returns the chunk position that contains this block.
122 ///
123 /// Chunk coordinates are derived by dividing block coordinates by 16
124 /// (arithmetic right shift by 4). This matches the Minecraft convention
125 /// where each chunk is a 16x16 column of blocks.
126 pub fn chunk_pos(&self) -> ChunkPosition {
127 ChunkPosition {
128 x: self.x >> 4,
129 z: self.z >> 4,
130 }
131 }
132}
133
134/// Converts a [`Position`] (packed wire format) into a [`BlockPosition`] (full i32 coordinates).
135impl From<Position> for BlockPosition {
136 fn from(pos: Position) -> Self {
137 Self {
138 x: pos.x,
139 y: pos.y,
140 z: pos.z,
141 }
142 }
143}
144
145/// Converts a [`BlockPosition`] into a [`Position`] for wire serialization.
146///
147/// Coordinates outside the Position range (x/z: 26-bit signed, y: 12-bit
148/// signed) will be silently truncated during packing.
149impl From<BlockPosition> for Position {
150 fn from(pos: BlockPosition) -> Self {
151 Position {
152 x: pos.x,
153 y: pos.y,
154 z: pos.z,
155 }
156 }
157}
158
159/// A chunk position in the world, identified by chunk-level x and z coordinates.
160///
161/// Each chunk is a 16x16 column of blocks. ChunkPosition represents the chunk's
162/// location in the world grid. It is derived from block coordinates by
163/// dividing by 16. Used for chunk loading, unloading, and spatial indexing.
164///
165/// ChunkPosition is not directly serialized on the wire — chunk packets use
166/// their own field encoding. This type exists for spatial logic.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
168pub struct ChunkPosition {
169 pub x: i32,
170 pub z: i32,
171}
172
173impl ChunkPosition {
174 /// Creates a new chunk position.
175 pub fn new(x: i32, z: i32) -> Self {
176 Self { x, z }
177 }
178}
179
180/// Converts a [`BlockPosition`] to the [`ChunkPosition`] that contains it.
181impl From<BlockPosition> for ChunkPosition {
182 fn from(pos: BlockPosition) -> Self {
183 pos.chunk_pos()
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190 use crate::Error;
191
192 fn roundtrip(x: i32, y: i32, z: i32) {
193 let pos = Position::new(x, y, z);
194 let mut buf = Vec::with_capacity(pos.encoded_size());
195 pos.encode(&mut buf).unwrap();
196 assert_eq!(buf.len(), 8);
197
198 let mut cursor = buf.as_slice();
199 let decoded = Position::decode(&mut cursor).unwrap();
200 assert!(cursor.is_empty());
201 assert_eq!(decoded, pos);
202 }
203
204 // -- Position encode/decode --
205
206 #[test]
207 fn origin() {
208 roundtrip(0, 0, 0);
209 }
210
211 #[test]
212 fn positive_coords() {
213 roundtrip(100, 64, 200);
214 }
215
216 #[test]
217 fn negative_coords() {
218 roundtrip(-100, -32, -200);
219 }
220
221 #[test]
222 fn max_values() {
223 // x/z max: 2^25 - 1 = 33554431, y max: 2^11 - 1 = 2047
224 roundtrip(33554431, 2047, 33554431);
225 }
226
227 #[test]
228 fn min_values() {
229 // x/z min: -2^25 = -33554432, y min: -2^11 = -2048
230 roundtrip(-33554432, -2048, -33554432);
231 }
232
233 #[test]
234 fn typical_overworld() {
235 // Typical overworld spawn area
236 roundtrip(256, 72, -128);
237 }
238
239 #[test]
240 fn position_underflow() {
241 let mut cursor: &[u8] = &[0x01; 7];
242 assert!(matches!(
243 Position::decode(&mut cursor),
244 Err(Error::BufferUnderflow { .. })
245 ));
246 }
247
248 #[test]
249 fn encoded_size_is_8() {
250 assert_eq!(Position::new(0, 0, 0).encoded_size(), 8);
251 assert_eq!(Position::new(100, 200, 300).encoded_size(), 8);
252 }
253
254 // -- Pack/unpack --
255
256 #[test]
257 fn pack_known_value() {
258 // From wiki.vg: position (18357644, 831, -20882616)
259 let pos = Position::new(18357644, 831, -20882616);
260 let packed = pos.pack();
261 let unpacked = Position::unpack(packed);
262 assert_eq!(unpacked, pos);
263 }
264
265 // -- BlockPosition --
266
267 #[test]
268 fn blockpos_to_position() {
269 let bp = BlockPosition::new(100, 64, -200);
270 let pos: Position = bp.into();
271 assert_eq!(pos, Position::new(100, 64, -200));
272 }
273
274 #[test]
275 fn position_to_blockpos() {
276 let pos = Position::new(-50, 128, 300);
277 let bp: BlockPosition = pos.into();
278 assert_eq!(bp, BlockPosition::new(-50, 128, 300));
279 }
280
281 // -- ChunkPosition --
282
283 #[test]
284 fn blockpos_to_chunkpos() {
285 let bp = BlockPosition::new(100, 64, -200);
286 let cp = bp.chunk_pos();
287 assert_eq!(cp, ChunkPosition::new(6, -13));
288 }
289
290 #[test]
291 fn blockpos_to_chunkpos_negative() {
292 // Block (-1, 0, -1) should be in chunk (-1, -1)
293 let bp = BlockPosition::new(-1, 0, -1);
294 let cp = bp.chunk_pos();
295 assert_eq!(cp, ChunkPosition::new(-1, -1));
296 }
297
298 #[test]
299 fn blockpos_to_chunkpos_origin() {
300 let bp = BlockPosition::new(0, 0, 0);
301 let cp = bp.chunk_pos();
302 assert_eq!(cp, ChunkPosition::new(0, 0));
303 }
304
305 #[test]
306 fn chunkpos_from_blockpos() {
307 let bp = BlockPosition::new(32, 0, 48);
308 let cp: ChunkPosition = bp.into();
309 assert_eq!(cp, ChunkPosition::new(2, 3));
310 }
311
312 // -- proptest --
313
314 mod proptests {
315 use super::*;
316 use proptest::prelude::*;
317
318 proptest! {
319 #[test]
320 fn position_roundtrip(
321 x in -33554432i32..=33554431,
322 y in -2048i32..=2047,
323 z in -33554432i32..=33554431,
324 ) {
325 roundtrip(x, y, z);
326 }
327 }
328 }
329}