Hooks New
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
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.
Time constraint
- The hook waits at most 1 ms for the VM. When the render thread is still running the script (
on_frame, receivers, paint), the packet passes unchanged and counts inhook_stats().busy. - All hooks of a packet share a budget of 2 ms counted from the packet arrival (wait included), checked every 1000 Lua instructions (hook callbacks and the functions defined inside them are never JIT compiled). Over budget, the hook is stopped, the packet passes unchanged (even if an earlier hook replaced it) and counts in
overruns; after 3 overruns in a row the hook is removed and the console printspacket hook '<identity>' removed: over the 2000 us budget 3 times in a row. - Long C calls (a big
string.rep,xiao.fs, the encoder of a returned table) are not interrupted, but a hook that ends past the budget still counts as an overrun and its verdict is ignored: keep hooks short. - On
PlayerAuthInput, the xiao.cmdauthinputcallbacks run first and the same 2 ms covers both, see Threads and time budget.
Restricted inside a hook
A hook runs with no frame. See Execution contexts for the whole list.
xiao.paintdraws nothing; fonts and images cannot be baked, unbaked or measured.xiao.gamefinds no frame: lists are empty and single reads returnnil.xiao.config,xiao.workspaceandxiao.notifyraise an error, for examplexiao.config is not available inside a packet hook or a cmd hook.xiao.timer.spawnraisesxiao.timer.spawn is not available inside a packet hook or a cmd hook.xiao.timer.after,everyandcancelwork: the callback runs later, on the render thread.print,xiao.packet.*(send included),xiao.json,xiao.signal,xiao.fs(slow, see above) and pure Lua work.
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
PlayerAuthInputorMovePlayerpacket 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.cmdauthinput: the packet is decoded for you and sent untouched when nothing changed. A hook onPlayerAuthInputsees the packet afterxiao.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 = falseandcomplete = 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.
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
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.
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
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 notniland has no_errorbefore editing. - Watch
hook_stats(): a growingbusyor anyoverrunsmeans the hook or the frame is too slow. - Remove hooks you do not need with
unhook, and fromunloadif 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)