xiao.paint
xiao.paint draws shapes, text and images over the game.
Everything you draw lasts one frame: register a paint listener and draw again on every call. Shapes drawn by the other callbacks of a frame (signals, timers, on_frame) are shown too, but shapes drawn by the main chunk are discarded before the next frame starts.
Create your colors and points once, outside the listener, and reuse them. Change their fields when they must move. See
Drawing is refused inside a packet hook or a cmd hook
While a packet hook or a cmd hook callback runs, xiao.paint is inert, without raising an error: shapes and text are silently skipped, text.unbake and image.bake return false, text.bake returns false unless the font is already baked, text.get_size returns zero sizes and screen returns 0, 0. Draw from a paint listener instead and pass data between the two with upvalues, or defer with xiao.timer.after. See Execution contexts.
At a glance
Listeners
A paint listener is a callback run once per frame with the screen size, at the last step of the order of a frame. Listeners run in the order they were first added. A listener that raises an error is removed and the error is printed in the console (paint listener 'hud' removed: ...). Each frame has a 100 ms time budget.
xiao.paint.listener.add(id, callback)
xiao.paint.listener.add(id: any, callback: function(w: number, h: number))
Registers callback, called every frame with the screen size. Adding an existing id replaces its callback and keeps its place in the order.
Returns: nothing.
Errors: bad argument #1 to 'add' (identifier expected), bad argument #2 to 'add' (function expected).
local origin = xiao.paint.point.vector2(10, 10)
local white = xiao.paint.color.rgb(255, 255, 255)
xiao.paint.listener.add("hud", function(w, h)
xiao.paint.text.left(origin, "hello", white)
end)
xiao.paint.listener.remove(id): boolean
xiao.paint.listener.remove(id: any): boolean
Returns: true if the listener existed and was removed.
Errors: none.
xiao.paint.listener.exists(id): boolean
xiao.paint.listener.exists(id: any): boolean
Returns: true if a listener is registered under id.
Errors: none.
xiao.paint.add(id, callback)
xiao.paint.add(id: any, callback: function(w: number, h: number))
Alias of xiao.paint.listener.add.
xiao.paint.remove(id): boolean
xiao.paint.remove(id: any): boolean
Alias of xiao.paint.listener.remove.
xiao.paint.screen(): number, number
xiao.paint.screen(): number, number
Returns: the screen size w, h in pixels during a frame, 0, 0 outside a frame (main chunk, hooks).
xiao.paint.add("size", function()
local w, h = xiao.paint.screen()
xiao.paint.text.left(xiao.paint.point.vector2(10, h - 30), w .. "x" .. h, xiao.paint.color.rgb(255, 255, 255))
end)
Points
Any table with numeric x and y fields is accepted wherever a point is expected.
- A point with only
xandyis a screen point, in pixels. - A point that also has a numeric
zis a world point, in blocks. It is projected to the screen every time it is drawn, and the shape is skipped when a point is behind the camera. - Sizes (
sizearguments) always usexandyin pixels, even when the origin is a world point.
A value that is not a table raises bad argument #n to '...' (paint_point expected, got ...); a table without numeric x and y raises paint_point needs numeric x and y. See xiao.game.world_to_screen if you need the projected coordinates yourself.
xiao.paint.point.vector2(x, y): paint_point
xiao.paint.point.vector2(x: number, y: number): paint_point
Returns: a screen point {x, y}, printed as vector2(x, y) by tostring.
Errors: bad argument #n to 'vector2' (finite number expected).
xiao.paint.point.vector3(x, y, z): paint_point
xiao.paint.point.vector3(x: number, y: number, z: number): paint_point
Returns: a world point {x, y, z}, printed as vector3(x, y, z) by tostring.
Errors: bad argument #n to 'vector3' (finite number expected).
local marker = xiao.paint.point.vector3(100, 64, -20)
local red = xiao.paint.color.rgb(255, 60, 60)
xiao.paint.add("marker", function()
xiao.paint.circle.filled(marker, red, 4)
end)
Colors
Any table with numeric r, g, b fields (0 to 255) and an optional a (default 255) is accepted wherever a color is expected. Fields can be changed at runtime, for example color.r = 128. Channels are clamped to 0 to 255 and rounded. A number 0xRRGGBBAA is accepted too.
A value that is neither raises bad argument #n to '...' (paint_color expected, got ...); a table without numeric r, g, b raises paint_color needs numeric r, g and b.
xiao.paint.color.rgb(r, g, b): paint_color
xiao.paint.color.rgb(r: number, g: number, b: number): paint_color
Returns: an opaque color, alpha 255.
xiao.paint.color.rgba(r, g, b, a): paint_color
xiao.paint.color.rgba(r: number, g: number, b: number, a?: number = 255): paint_color
Returns: a color with alpha.
xiao.paint.color.hex(hex): paint_color
xiao.paint.color.hex(hex: string): paint_color
Returns: the color, opaque for 6 digits.
Errors: bad argument #1 to 'hex' (#RRGGBB or #RRGGBBAA expected), bad argument #1 to 'hex' (invalid hexadecimal digit).
xiao.paint.color.hsv(h, s, v, a): paint_color
xiao.paint.color.hsv(h: number, s: number, v: number, a?: number = 255): paint_color
Returns: the color.
xiao.paint.color.hsl(h, s, l, a): paint_color
xiao.paint.color.hsl(h: number, s: number, l: number, a?: number = 255): paint_color
Same as hsv, with l (lightness, 0 to 1) in place of v.
Returns: the color.
local solid = xiao.paint.color.hex("#3b82f6")
local glass = xiao.paint.color.hex("3b82f680")
local rainbow = xiao.paint.color.hsv((os.clock() * 60) % 360, 1, 1)
local pastel = xiao.paint.color.hsl(200, 0.6, 0.8, 200)
Shapes
Every shape takes points and colors as described above and returns nothing. Most shapes end with optional arguments:
Numbers must be finite (bad argument #n to '...' (finite number expected)); a negative radius, rounding, thickness or outline width counts as 0.
xiao.paint.line(p1, p2, color, ...)
xiao.paint.line(p1: paint_point, p2: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Draws a line between two points.
xiao.paint.circle.filled(center, color, radius, ...)
xiao.paint.circle.filled(center: paint_point, color: paint_color, radius: number, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Draws a filled circle.
xiao.paint.circle.hollow(center, color, radius, ...)
xiao.paint.circle.hollow(center: paint_point, color: paint_color, radius: number, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Draws a circle ring. Same parameters as circle.filled, plus thickness.
xiao.paint.tri.filled(p1, p2, p3, color, ...)
xiao.paint.tri.filled(p1: paint_point, p2: paint_point, p3: paint_point, color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Draws a filled triangle.
xiao.paint.tri.hollow(p1, p2, p3, color, ...)
xiao.paint.tri.hollow(p1: paint_point, p2: paint_point, p3: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Draws a triangle outline. Same parameters as tri.filled, plus thickness.
xiao.paint.rect.filled(top_left, size, color, ...)
xiao.paint.rect.filled(top_left: paint_point, size: paint_point, color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Draws a filled rectangle.
xiao.paint.rect.hollow(top_left, size, color, ...)
xiao.paint.rect.hollow(top_left: paint_point, size: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Draws a rectangle outline. Same parameters as rect.filled, plus thickness.
xiao.paint.rect.rfilled(rounding, top_left, size, color, ...)
xiao.paint.rect.rfilled(rounding: number, top_left: paint_point, size: paint_point, color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Filled rectangle with rounded corners. rounding is the corner radius in pixels; the other parameters are those of rect.filled.
xiao.paint.rect.rhollow(rounding, top_left, size, color, ...)
xiao.paint.rect.rhollow(rounding: number, top_left: paint_point, size: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Rounded rectangle outline. Same parameters as rect.rfilled, plus thickness.
xiao.paint.rect.gradient(top_left, size, ...)
xiao.paint.rect.gradient(top_left: paint_point, size: paint_point, up_left: paint_color, up_right: paint_color, bottom_left: paint_color, bottom_right: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Rectangle with one color per corner, blended across the surface.
xiao.paint.qrot.filled(center, size, color, rotation, ...)
xiao.paint.qrot.filled(center: paint_point, size: paint_point, color: paint_color, rotation: number, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Filled rectangle drawn around its center and rotated.
xiao.paint.qrot.hollow(center, size, color, rotation, ...)
xiao.paint.qrot.hollow(center: paint_point, size: paint_point, color: paint_color, rotation: number, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Rotated rectangle outline. Same parameters as qrot.filled, plus thickness.
xiao.paint.quad.filled(p1, p2, p3, p4, color, ...)
xiao.paint.quad.filled(p1: paint_point, p2: paint_point, p3: paint_point, p4: paint_point, color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Draws a filled quadrilateral.
xiao.paint.quad.hollow(p1, p2, p3, p4, color, ...)
xiao.paint.quad.hollow(p1: paint_point, p2: paint_point, p3: paint_point, p4: paint_point, color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Draws a quadrilateral outline. Same parameters as quad.filled, plus thickness.
xiao.paint.poly.filled(vertices, color, ...)
xiao.paint.poly.filled(vertices: paint_point[], color: paint_color, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Filled polygon. Concave shapes are allowed.
Errors: bad argument #1 to 'filled' (between 3 and 4096 vertices expected).
xiao.paint.poly.hollow(vertices, color, ...)
xiao.paint.poly.hollow(vertices: paint_point[], color: paint_color, thickness?: number = 1, outline?: paint_color = {0, 0, 0, 255}, outline_thickness?: number = 0)
Polygon outline. Same parameters and limits as poly.filled, plus thickness.
local paint = xiao.paint
local fill = paint.color.rgba(59, 130, 246, 160)
local edge = paint.color.rgb(255, 255, 255)
local top_left = paint.point.vector2(40, 40)
local size = paint.point.vector2(160, 60)
paint.add("panel", function()
paint.rect.rfilled(8, top_left, size, fill, edge, 1)
end)
Text
Text is outlined only when the outline argument is given. Text longer than 1024 bytes is cut. The font used is the current font, "default" unless you select another with text.font.
xiao.paint.text.left(point, text, color, outline)
xiao.paint.text.left(point: paint_point, text: string, color: paint_color, outline?: paint_color)
Draws text with point as its left edge and top.
Returns: nothing.
xiao.paint.text.center(point, text, color, outline)
xiao.paint.text.center(point: paint_point, text: string, color: paint_color, outline?: paint_color)
Same as text.left, point is the horizontal center.
xiao.paint.text.right(point, text, color, outline)
xiao.paint.text.right(point: paint_point, text: string, color: paint_color, outline?: paint_color)
Same as text.left, point is the right edge.
xiao.paint.text.get_size(text): table
xiao.paint.text.get_size(text: string): table
xiao.paint.text.get_size(font_name: string, text: string): table
Measures text with the current font, or with the named font when two arguments are given. Useful to center a box around some text.
Returns: {width, height} in pixels, measured with "default" when font_name is not baked; zero sizes inside a hook.
local size = xiao.paint.text.get_size("hello")
print(size.width, size.height)
xiao.paint.text.bake(font_name, font_data, size): boolean
xiao.paint.text.bake(font_name: string, font_data: string, size: number): boolean
Loads a TTF or OTF font from memory under font_name.
Returns: true on success or when font_name is already baked ("default" included, nothing is loaded again); false for bad data, an empty string, a size <= 0, or inside a hook.
Errors: bad argument #3 to 'bake' (finite number expected).
xiao.paint.text.unbake(font_name): boolean
xiao.paint.text.unbake(font_name: string): boolean
Frees a baked font. If it was the current font, the current font goes back to "default".
Returns: true if the font existed and was freed; false for "default", an unknown name, or inside a hook.
xiao.paint.text.isbaked(font_name): boolean
xiao.paint.text.isbaked(font_name: string): boolean
Returns: true if the font is baked. "default" is always baked.
xiao.paint.text.font(font_name): string
xiao.paint.text.font(font_name?: string): string
Selects the font used by the next text calls. Without argument, only reads the current name.
Returns: the current font name, after the change.
Errors: bad argument #1 to 'font' (font is not baked).
Baked fonts stay loaded until unbake, even across Clear VM. The current font goes back to "default" in a fresh VM.
local ok, data = pcall(xiao.fs.read, "lua/fonts/consola.ttf")
if not ok or not xiao.paint.text.bake("Consolas", data, 16) then
error("could not load the font")
end
local point = xiao.paint.point.vector2(40, 40)
local white = xiao.paint.color.rgb(255, 255, 255)
xiao.paint.add("hello", function()
xiao.paint.text.font("Consolas")
xiao.paint.text.left(point, "hello world", white)
xiao.paint.text.font("default")
end)
Images
An image is a userdata (tostring gives xiao.image(1)). It is freed automatically once Lua no longer references it, and on Clear VM.
xiao.paint.image.bake(data): image | false
xiao.paint.image.bake(data: string): image | false
Returns: an image, or false if the data is empty, cannot be decoded, or inside a hook.
xiao.paint.image.bake(width, height, rgba): image | false
xiao.paint.image.bake(width: number, height: number, rgba: string): image | false
Builds an image from raw pixels.
Returns: an image, or false if the size does not match or inside a hook.
xiao.paint.image.normal(image, top_left, size, color, outline)
xiao.paint.image.normal(image: image, top_left: paint_point, size: paint_point, color: paint_color, outline?: paint_color)
Draws the image.
Errors: bad argument #1 to 'normal' (xiao.image expected, got boolean) when bake returned false.
xiao.paint.image.round(image, rounding, top_left, size, color, outline)
xiao.paint.image.round(image: image, rounding: number, top_left: paint_point, size: paint_point, color: paint_color, outline?: paint_color)
Draws the image with rounded corners. rounding is the corner radius in pixels; the other parameters are those of image.normal.
local pixel = xiao.paint.image.bake(1, 1, "\255\0\0\255")
local origin = xiao.paint.point.vector2(50, 50)
local size = xiao.paint.point.vector2(64, 64)
local white = xiao.paint.color.rgb(255, 255, 255)
xiao.paint.add("square", function()
if pixel then xiao.paint.image.normal(pixel, origin, size, white) end
end)
Types
paint_point
paint_color
Good practice
- Create colors and points once outside the listener and reuse them.
- Mutate their fields (
color.r,center.x) instead of allocating new tables each frame. - Keep the listener cheap: it has 100 ms per frame, and an error removes it.
local paint = xiao.paint
local color = paint.color.rgba(255, 255, 255, 255)
local center = paint.point.vector2(0, 0)
local black = paint.color.rgb(0, 0, 0)
paint.add("pulse", function(w, h)
color.r = (os.clock() % 1) * 255
center.x, center.y = w / 2, h / 2
paint.circle.filled(center, color, 8, black, 1)
end)
Complete example
A small HUD: a rounded panel, a title, and a ring that follows a world position.
local paint = xiao.paint
local panel_fill = paint.color.rgba(20, 20, 28, 200)
local accent = paint.color.hex("#3b82f6")
local white = paint.color.rgb(255, 255, 255)
local panel_pos = paint.point.vector2(20, 20)
local panel_size = paint.point.vector2(180, 44)
local title_pos = paint.point.vector2(32, 34)
local target = paint.point.vector3(0, 70, 0)
paint.add("demo.hud", function(w, h)
paint.rect.rfilled(8, panel_pos, panel_size, panel_fill, accent, 1)
paint.text.left(title_pos, "XiaoHook", white)
paint.circle.hollow(target, accent, 6, 2)
end)
Remove it when you are done:
xiao.paint.listener.remove("demo.hud")