Modify packets New

Change or cancel packets as they pass, and stay inside the time budget.

A hook decides what the game sends or receives. This guide covers the patterns and the rules that keep the game smooth. The full reference is Hooks.

The rule to remember

Hooks run on the game network thread and the game waits for them. You get 1 ms to obtain the VM and 2 ms in total per packet. A slow hook is stopped, the packet passes unchanged, and after 3 overruns in a row the hook is removed. A hook that raises an error, or returns something other than nil, false, a string or a table, is removed at once.

Return Effect
nil the packet passes unchanged, the next hook of the same packet runs
false the packet is cancelled, later hooks do not run
a string the new payload, later hooks see it
a table encoded like msg:fields(), then used as the new payload

At most 64 hooks exist at once. A blocked packet (xiao.packet.block) never reaches a hook.

1. Cancel a packet

Return false:

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

Cancel only some packets of an id by testing first:

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

source exists only for the chat, whisper and announcement text types. xiao.packet.unhook("Text", "mute") removes the hook.

2. Change values with fields

Return the edited msg:fields() table and the client encodes it:

xiao.packet.hook("MovePlayer", "nudge", function(msg)
    if not msg.outgoing then return nil end
    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)

This is cheap for small packets (about 30 us for MovePlayer). Enum values use the names of the game schema ("swing", "teleport"); the old names still encode, see Renamed fields. A table that does not encode removes the hook with the encoding error.

3. Change bytes for big packets

A table pays the encoder. For a large packet, such as an inventory of 4.7 KB, that is 60 to 85 ms, far over the budget. Work on the bytes instead and return a string:

xiao.packet.hook("InventoryContent", "bytes", function(msg)
    local p = msg.payload
    return string.char(0) .. p:sub(2)
end)

The edit above only illustrates the shape: it overwrites the first byte. Use your own byte edit. Incoming replacements cannot be longer than the buffer the game received, and payloads over 64 KiB are not hooked.

4. Watch your budget

xiao.signal.add("think", "hook_report", function()
    local s = xiao.packet.hook_stats()
    if s.overruns > 0 or s.removed > 0 then
        print("hooks in trouble", s.overruns, s.removed, ("%.0f us max"):format(s.max_us))
        xiao.signal.remove("think", "hook_report")
    end
end)
Counter Read it as
calls packets that reached a hook
busy the VM was busy (the render thread was running the script) for 1 ms, the packet passed unchanged
overruns your hook was too slow
errors your hook raised or returned a bad value
dropped, replaced packets cancelled, payloads replaced
removed hooks that were taken out
avg_us, max_us, total_us time spent in hooks

The counters restart at zero on Clear VM. All of them are listed with xiao.packet.hook_stats() in Hooks.

What not to do inside a hook

What works: print, the xiao.packet functions (a send from a hook goes to the send queue), xiao.json, xiao.signal, xiao.timer.after, every and cancel (the callback runs later on the render thread), and pure Lua. The full table per context is in Execution contexts.

If you need game state in a hook, store it from think into an upvalue and read the upvalue:

local my_health = 20

xiao.signal.add("think", "cache", function()
    local me = xiao.game.player()
    if me and me.health then my_health = me.health end
end)

xiao.packet.hook("Animate", "low_hp_no_swing", function(msg)
    if my_health < 6 then return false end
end)

To show something on screen from a hook, do the reverse: set an upvalue in the hook and draw it from a paint listener.

Movement and input: use xiao.cmd

To change the view angles, the movement or the input buttons of the local player, a packet hook on PlayerAuthInput is the hard way. xiao.cmd gives a cmd object once per tick with getters and setters for all of them, under the same 2 ms rules, and on two layers: createmove (what the client simulates) and authinput (what the server receives). A packet hook on PlayerAuthInput sees the payload after the authinput callbacks changed it.

xiao.cmd.hook("createmove", "auto_sprint", function(cmd)
    if cmd:get_forward_move() > 0.5 and not cmd:has_button("sneak_down") then
        cmd:set_button("sprint_down")
    end
end)

See the cmd overview and the examples.

Good practice

  • return nil as early as possible, test msg.outgoing first when only one direction matters
  • one hook per packet and per job, with a stable identity
  • check fields() is not nil and has no _error before touching it
  • hooks do not see packets sent by xiao.packet.send
  • Clear VM stops every hook

Related pages: Hooks, Hook limits, Read packets, Send packets, xiao.cmd.