Skip to content

LuaUI Protocol

wezzzyrek1 edited this page Sep 24, 2026 · 2 revisions

Lua UI - Protocol

Two channels carry Lua messages. They use the same payload format and differ only in what addresses a message.

Channel Addressed by Use it for
window window id + action anything belonging to a window
general opcode systems with no window - HUD feeds, counters, notifications

Where the numbers live

Client UI\Protocol.lua and server Defines\UIProtocol.lua hold one table per system: its actions, its result codes, its limits. Both files must carry identical values.

Keep a system's constants inside its own table. Two windows can then never disagree about what 2 means, and you can read a handler without hunting for a global.

P.Exchange = {
	id = R.win.exchange,

	RATE    = 1,
	CONVERT = 2,
	HISTORY = 3,

	MIN = 100,
	MAX = 100000,

	TICKER_OP = 110,   -- general channel: rate + today's allowance

	RESULT = {
		OK         = 0,
		BAD_AMOUNT = 10,
		NO_FUNDS   = 11,
		LIMIT      = 12,
	},
}

Result codes

Every answer carries a result byte. Zero always means success, on both channels and in every system - an open request is granted only on 0.

Beyond that the numbering is yours. A convention that pays off once a system grows: group codes by what the player can do about them.

RESULT = {
	OK          = 0,

	DISABLED    = 10,   -- feature is off; nothing the player can do
	NOT_READY   = 11,

	BUSY        = 20,   -- try again in a moment
	IN_TRADE    = 21,

	NO_FUNDS    = 30,   -- the player can fix this
	BAD_AMOUNT  = 31,
}

Grouping by remedy rather than by internal cause means the client can map a whole band to one message without a case for every code, and adding a code later does not break an older client.

Payload types

Both sides use the same primitives. All integers are unsigned and little-endian.

Type Bytes Notes
byte 1 0 to 255
word 2 0 to 65535
dword 4 0 to 4294967295
qword 8 exact up to 2^53 - see below
float 4 ~7 significant digits
double 8 full precision - the safe choice for a real number
text 2 + n length-prefixed, any bytes
str n fixed width, zero padded

Two traps worth stating plainly:

  • float is not precise enough for money. 1500000123 comes back as 1500000128. Use dword/qword for whole numbers and double for real ones.
  • qword is exact only to 2^53. The client runs LuaJIT (Lua 5.1), where every number is a double, so the server's own 64-bit integers survive only as far as the wire. That range still covers experience, zen and tick counts. Item serials use all 64 bits - send those as two dwords or as text.

Writing and reading

Client, outgoing:

net.send(S.id, S.CONVERT)
	:dword(amount)
	:text(note)
	:send()

The packet sizes itself from what you appended; :send() finishes it.

Client, incoming:

net.on(S.id, S.RATE, function(result, reader, iParam, windowId)
	local rate = reader:dword()
end)

Server, incoming and outgoing:

UIWindow.On(S.id, S.CONVERT, function(oPlayer, reader, iParam)
	local amount = reader:dword()

	local w = UIPacket.Writer()
	w:dword(newBalance)

	UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.OK, w)
end)

Reader methods on both sides: byte word dword qword float double text str(len), plus remaining() and, on the client, seek(pos).

A short read returns 0 or "" rather than raising, so a truncated or forged packet cannot take a handler down. A reader handing back zeros usually means the two sides disagree about the field order - on the client the console says so, with the position and the size it wanted.

Payloads are optional. Send a bare result when there is nothing to report:

UIWindow.Send(oPlayer, S.id, S.CONVERT, S.RESULT.NO_FUNDS)

and check before reading when the answer may be empty:

net.on(S.id, S.CONVERT, function(result, reader)
	if reader:remaining() >= 4 then
		local balance = reader:dword()
	end
end)

The general channel

For anything without a window of its own, addressed by an opcode instead of a window id.

Opcodes are global to the client. Unlike an action, which is scoped to its window, one opcode means one thing across every script on the machine - two systems that both pick 100 will read each other's packets. So put the number in the system's protocol table with the rest of its constants, where the next person can see it is taken:

P.Exchange = {
	...
	TICKER_OP = 110,   -- general channel: rate + today's allowance
}

The shipped scripts take 100 and 101 (the demo), 110 (the Coin Exchange ticker) and 120 (windows opened from an NPC).

The running example's ticker is exactly this case: it needs the current rate, but it is a HUD panel with no window channel of its own.

Client - ask once on login, then take pushes as they come:

Net.OnPacket(S.TICKER_OP, function(reader)
	Exchange.rate       = reader:dword()
	Exchange.usedToday  = reader:dword()
	Exchange.dailyLimit = reader:dword()
end)

Event.on("ingame", function()
	Net.CreatePacket(S.TICKER_OP):send()
end)

Server - the same constant, read from the mirrored table:

function Exchange.SendTicker(oPlayer)
	local w = UIPacket.Writer()
	w:dword(Exchange.rate)
	w:dword(Exchange.UsedToday(oPlayer))
	w:dword(Exchange.DAILY_LIMIT)

	Net.Send(oPlayer, S.TICKER_OP, w)
end

Net.On(S.TICKER_OP, function(oPlayer, reader)
	Exchange.SendTicker(oPlayer)
end)

Nothing stops the server calling Exchange.SendTicker on its own - after a conversion, or for every player when an admin changes the rate. A push does not have to answer a request.

Note the capitalisation: Net.CreatePacket / Net.OnPacket are the client's own global table, while net.send / net.on (lowercase local from require("Net")) are the window channel. Mixing them up sends a window message to nobody.

The raw window-channel calls

Under net.send and net.on sit two globals:

Net.WindowUse(windowId, iParam)   -- returns a writer, same as net.send
Net.OnWindowUse(fn)               -- installs THE single incoming dispatcher

Net.WindowUse is harmless - net.send is literally a call to it.

Do not call Net.OnWindowUse from a window script. There is one dispatcher for the whole client and UI\Net.lua owns it; installing your own replaces it and every other window stops receiving answers. Register with net.on instead, which routes through that one dispatcher.

net.off(windowId [, action]) removes a handler again - omit the action to drop the window's fallback.

What does not belong on the wire

Catalogue data - names, prices, colours, capacities - should live in a file on each side rather than being sent on every open. Ship the client a .bmd and the server an .xml describing the same catalogue, and let the channel carry only what changes: the player's own state.

That keeps the packets small, survives a player opening a window twenty times, and means a price change is a file edit rather than a protocol change.

Text and encoding

text and str carry bytes exactly as given. The channel does not know whether a field is a name, a token or a serial, so it does not convert anything.

Scripts and the renderer work in UTF-8. Where a value has to match the game's own structures or a database column, convert it at that point:

input.textCodePage      -- the field's text in the client's codepage
UI.ToCodePage(s)        -- any string; returns the text and a lossy flag
UI.FromCodePage(s)      -- back to UTF-8, for text that came from the game or DB
UI.CodePage()           -- the codepage number in force

Converting everything "just in case" is the wrong default - it corrupts anything that was never text in the first place.

See Also

Clone this wiki locally