Receiving

Watch packets after the fact, read them byte by byte or decode them into a table of named fields.

Watch the packets the game sends and receives, and decode them. Receivers see packets after the fact: to change or cancel a packet use hooks.

At a glance

Function Purpose
receive(packet, identity, callback) register a receiver
remove(packet, identity) remove it
reader(bytes, packet?) packet object over any string
fields(packet, bytes) New decode a payload into a table
decode(packet, bytes) decode into the lines of the packet log
id(name), name(id) name and id lookup
dropped() packets lost to a full queue
msg:read_*() read the payload field by field
msg:fields() New decode the payload into a table

Receivers

xiao.packet.receive(packet, identity, callback)

xiao.packet.receive(packet: number | string, identity: any, callback: function(msg: packet))

Registers a receiver for one packet, or for all of them.

Name Type Description
packet number or string id, name, or "*" for every packet
identity any key of the receiver; the same key replaces it
callback function called with a packet object

Returns: nothing. Errors: bad argument #2 to 'receive' (identity expected), bad argument #3 to 'receive' (function expected), and the unknown packet error.

xiao.packet.receive("Text", "chat_log", function(msg)
    local needs_translation = msg:read_bool()
    local body = msg:read_byte()
    local message_type = msg:read_byte()
    if body == 1 then
        local source = msg:read_string()
        print(msg.name, msg.outgoing and "sent" or "received", source, msg:read_string())
    end
end)

xiao.packet.receive("*", "counter", function(msg)
    count = (count or 0) + 1
end)

xiao.packet.remove(packet, identity)

xiao.packet.remove(packet: number | string, identity: any): boolean
Name Type Description
packet number or string id, name or "*", as given to receive
identity any key given to receive

Returns: true if the receiver existed.

xiao.packet.remove("*", "counter")

xiao.packet.dropped()

xiao.packet.dropped(): number

Returns: the number of packets lost because the queue of 4096 packets was full, since the VM started.


Lookup and decoding

xiao.packet.reader(bytes, packet?)

xiao.packet.reader(bytes: string, packet?: number | string): packet

Packet object over any string, to parse saved payloads. It has outgoing = false, blocked = false, complete = true; without packet it has no id and no name, so decode() and fields() return nil.

Name Type Description
bytes string payload
packet number or string optional id or name
local r = xiao.packet.reader(saved_payload, "MovePlayer")
print(r:read_varuint64(), r:remaining())

xiao.packet.id(name)

xiao.packet.id(name: string | number): number | nil

Returns: the id, or nil for an unknown name. A number is checked against 0 to 1023 and returned.

xiao.packet.name(id)

xiao.packet.name(id: number | string): string | nil

Returns: the full name ("MovePlayerPacket"), or nil for an unused id. Errors: an id outside 0 to 1023 or an unknown name raises an error.

print(xiao.packet.id("MovePlayer"), xiao.packet.name(19))

xiao.packet.decode(packet, bytes)

xiao.packet.decode(packet: number | string, bytes: string): string[] | nil

Returns: the lines of the built-in decoder (same as the packet log), or nil.

xiao.packet.fields(packet, bytes) New

xiao.packet.fields(packet: number | string, bytes: string): table | nil

Same as msg:fields() on a payload string, without building a packet object. See msg:fields().

Name Type Description
packet number or string packet id or name
bytes string payload

Returns: the fields table, or nil when the decoder does not know the packet.

local f = xiao.packet.fields("MovePlayer", saved_payload)
if f ~= nil and f._error == nil then print(f.position.x, f.mode) end

Packet object

Callbacks get a packet object, in both directions (sent by the game and received from the server). The same object is used by hooks.

Fields of the packet

Field Type Description
id number packet id (absent on a reader without packet)
name string packet name (absent when the id has none)
outgoing boolean true for client to server
blocked boolean true if the client dropped it (block list or a hook)
size number real payload size
complete boolean false if payload was cut (capture limit, or over 64 KiB)
payload string payload bytes as a string
pos number read position, reset to 0 before each callback

Reading

Reading past the end of the packet raises read past end of packet (<n> bytes left, <m> wanted); a varint longer than its type raises malformed varint.

