Receiving
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.
Receivers run once per frame, before on_frame, for every packet captured since the last frame, in their own call with a 100 ms limit (over it the VM is stopped with script timeout). Up to 4096 packets are queued per frame. A receiver that raises an error is removed and the console prints packet receiver '<identity>' removed: <error>. The packets sent by xiao.packet.send are delivered too, as outgoing.
At a glance
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.
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
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.
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
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
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.
Position and decoding
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
nilwhen 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.
Check _error
A partial table is still returned when decoding fails. Test f._error == nil before trusting a field.
Shape of the table
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:
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)