Hooks New

Modify or cancel the packets the game sends and receives, as they pass, within a strict time budget.

A hook sees one packet id while it passes, in both directions, and decides what the game sends or receives: leave it, cancel it, or replace its payload. A walkthrough is in the hook guide; for the player input, xiao.cmd is the dedicated hook.

At a glance

Function Purpose
hook(packet, identity, callback) install a hook
unhook(packet, identity) remove it
hook_stats() counters and timings
Return value Effect
nil packet unchanged, next hook runs
false packet cancelled (marked blocked for receivers and the packet log), later hooks do not run
string payload replaced; later hooks see the new payload
table fields encoded like send_fields, then like a string

Constraints

Packets pass on the game network thread, the script runs on the render thread. A hook runs synchronously on the network thread, inside the same VM, so the game waits for it.

Other rules:

  • A hook that raises an error, returns another type, or returns a table that does not encode, is removed and the console prints packet hook '<identity>' removed: <error> (hook returned number (nil, false, string or table expected)). Its verdict is ignored and the next hooks still run.
  • An incoming replacement cannot be longer than the buffer the game received the packet in (its capacity, usually the original size rounded up); a longer one passes unchanged and counts in the network stats. Outgoing replacements have no size limit.
  • Hooks see packets the game sends and receives, after the native aim, and not the packets sent by xiao.packet.send. Packets blocked by a block list are dropped before hooks.
  • A hook that changes the angles of a PlayerAuthInput or MovePlayer packet the native aim already aimed cancels that aim delivery: the aim waits for a packet that carries its own angles.
  • To read or change the player input of PlayerAuthInputPacket (angles, movement, buttons), use xiao.cmd authinput: the packet is decoded for you and sent untouched when nothing changed. A hook on PlayerAuthInput sees the packet after xiao.cmd.
  • A new hook takes effect at the next frame. Clear VM and unload stop all hooks: no hook runs after the VM is closed.
  • Payloads over 64 KiB pass unchanged and do not reach hooks.
  • The packet object of a hook always has blocked = false and complete = true.

Functions

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

xiao.packet.hook(packet: number | string, identity: any, callback: function(msg: packet): nil | false | string | table)

Installs a hook on one packet id.

Name Type Description
packet number or string one id or name; "*" is refused
identity any key of the hook; the same key replaces it
callback function receives a packet object with the current payload

Returns: nothing. The callback's return value is the verdict, see the table above. Errors: bad argument #1 to 'hook' (one packet id or name expected) for "*", bad argument #2 to 'hook' (identity expected), bad argument #3 to 'hook' (function expected), too many packet hooks (64), and the unknown packet error.

xiao.packet.hook("Text", "mute", function(msg)
    local f = msg:fields()
    if f ~= nil and f.source == "spammer" then return false end
end)

xiao.packet.unhook(packet, identity)

xiao.packet.unhook(packet: number | string, identity: any): boolean
Name Type Description
packet number or string id or name given to hook
identity any key given to hook

Returns: true if the hook existed.

xiao.packet.unhook("Text", "mute")

xiao.packet.hook_stats()

xiao.packet.hook_stats(): table

Counters since the VM started, as numbers.

Field Description
calls hooked packets seen, busy ones included
busy passed unchanged because the VM was busy
overruns passed unchanged because the budget ran out
errors hook errors and invalid returns
dropped packets cancelled
replaced payloads replaced
removed hooks removed (errors, invalid returns and overruns)
total_us, max_us time spent on the network thread per hooked packet, waiting included
avg_us total_us / calls
budget_us, wait_us 2000 and 1000

Returns: a new table with these 12 fields.

local s = xiao.packet.hook_stats()
print(s.calls, s.busy, s.overruns, s.avg_us)

Which return to use

Goal Return
watch only nil
drop the packet false
change a few values of a small packet the edited msg:fields() table
change a big packet (inventory, skins) bytes: edit msg.payload, return the string

A hook that returns a table pays the encoder, which decodes the packet once more for each presence flag, count or length that is not zero: about 30 us for MovePlayer, but 60 to 85 ms for a 4.7 KB InventoryContent with 50 items, far over the budget. For such packets, change the msg.payload bytes and return a string.

Measured in lua_runtime_test: 0.7 us per packet for a hook returning nil or a string, 27 us for a MovePlayer hook returning a table, 1 ms when the VM is busy.

Good practice

  • Do the minimum: test the packet, return early with nil.
  • Never build tables, strings or draw calls you do not need. Keep state in upvalues and use the render thread (think, xiao.timer.after) for heavy work.
  • Check msg:fields() is not nil and has no _error before editing.
  • Watch hook_stats(): a growing busy or any overruns means the hook or the frame is too slow.
  • Remove hooks you do not need with unhook, and from unload if they must not outlive the script.

Complete example

Raise the Y of every movement packet by half a block, drop long chat lines without decoding them, cancel every Animate packet, report the counters every 5 seconds and clean up on unload:

xiao.packet.hook("MovePlayer", "nudge", function(msg)
    local f = msg:fields()
    if f == nil or f._error ~= nil then return nil end
    f.position.y = f.position.y + 0.5
    return f
end)

xiao.packet.hook("Text", "size_gate", function(msg)
    if msg.size > 64 then return false end
end)

xiao.packet.hook("Animate", "no_swing", function(msg)
    return false
end)

local report = xiao.timer.every(5, function()
    local s = xiao.packet.hook_stats()
    print("hooks", s.calls, "busy", s.busy, "overruns", s.overruns, "avg us", s.avg_us)
end)

xiao.signal.add("unload", "hooks_cleanup", function()
    xiao.timer.cancel(report)
    xiao.packet.unhook("MovePlayer", "nudge")
    xiao.packet.unhook("Text", "size_gate")
    xiao.packet.unhook("Animate", "no_swing")
end)