Method Returns
read_bool() boolean
read_byte() number
read_short(), read_ushort() number
read_int(), read_uint() number
read_int64(), read_uint64() number
read_float(), read_double() number
read_varint(), read_varuint() number, zigzag and unsigned varints
read_varint64(), read_varuint64() number, 64-bit varints
read_string() string, varint length then bytes
read_bytes(n?) string, n bytes; without n the rest of the payload
read_vec2() vector2, 2 floats (8 bytes)
read_vec3() vector3, 3 floats (12 bytes), usable directly as a paint point
read_blockpos() vector3 with integer x, y, z: zigzag varint x, unsigned varint y (wrapped to a signed 32-bit value), zigzag varint z

Position and decoding

Method Description
tell() returns the read position
seek(pos) sets the read position (0 to size), returns the packet
remaining() returns the number of bytes left to read
decode() decodes the packet with the built-in decoder, returns string[] \| nil
fields() New decodes the packet into a Lua table, see below
xiao.packet.receive("MovePlayer", "peek", function(msg)
    local first = msg:read_varuint64()
    msg:seek(0)
    print(msg:tell(), msg:remaining(), first)
end)

fields() New

msg:fields(): table | nil

Decodes the packet with the built-in decoder and returns its fields as a Lua table, named like the packet log lines (names come from the schemas of the game).

  • Does not move the read position.
  • Returns nil when the packet has no id or the decoder does not know it.
  • When the payload is cut or invalid, returns the fields read before the problem plus _error (string, for example "truncated data at byte 9"). A complete decode has no _error.
  • Bytes left after the decoded fields (prefix-only decoders) are in _rest (byte string), next to _error.

Shape of the table

Decoder value Lua value
integer, float number (integers past 2^53 become a decimal string)
boolean boolean
text string
enum name string ("normal"), number when the value has no name
vec2, vec3, block position table with x, y (and z)
list (attribute[0], input_flag[1]) array starting at 1
sub-structure (item.id, stack_request.action[0]) nested table
UUID, NBT string as shown in the packet log; NBT also as bytes in <name>_raw (nbt_raw, meta[1].value_raw)
raw data (item extra, skin image) byte string

Counts stay as fields (attributes = 1 next to the attribute list, property_ints next to property_int). Metadata entries are a list meta of { key, type, name, value }, properties are lists property_int and property_float of { key, value }.

Every byte of a decoded packet has a name, including the ones that only describe the layout:

Wire value Field
presence byte of an optional field has_<field> (has_teleport, has_fire_position, item.has_net_id)
length of a byte string <string>_length (item.extra_length, skin.image.data_length)
element count of a list plural of the list (links, property_ints, parameters)
variant tag of TextPacket body (message_only, author_and_message, message_and_params)

Presence bytes and lengths are fields only: the packet log lines stay as before.

Enum names are the exact names of the game schema; 12 of them changed on 2026-11-05, see Renamed fields.

Example

For a MovePlayer packet, msg:fields() returns:

{
    runtime_id = 1,
    position = { x = -70.5, y = 64.62, z = 8.4 },
    pitch = 0,
    yaw = 90,
    head_yaw = 90,
    mode = "normal",
    on_ground = true,
    ridden_runtime_id = 0,
    has_teleport = false,
    tick = 1234,
}

Floats keep their 32-bit value: position.y is 64.62000274658203 (print shows 64.620002746582).

xiao.packet.receive("MovePlayer", "where", function(msg)
    local f = msg:fields()
    if f ~= nil and f.mode == "teleport" then
        print("teleported to", f.position.x, f.position.y, f.position.z)
    end
end)

The table can be edited and given back to send_fields or returned from a hook.


Complete example

Log incoming chat lines with their decoded body, and count packets by name:

local counts = {}

xiao.packet.receive("*", "count", function(msg)
    local key = msg.name or msg.id
    counts[key] = (counts[key] or 0) + 1
end)

xiao.packet.receive("Text", "chat", function(msg)
    if msg.outgoing then return end
    local f = msg:fields()
    if f ~= nil and f._error == nil then
        print("text", f.body, f.source, f.message)
    end
end)

xiao.signal.add("unload", "report", function()
    for name, n in pairs(counts) do print(name, n) end
    print("lost", xiao.packet.dropped())
end)